Files
wanderbooks/crates/ledger-store/tests/parties.rs
T
swaits d5b9e5cf99 feat(mcp): the create tools preview and confirm, and the party keeps its contact block (P4b S4.4)
An invoice is issued, not edited. Without `confirmed` the create tools now
preview: the posting (with the accounts' codes, which the live chart may
not hold yet), the number it will take, its due date, terms and early
discount, warnings, and for an invoice the DRAFT copy of the PDF, handed
over as a file. Nothing is written and no number is used up. With
`confirmed` the invoice posts, takes the number the preview showed, and
files the issued copy with itself in the same call: `receipt.attached` on
its entry plus the link to the invoice, its SHA-256 in the event (A-1,
A-7, B-40). The copy the customer received is therefore the copy in the
books, and `books.document`'s re-created page can never be mistaken for
it. The result also carries a `mailto` link for a human to send it; the
books never send (A-4).

A bill keeps what the plan says a bill is: the vendor's own invoice
number, the vendor's invoice filed on its entry as its evidence, and, in
books that have declared `documents_v2`, an account on every line, so no
bill can land in General Expenses by default any more (A-9, A-10, A-13).
A vendor number entered twice is refused unless the caller says why, and
the reason is journaled with the second bill (B-40 item 4). Books that
have not declared the feature keep recording bills exactly as before, so
the owner's books keep working between units (D-39, 8.0.4).

Terms are resolved in one place and in one order: the due date given,
else the terms given, else the customer's or vendor's own default, else
the books' `invoicing.default_terms`; a document given only a due date
stores no terms, so an old-shape invoice is still written byte for byte
as before (B-39, A-13).

D-55: a party carries the owner's full contact block (phone, contact
person, billing and shipping addresses, billing email). It rides the same
`party.updated` event behind `documents_v2`, and an invoice's bill-to is
snapshotted at issue with the billing address and billing email in place
of the general ones when the party has them — `PartySnapshot::from_party`
is the one place that rule lives, so a later edit cannot change what was
sent, and the next invoice takes today's details.

Tests: the wire tests are the new `crates/ledger-cli/tests/documents.rs`
(preview writes nothing, the DRAFT copy, the issued copy's SHA-256 equals
`books.get_receipt`'s, numbers run, a bill's file and terms, the duplicate
refusal, the line-account rules, the terms fallback, the `party_id` alias
and the ambiguity it refuses, the contact block's set and clear, and the
bill-to at issue); `format_upgrade.rs` gains
`documents_v2_refused_until_consent` and the cut-over test now refuses and
then makes a document carrying a new field (8.0.6 item 3); the store
tests are `the_contact_block_round_trips_and_replays` and
`an_invoice_keeps_the_billing_details_it_was_issued_with`, and
`books_written_under_older_rules_still_open` gains a hand-written
contact-block event (8.0.6 item 1).

Merge does not need the owner's go-ahead: the live books refuse a write
that needs `documents_v2` until he gives it, and take every other write as
before (8.0.6 item 6). No figure moved: S4 moves none.
2026-10-27 12:23:32 -06:00

1278 lines
45 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::{JOURNAL_FILE, JournalWriter, Store, db_file};
use rust_decimal::Decimal;
use serde_json::{Value, json};
use std::path::{Path, PathBuf};
use std::str::FromStr;
// ---------------------------------------------------------------- helpers
/// A test's dir, removed when the test ends, pass or panic.
struct TestDir(PathBuf);
impl std::ops::Deref for TestDir {
type Target = std::path::Path;
fn deref(&self) -> &std::path::Path {
&self.0
}
}
impl AsRef<std::path::Path> for TestDir {
fn as_ref(&self) -> &std::path::Path {
&self.0
}
}
impl Drop for TestDir {
fn drop(&mut self) {
let _ = std::fs::remove_dir_all(&self.0);
}
}
fn test_dir(name: &str) -> TestDir {
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();
TestDir(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!("{}{suffix}", db_file())));
}
}
/// 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("Tobin", &[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: P4b S4.2 (A-13, rules.md B-39, R-A step 3): a party's email and
/// default terms are `party.updated` facts the `documents_v2` feature
/// guards. In books that haven't declared it they are refused, journaling
/// nothing; after the upgrade they journal, replay gives them back, and an
/// email in no plain address shape or holding a full TIN is refused.
#[test]
fn a_party_takes_an_email_and_terms_under_documents_v2() {
let dir = test_dir("email-terms");
// Books as an older version wrote them: no features declared, as the
// owner's live books are until the go-ahead (D-39).
let mut s =
Store::create_in_format(&dir, Currency::USD, &ledger_store::JournalFormat::default())
.unwrap();
let id = s.add_party("Acme", &[]).unwrap();
let net = ledger_core::Terms::parse("Net 30").unwrap();
let early = ledger_core::Terms::parse("2/10 net 30").unwrap();
let fields = [
PartyField::Email(Some("billing@acme.example".into())),
PartyField::DefaultTermsAsCustomer(Some(net.clone())),
PartyField::DefaultTermsAsVendor(Some(early.clone())),
];
for field in &fields {
refused_nothing_journaled(&mut s, &dir, &format!("{field:?} before consent"), |s| {
s.update_party(id, field.clone())
});
}
assert!(s.upgrade_format().unwrap().is_some(), "the go-ahead");
let ((), p) = one_event(&mut s, &dir, "party.updated", |s| {
s.update_party(id, fields[0].clone()).unwrap()
});
assert_eq!(
p["change"],
json!({"field": "email", "value": "billing@acme.example"})
);
let ((), p) = one_event(&mut s, &dir, "party.updated", |s| {
s.update_party(id, fields[1].clone()).unwrap()
});
assert_eq!(p["change"]["field"], json!("default_terms_as_customer"));
assert_eq!(
serde_json::from_value::<ledger_core::Terms>(p["change"]["value"].clone()).unwrap(),
net,
"the journal carries the terms whole"
);
let ((), p) = one_event(&mut s, &dir, "party.updated", |s| {
s.update_party(id, fields[2].clone()).unwrap()
});
assert_eq!(p["change"]["field"], json!("default_terms_as_vendor"));
assert_eq!(
serde_json::from_value::<ledger_core::Terms>(p["change"]["value"].clone()).unwrap(),
early,
);
let got = s.ledger().get_party(id).unwrap();
assert_eq!(got.email.as_deref(), Some("billing@acme.example"));
assert_eq!(
got.default_terms_as_customer.as_ref().map(|t| t.describe()),
Some("Net 30".to_string())
);
assert_eq!(
got.default_terms_as_vendor.as_ref().map(|t| t.describe()),
Some("2/10 net 30".to_string())
);
// Refusals, journaling nothing: no plain address shape, or a full TIN.
for bad in [
"billing",
"a b@example.com",
"a@@example.com",
"123-45-6789",
] {
refused_nothing_journaled(&mut s, &dir, &format!("email {bad:?}"), |s| {
s.update_party(id, PartyField::Email(Some(bad.into())))
});
}
// `None` clears them, and the books replay to the same directory.
s.update_party(id, PartyField::Email(None)).unwrap();
s.update_party(id, PartyField::DefaultTermsAsVendor(None))
.unwrap();
s.verify().unwrap();
let live = s.ledger().clone();
drop(s);
for without_db in [false, true] {
if without_db {
delete_db(&dir);
}
let s = Store::open(&dir).unwrap();
assert!(
*s.ledger() == live,
"replay (db {}): {:?}",
!without_db,
s.ledger().differs_from(&live)
);
let got = s.ledger().get_party(id).unwrap();
assert_eq!(got.email, None);
assert_eq!(got.default_terms_as_vendor, None);
assert_eq!(
got.default_terms_as_customer.as_ref().map(|t| t.describe()),
Some("Net 30".to_string())
);
s.verify().unwrap();
}
}
/// 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("Mo", &[]).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);
}
// ------------------------------------------------------- party shares
/// Oracle: Store::set_entry_parties doc (`entry.parties_set {entry_id,
/// parties: [{party_id, share}]}`, a share `{"amount": "30.00"}`,
/// `{"percent": "60"}`, or `"derived"`; refusals journal nothing) and
/// Store::verify (a fresh replay equals the live ledger).
mod shares {
use super::*;
use ledger_core::{PartyShare, Share};
fn share(party: PartyId, share: Share) -> PartyShare {
PartyShare { party, share }
}
#[test]
fn set_entry_parties_journals_one_event_with_each_share() {
let dir = test_dir("shares-event");
let (mut s, fx) = fixture(&dir);
let a = s.add_party("Tobin Harlow", &[Role::Member]).unwrap();
let b = s.add_party("Morgan Ellis", &[Role::Member]).unwrap();
let ((), pl) = one_event(&mut s, &dir, "entry.parties_set", |s| {
s.set_entry_parties(fx.entry, &[PartyShare::whole(a), PartyShare::whole(b)])
.unwrap()
});
assert_eq!(
pl,
json!({"entry_id": fx.entry, "parties": [
{"party_id": a, "share": "derived"},
{"party_id": b, "share": "derived"},
]})
);
let ((), pl) = one_event(&mut s, &dir, "entry.parties_set", |s| {
s.set_entry_parties(
fx.entry,
&[
share(a, Share::Amount(dec("300.00"))),
share(b, Share::Amount(dec("200.00"))),
],
)
.unwrap()
});
assert_eq!(
pl,
json!({"entry_id": fx.entry, "parties": [
{"party_id": a, "share": {"amount": "300.00"}},
{"party_id": b, "share": {"amount": "200.00"}},
]})
);
let ((), pl) = one_event(&mut s, &dir, "entry.parties_set", |s| {
s.set_entry_parties(
fx.entry,
&[
share(a, Share::Percent(dec("60"))),
share(b, Share::Percent(dec("40"))),
],
)
.unwrap()
});
assert_eq!(
pl["parties"][0]["share"],
json!({"percent": "60"}),
"percent shares journal as strings"
);
assert_eq!(s.ledger().entry_parties(fx.entry).len(), 2);
assert_eq!(s.ledger().entry_party(fx.entry), None);
}
#[test]
fn refusals_journal_nothing() {
let dir = test_dir("shares-refused");
let (mut s, fx) = fixture(&dir);
let a = s.add_party("A", &[Role::Member]).unwrap();
let b = s.add_party("B", &[Role::Member]).unwrap();
let v1 = s.add_vendor("V1").unwrap();
let v2 = s.add_vendor("V2").unwrap();
let (p1, p2) = (
s.ledger().party_of_vendor(v1).unwrap(),
s.ledger().party_of_vendor(v2).unwrap(),
);
let cases: Vec<(&str, EntryId, Vec<PartyShare>)> = vec![
("unknown entry", fx.entry + 99, vec![PartyShare::whole(a)]),
("unknown party", fx.entry, vec![PartyShare::whole(999)]),
(
"duplicate",
fx.entry,
vec![PartyShare::whole(a), PartyShare::whole(a)],
),
(
"mixed",
fx.entry,
vec![share(a, Share::Percent(dec("50"))), PartyShare::whole(b)],
),
(
"percent sum",
fx.entry,
vec![
share(a, Share::Percent(dec("50"))),
share(b, Share::Percent(dec("40"))),
],
),
(
"sub-cent",
fx.entry,
vec![
share(a, Share::Amount(dec("250.001"))),
share(b, Share::Amount(dec("249.999"))),
],
),
(
"two vendors",
fx.entry,
vec![
share(p1, Share::Amount(dec("250.00"))),
share(p2, Share::Amount(dec("250.00"))),
],
),
];
for (what, e, shares) in cases {
refused_nothing_journaled(&mut s, &dir, what, |s| s.set_entry_parties(e, &shares));
}
}
#[test]
fn replay_gives_equal_state() {
let dir = test_dir("shares-replay");
let (mut s, fx) = fixture(&dir);
let a = s.add_party("Tobin Harlow", &[Role::Member]).unwrap();
let b = s.add_party("Morgan Ellis", &[Role::Member]).unwrap();
let v = s.add_vendor("Contractor").unwrap();
let pv = s.ledger().party_of_vendor(v).unwrap();
s.set_entry_parties(
fx.entry,
&[
share(a, Share::Amount(dec("300.00"))),
share(b, Share::Amount(dec("200.00"))),
],
)
.unwrap();
// A second entry shared with a vendor party, then reversed.
let other = s.create_entry(date(2026, 2, 1), "Split work").unwrap();
let exp = s
.add_account("6000", "Work", AccountType::Expense, None, false)
.unwrap();
s.add_line(other, exp, Side::Debit, usd("90.00")).unwrap();
s.add_line(other, fx.cash, Side::Credit, usd("90.00"))
.unwrap();
s.post_entry(other).unwrap();
s.set_entry_parties(
other,
&[
share(pv, Share::Percent(dec("66.67"))),
share(a, Share::Percent(dec("33.33"))),
],
)
.unwrap();
assert_eq!(s.ledger().entry_vendor(other), Some(v));
let r = s.reverse_entry(other, date(2026, 2, 2), "undo").unwrap();
assert_eq!(s.ledger().entry_parties(r), s.ledger().entry_parties(other));
// An old-style single tag after a multi-party one.
let third = s.create_entry(date(2026, 3, 1), "Draft").unwrap();
s.set_entry_parties(third, &[PartyShare::whole(a), PartyShare::whole(b)])
.unwrap();
s.set_entry_party(third, Some(b)).unwrap();
s.verify().unwrap();
let live = s.ledger().clone();
drop(s);
for db in [true, false] {
if !db {
delete_db(&dir);
}
let s = Store::open(&dir).unwrap();
assert!(
*s.ledger() == live,
"replay (db {db}): {:?}",
s.ledger().differs_from(&live)
);
assert_eq!(s.ledger().entry_parties(r).len(), 2);
assert_eq!(s.ledger().entry_party(third), Some(b));
s.verify().unwrap();
}
}
}
/// Oracle: D-55 (the owner's full contact block): a party's phone, contact
/// person, billing and shipping addresses and billing email are
/// `party.updated` facts under `documents_v2`; they journal one event
/// apiece, replay gives them back, and the live ledger and a fresh replay
/// agree.
#[test]
fn the_contact_block_round_trips_and_replays() {
let dir = test_dir("contact-block");
let mut s = Store::create(&dir, Currency::USD).unwrap();
let id = s.add_party("Harbor Coffee", &[]).unwrap();
let fields = [
(
PartyField::Phone(Some("555-0142".into())),
json!({"field": "phone", "value": "555-0142"}),
),
(
PartyField::Contact(Some("Dana Reed".into())),
json!({"field": "contact", "value": "Dana Reed"}),
),
(
PartyField::BillingAddress(Some("12 Billing Way\nPortland, OR 97209".into())),
json!({"field": "billing_address",
"value": "12 Billing Way\nPortland, OR 97209"}),
),
(
PartyField::ShippingAddress(Some("Dock 4, 9 Wharf Street".into())),
json!({"field": "shipping_address", "value": "Dock 4, 9 Wharf Street"}),
),
(
PartyField::BillingEmail(Some("ap@harbor.example".into())),
json!({"field": "billing_email", "value": "ap@harbor.example"}),
),
];
for (field, want) in &fields {
let ((), p) = one_event(&mut s, &dir, "party.updated", |s| {
s.update_party(id, field.clone()).unwrap()
});
assert_eq!(p["change"], *want, "{field:?}");
}
let got = s.ledger().get_party(id).unwrap();
assert_eq!(got.phone.as_deref(), Some("555-0142"));
assert_eq!(got.contact.as_deref(), Some("Dana Reed"));
assert_eq!(
got.billing_address.as_deref(),
Some("12 Billing Way\nPortland, OR 97209")
);
assert_eq!(
got.shipping_address.as_deref(),
Some("Dock 4, 9 Wharf Street")
);
assert_eq!(got.billing_email.as_deref(), Some("ap@harbor.example"));
// The general email is its own fact, and neither replaces the other.
s.update_party(id, PartyField::Email(Some("hello@harbor.example".into())))
.unwrap();
// A full TIN in the block, or an address that isn't one, is refused.
for field in [
PartyField::Phone(Some("123-45-6789".into())),
PartyField::BillingEmail(Some("not an address".into())),
] {
refused_nothing_journaled(&mut s, &dir, &format!("{field:?}"), |s| {
s.update_party(id, field.clone())
});
}
s.verify().unwrap();
let live = s.ledger().clone();
drop(s);
for without_db in [false, true] {
if without_db {
delete_db(&dir);
}
let s = Store::open(&dir).unwrap();
assert!(
*s.ledger() == live,
"replay (db {}): {:?}",
!without_db,
s.ledger().differs_from(&live)
);
let got = s.ledger().get_party(id).unwrap();
assert_eq!(got.contact.as_deref(), Some("Dana Reed"));
assert_eq!(got.billing_email.as_deref(), Some("ap@harbor.example"));
s.verify().unwrap();
}
// `None` clears each, and the journal keeps what they were.
let mut s = Store::open(&dir).unwrap();
for field in [
PartyField::Phone(None),
PartyField::Contact(None),
PartyField::BillingAddress(None),
PartyField::ShippingAddress(None),
PartyField::BillingEmail(None),
] {
s.update_party(id, field).unwrap();
}
let got = s.ledger().get_party(id).unwrap();
assert!(got.phone.is_none() && got.contact.is_none());
assert!(got.billing_address.is_none() && got.shipping_address.is_none());
assert!(got.billing_email.is_none());
s.verify().unwrap();
}
/// Oracle: D-55 and A-13: an invoice's bill-to snapshot is the customer's
/// contact block at issue, with the billing address and billing email in
/// place of the general ones when it has them
/// (`PartySnapshot::from_party`, the one place that rule lives); a later
/// party edit does not change the issued snapshot.
#[test]
fn an_invoice_keeps_the_billing_details_it_was_issued_with() {
use ledger_core::arap::PartySnapshot;
let dir = test_dir("contact-snapshot");
let mut s = Store::create(&dir, Currency::USD).unwrap();
let customer = s.add_customer("Harbor Coffee").unwrap();
let party = s.ledger().party_of_customer(customer).unwrap();
for field in [
PartyField::Address(Some("PO Box 9, Portland, OR 97209".into())),
PartyField::Email(Some("hello@harbor.example".into())),
PartyField::Phone(Some("555-0142".into())),
PartyField::Contact(Some("Dana Reed".into())),
PartyField::BillingAddress(Some("12 Billing Way, Portland, OR 97209".into())),
PartyField::BillingEmail(Some("ap@harbor.example".into())),
PartyField::ShippingAddress(Some("Dock 4, 9 Wharf Street".into())),
] {
s.update_party(party, field).unwrap();
}
let snapshot = PartySnapshot::from_party(s.ledger().get_party(party).unwrap());
assert_eq!(
snapshot.address.as_deref(),
Some("12 Billing Way, Portland, OR 97209"),
"the billing address, not the general one"
);
assert_eq!(snapshot.email.as_deref(), Some("ap@harbor.example"));
assert_eq!(snapshot.contact.as_deref(), Some("Dana Reed"));
assert_eq!(snapshot.phone.as_deref(), Some("555-0142"));
assert_eq!(
snapshot.shipping_address.as_deref(),
Some("Dock 4, 9 Wharf Street")
);
assert_eq!(
snapshot.billing_address.as_deref(),
Some("12 Billing Way, Portland, OR 97209")
);
let line = ledger_core::DocumentLine {
description: "Coffee".into(),
amount: dec("100.00"),
class_id: None,
account: None,
quantity: None,
unit_price: None,
};
let id = s
.create_invoice_with(
customer,
date(2026, 2, 1),
date(2026, 3, 3),
vec![line],
ledger_core::arap::InvoiceFields {
bill_to: Some(snapshot.clone()),
..ledger_core::arap::InvoiceFields::default()
},
)
.unwrap();
// The event carries it, so replay can never lose it.
let event = journal(&dir)
.into_iter()
.rev()
.find(|(t, _)| t == "invoice.created")
.map(|(_, p)| p)
.expect("invoice.created");
assert_eq!(
event["bill_to"],
json!({
"name": "Harbor Coffee",
"address": "12 Billing Way, Portland, OR 97209",
"email": "ap@harbor.example",
"phone": "555-0142",
"contact": "Dana Reed",
"billing_address": "12 Billing Way, Portland, OR 97209",
"shipping_address": "Dock 4, 9 Wharf Street",
"billing_email": "ap@harbor.example",
}),
"{event}"
);
// A later edit changes the record, never the issued copy.
s.update_party(party, PartyField::BillingAddress(Some("99 New Way".into())))
.unwrap();
s.update_party(party, PartyField::Phone(Some("555-9999".into())))
.unwrap();
let kept = s.ledger().get_invoice(id).unwrap().bill_to.clone().unwrap();
assert_eq!(kept, snapshot, "an issued snapshot never changes");
// And the books come back to it exactly.
s.verify().unwrap();
let live = s.ledger().clone();
drop(s);
let s = Store::open(&dir).unwrap();
assert_eq!(
s.ledger().get_invoice(id).unwrap().bill_to.clone().unwrap(),
snapshot
);
assert!(*s.ledger() == live);
// The party's own record took the edit.
assert_eq!(
s.ledger()
.get_party(party)
.unwrap()
.billing_address
.as_deref(),
Some("99 New Way")
);
}