Files
wanderbooks/crates/ledger-store/tests/party_tin.rs
T
swaitsandClaude Opus 5.5 4c74169d18
check / check (push) Canceled after 0s
Shared parties with exact shares, member capital statements, TINs for any party, K-1 worksheet
Entries may name several parties, each with an exact amount, a percent,
or a share derived from the members' capital lines or [[company.members]]
shares (entry.parties_set); shares always sum exactly. The 1099
worksheet counts a vendor's share. A member's party page is their
partner capital statement, from the same ledger_core::k1 source as the
CPA package.

books.set_tin stores any party's TIN (members' SSNs for K-1s) in the
vault (party.tin_set, last four in the books). The CPA package carries a
K-1 member worksheet (k1-members.csv, with a members' total that ties to
Schedule L) and the 1099 worksheet with full TINs, saved outside the
books only. files_jointly_with notes spouses' two K-1s land on one 1040.

Adversarial review fixes: reversed contributions no longer inflate K-1
contributions and distributions; a K-1 row never takes another party's
TIN; TIN detection covers dot/slash/dash separators; refusals redact
TINs; vendor tagging can't silently replace shared parties; reversals
follow re-apportioning.

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

145 lines
5.1 KiB
Rust

//! Spec-derived tests for a party's taxpayer-ID facts in the store.
//!
//! Oracles: `Store::set_party_tin` ("journaled as `party.tin_set`" {id,
//! facts}), the `ledger_core::parties` spec (INVARIANTS 1, 2, 2a: any role,
//! never a full TIN, the TIN and W-9 facts agree), and `Store::verify` (a
//! fresh replay equals the live ledger). Values worked by hand.
use chrono::NaiveDate;
use ledger_core::{Currency, Role, TinFacts, TinType, W9Facts};
use ledger_store::journal::read_and_verify_journal;
use ledger_store::{DB_FILE, JOURNAL_FILE, JournalWriter, Store};
use serde_json::{Value, json};
use std::path::{Path, PathBuf};
fn test_dir(name: &str) -> PathBuf {
let dir = std::env::temp_dir().join(format!("wb-party-tin-{name}-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(&dir).unwrap();
dir
}
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 tin(last4: &str) -> TinFacts {
TinFacts {
tin_type: TinType::Ssn,
last4: last4.into(),
vault_record: Some("ab".repeat(32)),
purged: None,
}
}
fn w9(last4: &str) -> W9Facts {
W9Facts {
received: NaiveDate::from_ymd_opt(2026, 1, 5).unwrap(),
entity_type: "individual/sole proprietor".into(),
exempt_payee_code: None,
fatca_code: None,
tin_last4: last4.into(),
kept_at: "wanderbooks vault".into(),
vault_record: None,
}
}
fn delete_db(dir: &Path) {
for suffix in ["", "-wal", "-shm", "-journal"] {
let _ = std::fs::remove_file(dir.join(format!("{DB_FILE}{suffix}")));
}
}
/// Any role, journaled as one `party.tin_set {id, facts}` line, replayed
/// on open (with and without the database), and cleared with null.
#[test]
fn tin_facts_journal_and_replay_for_any_role() {
let dir = test_dir("replay");
let mut s = Store::create(&dir, Currency::USD).unwrap();
let member = s.add_party("Kat", &[Role::Member]).unwrap();
let nobody = s.add_party("Plain", &[]).unwrap();
s.set_party_tin(member, Some(tin("4915"))).unwrap();
s.set_party_tin(nobody, Some(tin("1234"))).unwrap();
s.set_party_tin(nobody, None).unwrap();
let j = journal(&dir);
let tins: Vec<&(String, Value)> = j.iter().filter(|(t, _)| t == "party.tin_set").collect();
assert_eq!(tins.len(), 3);
assert_eq!(tins[0].1["id"], json!(member));
assert_eq!(tins[0].1["facts"]["last4"], "4915");
assert_eq!(tins[0].1["facts"]["tin_type"], "ssn");
assert!(tins[2].1["facts"].is_null());
s.verify().unwrap();
drop(s);
for without_db in [false, true] {
if without_db {
delete_db(&dir);
}
let s = Store::open(&dir).unwrap();
assert_eq!(s.ledger().get_party(member).unwrap().tin, Some(tin("4915")));
assert_eq!(s.ledger().get_party(nobody).unwrap().tin, None);
}
}
/// The TIN and W-9 facts never disagree (INVARIANT 2a), and a refusal
/// journals nothing.
#[test]
fn tin_and_w9_facts_agree() {
let dir = test_dir("agree");
let mut s = Store::create(&dir, Currency::USD).unwrap();
let c = s.add_party("Acme", &[Role::Contractor]).unwrap();
s.set_party_w9(c, Some(w9("2846"))).unwrap();
let before = journal(&dir).len();
assert!(s.set_party_tin(c, Some(tin("1111"))).is_err());
assert!(s.set_party_tin(c, Some(tin("12"))).is_err(), "four digits");
assert!(
s.set_party_tin(
c,
Some(TinFacts {
purged: Some("571932846".into()),
..tin("2846")
})
)
.is_err(),
"never a full TIN"
);
assert!(s.set_party_tin(99, Some(tin("2846"))).is_err());
assert_eq!(journal(&dir).len(), before);
s.set_party_tin(c, Some(tin("2846"))).unwrap();
assert!(s.set_party_w9(c, Some(w9("1111"))).is_err());
// Cleared first, the W-9 may change, then the TIN facts follow.
s.set_party_tin(c, None).unwrap();
s.set_party_w9(c, Some(w9("1111"))).unwrap();
s.set_party_tin(c, Some(tin("1111"))).unwrap();
s.verify().unwrap();
}
/// A `party.tin_set` line that can't replay is refused on open.
#[test]
fn corrupt_tin_payloads_are_refused_on_open() {
for (i, facts) in [
json!({"tin_type": "passport", "last4": "1234"}),
json!({"tin_type": "ssn", "last4": "123456789"}),
json!({"tin_type": "ssn", "last4": "1234", "vault_record": "nothex"}),
json!({"last4": "1234"}),
]
.into_iter()
.enumerate()
{
let dir = test_dir(&format!("corrupt-{i}"));
let mut s = Store::create(&dir, Currency::USD).unwrap();
let id = s.add_party("A", &[Role::Member]).unwrap();
drop(s);
let mut w = JournalWriter::open(&dir.join(JOURNAL_FILE)).unwrap();
w.append("party.tin_set", json!({"id": id, "facts": facts}))
.unwrap();
drop(w);
assert!(Store::open(&dir).is_err(), "{facts} opened");
delete_db(&dir);
assert!(Store::open(&dir).is_err(), "{facts} opened without the db");
}
}