//! Spec-blind tests for the TIN vault tools of the books server, driven //! through a real `wanderbooks server` as an agent reaches it (common.rs). //! //! Expected behavior comes only from the tool descriptions and parameter / //! result doc comments in `src/server.rs` and `src/types.rs`, //! `docs/mcp-interface.md` ("Tools — the TIN vault"), and //! `docs/parties-and-attachments.md` ("The TIN vault"). The rule above all //! (DECISION, owner, 2026-10-15): a full TIN is never in the journal, the //! SQLite projection, the viewer, or any tool output, except the 1099 //! worksheet for the CPA when asked (`books.vendor_1099` with //! `include_tins`); and the vault holds everyone's, members included, for //! the CPA package, which carries them only in the zip it hands over, //! never kept in the books' folder but as the file handed over (DECISION, owner, 2026-10-15: "In the vault, that's the //! WHOLE reason we built it. And it needs to be in the damn CPA //! package."). The harness is `tests/spec_parties.rs`'s, with a //! transcript of every response and the server's stderr kept; the vault's //! key is the book's own `keys/legacy.key` in the test's data root. use common::{Place, Wire}; use serde_json::{Value, json}; use std::collections::HashMap; use std::path::{Path, PathBuf}; use std::time::Duration; mod common; const TIMEOUT: Duration = common::TIMEOUT; // ---------------------------------------------------------------- harness #[derive(Debug, Clone)] enum Out { Ok(Value), ToolErr(String), RpcErr(Value), } impl Out { fn refused(&self) -> bool { !matches!(self, Out::Ok(_)) } fn err_text(&self) -> String { match self { Out::Ok(v) => panic!("expected a refusal, got {v}"), Out::ToolErr(t) => t.clone(), Out::RpcErr(e) => e.to_string(), } } } struct Mcp { wire: Wire, next_id: u64, data: PathBuf, validators: HashMap, /// Every response line the server wrote, with the tool it answered. transcript: Vec<(String, String)>, /// The test's place, when the session owns it (its server is closed /// and its home removed after the session). books_dir: Option, } /// A test's place (common.rs): its home, its server, its books. type TestDir = Place; fn test_dir(name: &str) -> TestDir { Place::new(name) } /// `wanderbooks.toml` with a company name, the receipt threshold, and /// extra `[books]` TOML. fn books_toml(dir: &Path, threshold: &str, books_extra: &str) -> PathBuf { let path = dir.join("wanderbooks.toml"); std::fs::write( &path, format!( "[company]\nname = \"Spec Co\"\nfiscal_year_start = \"01-01\"\n\ [books]\nreceipt_threshold = \"{threshold}\"\ncash_accounts = [\"1000\"]\n{books_extra}\n" ), ) .unwrap(); path } impl Mcp { fn start(dir: &TestDir, toml: &Path) -> Self { let wire = dir.start(Some(&std::fs::read_to_string(toml).unwrap())); let mut mcp = Self { books_dir: None, wire, next_id: 1, data: dir.books_data(), validators: HashMap::new(), transcript: Vec::new(), }; let init = mcp.rpc( "initialize", json!({ "protocolVersion": "2025-06-18", "capabilities": {}, "clientInfo": {"name": "spec-vault", "version": "0.1.0"}, }), ); assert!(init.get("result").is_some(), "initialize failed: {init}"); mcp.send_line( &json!({"jsonrpc": "2.0", "method": "notifications/initialized"}).to_string(), ); let list = mcp.rpc("tools/list", json!({})); for tool in list["result"]["tools"].as_array().expect("tools") { let name = tool["name"].as_str().unwrap().to_string(); let validator = jsonschema::validator_for(&tool["outputSchema"]) .unwrap_or_else(|e| panic!("{name}'s outputSchema is not valid: {e}")); mcp.validators.insert(name, validator); } mcp } fn journal(&self) -> Vec { std::fs::read(self.data.join("journal.jsonl")).unwrap_or_default() } /// The journal's line types, in order. fn journal_types(&self) -> Vec { String::from_utf8(self.journal()) .unwrap() .lines() .filter(|l| !l.trim().is_empty()) .map(|l| { let v: Value = serde_json::from_str(l).unwrap(); v["type"] .as_str() .or_else(|| v["line_type"].as_str()) .unwrap_or_default() .to_string() }) .collect() } fn send_line(&mut self, line: &str) { self.wire.send_line(line); } fn recv(&mut self) -> Option { self.wire.recv(TIMEOUT) } fn rpc(&mut self, method: &str, params: Value) -> Value { let id = self.next_id; self.next_id += 1; let msg = json!({"jsonrpc": "2.0", "id": id, "method": method, "params": params}); self.send_line(&msg.to_string()); let resp = self .recv() .unwrap_or_else(|| panic!("no response to {method} (id {id})")); assert_eq!(resp["id"], json!(id), "response id mismatch: {resp}"); let what = params["name"].as_str().unwrap_or(method).to_string(); self.transcript.push((what, resp.to_string())); resp } /// Call a tool; a refusal must leave the journal unchanged, a success /// must match the outputSchema. fn tool(&mut self, name: &str, args: Value) -> Out { let before = self.journal(); let resp = common::tools_call(|m, p| self.rpc(m, p), name, args.clone()); let out = if let Some(err) = resp.get("error") { Out::RpcErr(err.clone()) } else { let result = &resp["result"]; if result.get("isError") == Some(&json!(true)) { Out::ToolErr( result["content"] .as_array() .and_then(|c| c.iter().find_map(|b| b["text"].as_str())) .unwrap_or_default() .to_string(), ) } else { let sc = result["structuredContent"].clone(); let v = self .validators .get(name) .unwrap_or_else(|| panic!("{name} is not in tools/list")); let errors: Vec = v.iter_errors(&sc).map(|e| e.to_string()).collect(); assert!( errors.is_empty(), "{name} violates its outputSchema: {errors:?}\n{sc}" ); Out::Ok(sc) } }; if out.refused() { assert!( self.journal() == before, "refused {name} {args} changed the journal ({out:?})" ); } out } fn ok(&mut self, name: &str, args: Value) -> Value { let a = args.to_string(); match self.tool(name, args) { Out::Ok(v) => v, other => panic!("{name} {a}: expected success, got {other:?}"), } } /// `tool`, expecting a refusal that says why. fn refuse(&mut self, name: &str, args: Value) -> String { let a = args.to_string(); let out = self.tool(name, args); assert!(out.refused(), "{name} {a} should be refused, got {out:?}"); let why = out.err_text(); assert!(!why.trim().is_empty(), "{name} {a}: a refusal says why"); why } /// `ok`, and the journal must not change (read-only tools). fn read_only(&mut self, name: &str, args: Value) -> Value { let before = self.journal(); let v = self.ok(name, args); assert!( self.journal() == before, "read-only {name} changed the journal" ); v } fn accounts(&mut self, accts: &[(&str, &str, &str)]) { for (code, name, ty) in accts { self.ok( "books.add_account", json!({"code": code, "name": name, "account_type": ty}), ); } } /// Draft an entry with `args` extra (party, vendor) and lines; the id. fn draft(&mut self, date: &str, extra: Value, lines: &[(&str, &str, &str)]) -> u64 { let mut args = json!({"date": date, "description": format!("entry {date}")}); for (k, v) in extra.as_object().unwrap() { args[k] = v.clone(); } let id = self.ok("books.draft_entry", args)["entry_id"] .as_u64() .unwrap(); for (account, side, amount) in lines { self.ok( "books.add_line", json!({"entry_id": id, "account": account, "side": side, "amount": amount}), ); } id } /// `draft`, then post it (high receipt threshold: no receipt needed). fn post(&mut self, date: &str, extra: Value, lines: &[(&str, &str, &str)]) -> u64 { let id = self.draft(date, extra, lines); self.ok( "books.post_entry", json!({"entry_id": id, "confirmed": true}), ); id } fn add_party(&mut self, name: &str, roles: &[&str]) -> u64 { self.ok("books.add_party", json!({"name": name, "roles": roles}))["party_id"] .as_u64() .unwrap() } fn party(&mut self, party: &str, year: i32) -> Value { self.read_only("books.party", json!({"party": party, "year": year})) } /// How many journal lines have this type. fn count(&self, line_type: &str) -> usize { self.journal_types() .iter() .filter(|t| t.as_str() == line_type) .count() } } const CHART: &[(&str, &str, &str)] = &[ ("1000", "Bank", "asset"), ("2050", "Card", "liability"), ("3000", "Capital", "equity"), ("6000", "Contract Labor", "expense"), ("6999", "General Expenses", "expense"), ]; /// Books with the chart (no receipts needed), a contractor "Acme Design" /// with a vendor record marked NEC, and a landlord "Rent Co". fn books(name: &str) -> Mcp { books_with(name, "") } /// `books`, with `extra` TOML after `[books]` (members, say). fn books_with(name: &str, extra: &str) -> Mcp { let dir = test_dir(name); let toml = books_toml(&dir, "1000000.00", extra); let mut m = Mcp::start(&dir, &toml); m.accounts(CHART); m.add_party("Acme Design", &["contractor"]); m.ok("books.add_vendor", json!({"name": "Acme Design"})); m.ok( "books.set_vendor_1099", json!({"vendor": "Acme Design", "form_1099": "nec", "decided_by": "Test Owner"}), ); m.add_party("Rent Co", &["landlord"]); m.books_dir = Some(dir); m } /// An SSN and an EIN no other number in these tests resembles. const SSN: &str = "291-12-2846"; const SSN_DIGITS: &str = "291122846"; const EIN: &str = "12-3279105"; const EIN_DIGITS: &str = "123279105"; /// A member's SSN, unlike the others. const MEMBER_SSN: &str = "241-40-4915"; const MEMBER_SSN_DIGITS: &str = "241404915"; /// Every file in a zip, by name. fn unzip(bytes: &[u8]) -> HashMap> { let mut z = zip::ZipArchive::new(std::io::Cursor::new(bytes)).unwrap(); let mut out = HashMap::new(); for i in 0..z.len() { let mut f = z.by_index(i).unwrap(); let mut bytes = Vec::new(); std::io::Read::read_to_end(&mut f, &mut bytes).unwrap(); out.insert(f.name().unwrap().to_string(), bytes); } out } /// Every file a tool handed over in `v` (a `HandedFile`: an object with /// its `download`), with its `file_name`, fetched by its download. fn take_files(m: &mut Mcp, v: &Value, files: &mut Vec<(String, Vec)>) { match v { Value::Object(o) => { if o.get("download").is_some_and(Value::is_string) { let name = o["file_name"].as_str().unwrap_or_default().to_string(); files.push((name, m.handed(v))); } for v in o.values() { take_files(m, v, files); } } Value::Array(a) => { for v in a { take_files(m, v, files); } } _ => {} } } /// The paths of every file under `dir` but the vault's, and but what /// every call writes by design (common.rs `book_files`: the access log, /// the viewer's side channel, the books' own backup, the files handed over /// for their download): what a tool wrote, compared before and after. fn paths_outside_vault(m: &Mcp, dir: &Path) -> std::collections::BTreeSet { common::book_files(dir) .into_keys() .map(|f| dir.join(f)) .filter(|f| !f.starts_with(m.vault())) .collect() } /// A CSV's rows (header first) as maps from column name to cell, cells /// split on commas outside double quotes. fn csv_rows(bytes: &[u8]) -> Vec> { let split = |l: &str| { let (mut cells, mut cur, mut quoted) = (Vec::new(), String::new(), false); let mut chars = l.chars().peekable(); while let Some(c) = chars.next() { match c { '"' if quoted && chars.peek() == Some(&'"') => { cur.push('"'); chars.next(); } '"' => quoted = !quoted, ',' if !quoted => cells.push(std::mem::take(&mut cur)), c => cur.push(c), } } cells.push(cur); cells }; let text = String::from_utf8(bytes.to_vec()).unwrap(); let mut lines = text.lines(); let head = split(lines.next().expect("a header")); lines .map(|l| { let cells = split(l); assert_eq!(cells.len(), head.len(), "{l}"); head.iter().cloned().zip(cells).collect() }) .collect() } impl Mcp { /// The books' folder in the data root. fn books_dir(&self) -> PathBuf { self.data.clone() } /// Every file in the test's home outside the data root but the /// server's log: what the server must never write. fn outside_files(&self) -> Vec { let home = self.books_dir.as_ref().expect("the place"); let root = home.home.root(); let log = home.join("server.log"); files_under(home) .into_iter() .filter(|f| !f.starts_with(&root) && *f != log) .collect() } /// The server's log (its stderr). fn server_log(&self) -> Vec { std::fs::read( self.books_dir .as_ref() .expect("the place") .join("server.log"), ) .unwrap_or_default() } fn vault(&self) -> PathBuf { self.books_dir().join("vault") } /// The vault's key: in the data root, the book's own. fn key(&self) -> PathBuf { self.books_dir().join("keys").join("legacy.key") } /// The bytes of a file a tool handed over (a `HandedFile`, D-29): /// fetched by its download, matching the size and SHA-256 it states. fn handed(&mut self, file: &Value) -> Vec { assert!(file["expires_at"].is_string(), "{file}"); self.wire.fetch(file) } fn party_id(&mut self, name: &str) -> u64 { self.party(name, 2026)["party"]["party_id"] .as_u64() .unwrap() } /// set_w9 for `party` with these extra arguments. fn w9(&mut self, party: &str, extra: Value) -> Out { let mut args = json!({"party": party, "received_on": "2026-01-05", "entity_type": "individual/sole proprietor"}); for (k, v) in extra.as_object().unwrap() { args[k] = v.clone(); } self.tool("books.set_w9", args) } /// Upload `bytes` as an agent does (D-46): the upload_id. fn upload(&mut self, bytes: &[u8], file_name: &str, mime_type: &str, purpose: &str) -> String { self.wire.upload(bytes, file_name, mime_type, purpose) } /// Upload a W-9 form, named and typed for what its first bytes say /// (w9.pdf, w9.png, w9.jpg; else w9.bin): the upload_id. fn w9_upload(&mut self, bytes: &[u8]) -> String { let (name, mime) = if bytes.starts_with(b"%PDF-") { ("w9.pdf", "application/pdf") } else if bytes.starts_with(b"\x89PNG\r\n\x1a\n") { ("w9.png", "image/png") } else if bytes.starts_with(&[0xFF, 0xD8, 0xFF]) { ("w9.jpg", "image/jpeg") } else { ("w9.bin", "application/octet-stream") }; self.upload(bytes, name, mime, "w9") } /// A W-9 file (a PDF); it shows the TIN, like the real one. fn w9_file(&self, tin: &str) -> Vec { format!("%PDF-1.4 Form W-9 TIN {tin}").into_bytes() } /// Pay `vendor` `amount` from the bank in 2026, tagged. fn pay(&mut self, vendor: &str, amount: &str) { self.post( "2026-03-01", json!({"vendor": vendor}), &[("6000", "debit", amount), ("1000", "credit", amount)], ); } /// The W-9 facts the journal holds for a party, last first. fn journaled_w9(&self, party: u64) -> Value { String::from_utf8(self.journal()) .unwrap() .lines() .filter_map(|l| serde_json::from_str::(l).ok()) .filter(|v| v["type"] == "party.w9_set" || v["line_type"] == "party.w9_set") .rfind(|v| v["payload"]["id"] == json!(party)) .map(|v| v["payload"]["facts"].clone()) .expect("a party.w9_set") } } /// Whether `hay` holds the nine `digits` in any formatting: separators /// (space, hyphen, dot, slash, underscore) between them are ignored. fn holds_tin(hay: &[u8], digits: &str) -> bool { let squeezed: Vec = hay .iter() .filter(|b| !matches!(b, b' ' | b'-' | b'.' | b'/' | b'_')) .copied() .collect(); squeezed .windows(digits.len()) .any(|w| w == digits.as_bytes()) } /// Every file under `dir`, recursively. fn files_under(dir: &Path) -> Vec { let mut out = Vec::new(); let Ok(rd) = std::fs::read_dir(dir) else { return out; }; for e in rd.flatten() { let p = e.path(); if p.is_dir() { out.extend(files_under(&p)); } else if p.extension().is_some_and(|x| x == "lock") { // A lock file holds no data, and on Windows a held lock is // mandatory: reading it is refused while the server runs. } else { out.push(p); } } out } /// The files a test was handed, in the books' `exports/` (D-29): every /// export id the session's answers named (`ex_` and 32 letters `a` to /// `p`, in each handed file's `download`). Checks that nothing else sits /// there but each one's bytes and its record (`.bin`, `.json`), and /// returns the bytes' paths: what a scan of the books' folder may skip, /// their contents having been checked through their downloads. The /// records are scanned like any other file. fn handed_exports(m: &Mcp) -> Vec { let mut ids = std::collections::BTreeSet::new(); for (_, resp) in &m.transcript { let mut rest = resp.as_str(); while let Some(at) = rest.find("ex_") { let id: String = rest[at..].chars().take(35).collect(); if ledger_store::exports::is_id(&id) { ids.insert(id); } rest = &rest[at + 3..]; } } assert!(!ids.is_empty(), "the test was handed no file"); let dir = m.books_dir().join(ledger_store::exports::EXPORTS_DIR); let mut bins = Vec::new(); for f in files_under(&dir) { let name = f.file_name().unwrap().to_string_lossy().into_owned(); let handed = ids .iter() .find(|id| name == format!("{id}.bin") || name == format!("{id}.json")); assert!(handed.is_some(), "{name} in exports/ wasn't handed over"); if name.ends_with(".bin") { bins.push(f); } } bins } // ------------------------------------------------- books.set_w9 with a TIN /// The TIN goes to the vault (the key created on first use, owner-only), /// the books keep its last four and the record's hash, and the output /// never shows it. #[test] fn a_tin_goes_to_the_vault_and_the_books_keep_the_last_four() { let mut m = books("store"); assert!(!m.key().exists()); let out = m.w9("Acme Design", json!({"tin": SSN, "tin_type": "ssn"})); let Out::Ok(p) = out else { panic!("{out:?}") }; assert_eq!(p["w9"]["tin_last4"], "2846", "from the TIN"); assert_eq!(p["w9"]["kept_at"], "wanderbooks vault"); assert_eq!(p["w9"]["in_vault"], true); assert!(!holds_tin(p.to_string().as_bytes(), SSN_DIGITS)); let id = p["party_id"].as_u64().unwrap(); assert!(m.vault().join(format!("tin-{id}.bin")).exists()); assert!(m.key().exists(), "the key is created on first use"); #[cfg(unix)] { use std::os::unix::fs::PermissionsExt; let mode = std::fs::metadata(m.key()).unwrap().permissions().mode(); assert_eq!(mode & 0o777, 0o600, "the key is the owner's only"); } let facts = m.journaled_w9(id); assert_eq!(facts["tin_last4"], "2846"); assert_eq!(facts["vault_record"].as_str().unwrap().len(), 64); // The vendor record's W-9 flag follows. let v = m.read_only("books.vendor_1099", json!({"year": 2026})); assert_eq!(v["vendors"][0]["tin_on_file"], true); assert_eq!(v["vendors"][0]["tin_last4"], "2846"); assert!(v["vendors"][0]["tin"].is_null()); assert!(v["warning"].is_null()); // A given tin_last4 that agrees is fine; an EIN for the landlord. m.w9( "Rent Co", json!({"tin": EIN, "tin_type": "ein", "tin_last4": "9105", "kept_at": "binder"}), ); let rent = m.party("Rent Co", 2026); assert_eq!(rent["party"]["w9"]["kept_at"], "binder"); assert_eq!(rent["party"]["w9"]["in_vault"], true); assert_eq!(m.ok("books.verify", json!({}))["status"], "ok"); } #[test] fn set_w9_refusals_write_nothing_anywhere() { let mut m = books("refusals"); let acme = m.party_id("Acme Design"); m.add_party("IRS", &["tax_agency"]); let pdf = m.w9_file(SSN); let txt = b"W-9"; for (party, extra) in [ ("Acme Design", json!({"tin": "57193284", "tin_type": "ssn"})), // eight digits ( "Acme Design", json!({"tin": "902-67-2846", "tin_type": "ssn"}), ), // an SSN starting 9 ( "Acme Design", json!({"tin": "291122846", "tin_type": "itin"}), ), // not an ITIN ("Acme Design", json!({"tin": SSN})), // no type ( "Acme Design", json!({"tin_type": "ssn", "tin_last4": "2846", "kept_at": "x"}), ), ("Acme Design", json!({"tin": SSN, "tin_type": "passport"})), ( "Acme Design", json!({"tin": SSN, "tin_type": "ssn", "tin_last4": "1111"}), ), ( "Acme Design", json!({"tin": 291122846.0, "tin_type": "ssn"}), ), // a float ( "Acme Design", json!({"tin": SSN, "tin_type": "ssn", "clear": true}), ), ( "Acme Design", json!({"tin": SSN, "tin_type": "ssn", "kept_at": "bad\u{7}"}), ), ("IRS", json!({"tin": SSN, "tin_type": "ssn"})), // no 1099 role ("Nobody", json!({"tin": SSN, "tin_type": "ssn"})), ] { let out = m.w9(party, extra.clone()); assert!(out.refused(), "{party} {extra} should be refused: {out:?}"); assert!( !holds_tin(out.err_text().as_bytes(), SSN_DIGITS), "a refusal never echoes the TIN: {}", out.err_text() ); } // The W-9 file, each refused for its own reason. Every upload is // its own: a refused call leaves it unused. let up_txt = m.upload(txt, "w9.txt", "text/plain", "w9"); let up_pdf = m.upload(&pdf, "w9.pdf", "application/pdf", "w9"); let up_receipt = m.upload(&pdf, "w9.pdf", "application/pdf", "receipt"); for (extra, why) in [ (json!({"w9_upload_id": up_txt}), "a PDF, PNG, or JPEG"), (json!({"w9_upload_id": "u1"}), "there is no upload \"u1\""), (json!({"w9_upload_id": up_receipt}), "begun for receipt"), ( json!({"w9_upload_id": up_pdf, "w9_file_name": "../w9.pdf"}), "no folder", ), // The inline form D-46 removed: an unknown argument, pointing to // the upload. ( json!({"w9_content_base64": "JVBERi0xLjQ=", "w9_sha256": "00"}), "books.begin_upload", ), ( json!({"w9_upload_id": up_pdf, "w9_sha256": "00"}), "unknown argument", ), ] { let mut args = json!({"tin": SSN, "tin_type": "ssn"}); for (k, v) in extra.as_object().unwrap() { args[k] = v.clone(); } let why_given = m.w9("Acme Design", args).err_text(); assert!(why_given.contains(why), "{extra}: {why_given}"); assert!(!holds_tin(why_given.as_bytes(), SSN_DIGITS), "{why_given}"); } assert!( !m.vault().join(format!("tin-{acme}.bin")).exists(), "no record written" ); assert!(!m.key().exists(), "no key made for a refusal"); assert_eq!(m.count("party.w9_set"), 0); } /// Facts that change without a new TIN keep the vault record, unless they /// disagree with it; a new TIN replaces it. #[test] fn the_facts_never_disagree_with_the_vault() { let mut m = books("agree"); let acme = m.party_id("Acme Design"); m.w9("Acme Design", json!({"tin": SSN, "tin_type": "ssn"})); let first = m.journaled_w9(acme)["vault_record"].clone(); // New facts, same last four: the record stays. let Out::Ok(p) = m.w9( "Acme Design", json!({"tin_last4": "2846", "kept_at": "wanderbooks vault", "exempt_payee_code": "5"}), ) else { panic!("same last four") }; assert_eq!(p["w9"]["in_vault"], true); assert_eq!(m.journaled_w9(acme)["vault_record"], first); // Another last four without the TIN: refused. let why = m .w9( "Acme Design", json!({"tin_last4": "1111", "kept_at": "binder"}), ) .err_text(); assert!( why.contains("books.purge_tin") && why.contains("2846"), "{why}" ); // A new TIN replaces the old. m.w9( "Acme Design", json!({"tin": "620-37-1111", "tin_type": "ssn"}), ); let facts = m.journaled_w9(acme); assert_eq!(facts["tin_last4"], "1111"); assert_ne!(facts["vault_record"], first); m.pay("Acme Design", "700.00"); let v = m.read_only( "books.vendor_1099", json!({"year": 2026, "include_tins": true}), ); assert_eq!(v["vendors"][0]["tin"], "620-37-1111"); } // ------------------------------------------------- the W-9 file #[test] fn the_w9_file_goes_to_the_vault_never_the_documents() { let mut m = books("w9-file"); let acme = m.party_id("Acme Design"); let src = m.w9_file(SSN); let up1 = m.w9_upload(&src); let Out::Ok(p) = m.w9( "Acme Design", json!({"tin": SSN, "tin_type": "ssn", "w9_upload_id": up1}), ) else { panic!("set_w9 with a file") }; assert_eq!(p["w9"]["in_vault"], true); assert!(m.vault().join(format!("w9-{acme}.bin")).exists()); let docs = m.read_only("books.list_receipts", json!({"include_withdrawn": true})); assert!( docs["receipts"].as_array().unwrap().is_empty(), "not a document: {docs}" ); // Read back: the bytes, handed over by their download, named for // their type; nothing written anywhere, and the output shows no TIN // but in the file itself. let (books_before, outside_before) = (paths_outside_vault(&m, &m.books_dir()), m.outside_files()); let out = m.ok("books.get_w9", json!({"party": "Acme Design"})); assert_eq!(out["party_id"], json!(acme)); assert_eq!(out["file"]["file_name"], "w9.pdf"); assert_eq!(out["file"]["mime_type"], "application/pdf"); assert_eq!(m.handed(&out["file"]), src); assert!(!holds_tin(out.to_string().as_bytes(), SSN_DIGITS), "{out}"); assert_eq!(paths_outside_vault(&m, &m.books_dir()), books_before); assert_eq!(m.outside_files(), outside_before); // A party with no W-9 file in the vault. m.w9("Rent Co", json!({"tin": EIN, "tin_type": "ein"})); let why = m.refuse("books.get_w9", json!({"party": "Rent Co"})); assert!(why.contains("holds no W-9 file"), "{why}"); // A W-9 file alone (the TIN typed in later) records the vault too. m.add_party("Solo", &["contractor"]); let solo = m.w9_file("000"); let up2 = m.w9_upload(&solo); let Out::Ok(p) = m.w9("Solo", json!({"tin_last4": "0000", "w9_upload_id": up2})) else { panic!("a W-9 file alone") }; assert_eq!(p["w9"]["in_vault"], true); assert_eq!(p["w9"]["kept_at"], "wanderbooks vault"); } /// A W-9 file is a PDF, PNG or JPEG, judged by its own first bytes: each /// is taken into the vault and comes back byte for byte, named for its /// type. #[test] fn every_w9_file_type_is_taken() { let mut m = books("w9-types"); for (n, (bytes, name)) in [ (b"%PDF-1.4 Form W-9".as_slice(), "w9.pdf"), (b"\x89PNG\r\n\x1a\n\0\0\0\rIHDR W-9".as_slice(), "w9.png"), (b"\xff\xd8\xff\xe0\0\x10JFIF W-9".as_slice(), "w9.jpg"), ] .iter() .enumerate() { let party = format!("Payee {n}"); m.add_party(&party, &["contractor"]); let up3 = m.w9_upload(bytes); let Out::Ok(p) = m.w9(&party, json!({"tin_last4": "0000", "w9_upload_id": up3})) else { panic!("{name} refused") }; assert_eq!(p["w9"]["in_vault"], true, "{name}"); let out = m.ok("books.get_w9", json!({"party": party})); assert_eq!(out["file"]["file_name"], *name); assert_eq!(m.handed(&out["file"]), *bytes); } // Its own name, when given; given alone, a name is refused. m.add_party("Payee N", &["contractor"]); let bytes = b"%PDF-1.4 Form W-9 named"; let up4 = m.w9_upload(bytes); let Out::Ok(_) = m.w9( "Payee N", json!({"tin_last4": "0000", "w9_upload_id": up4, "w9_file_name": "payee-n-w9.pdf"}), ) else { panic!("named W-9 refused") }; let out = m.ok("books.get_w9", json!({"party": "Payee N"})); assert_eq!(out["file"]["file_name"], "payee-n-w9.pdf"); assert_eq!(m.handed(&out["file"]), bytes); let why = m .w9( "Payee N", json!({"tin_last4": "0000", "w9_file_name": "x.pdf"}), ) .err_text(); assert!(why.contains("w9_file_name names the W-9"), "{why}"); // Anything else is refused, near misses too. m.add_party("Payee X", &["contractor"]); for bytes in [ b"PDF-1.4 no percent".as_slice(), b"\x89PNG\r\n no".as_slice(), b"\xff\xd8 short".as_slice(), b"GIF89a".as_slice(), ] { let up5 = m.w9_upload(bytes); let why = m .w9("Payee X", json!({"tin_last4": "0000", "w9_upload_id": up5})) .err_text(); assert!(why.contains("PDF, PNG, or JPEG"), "{why}"); } assert_eq!(m.party("Payee X", 2026)["party"]["w9"], Value::Null); } #[test] fn a_locked_vault_says_so_and_never_guesses() { let mut m = books("locked"); let src = m.w9_file(SSN); let up6 = m.w9_upload(&src); m.w9( "Acme Design", json!({"tin": SSN, "tin_type": "ssn", "w9_upload_id": up6}), ); m.pay("Acme Design", "700.00"); let key = std::fs::read(m.key()).unwrap(); std::fs::remove_file(m.key()).unwrap(); let why = m.refuse("books.get_w9", json!({"party": "Acme Design"})); assert!(why.contains("locked"), "{why}"); let v = m.read_only( "books.vendor_1099", json!({"year": 2026, "include_tins": true}), ); assert!(v["vendors"][0]["tin"].is_null()); assert!( v["vendors"][0]["tin_note"] .as_str() .unwrap() .contains("locked"), "{v}" ); assert!(!m.key().exists(), "reading never makes a key"); // Another key can't read it: refused, not guessed. std::fs::write(m.key(), "11".repeat(32)).unwrap(); let v = m.read_only( "books.vendor_1099", json!({"year": 2026, "include_tins": true}), ); assert!(v["vendors"][0]["tin"].is_null()); assert!( v["vendors"][0]["tin_note"] .as_str() .unwrap() .contains("can't be read"), "{v}" ); std::fs::write(m.key(), key).unwrap(); let v = m.read_only( "books.vendor_1099", json!({"year": 2026, "include_tins": true}), ); assert_eq!(v["vendors"][0]["tin"], SSN); } // ------------------------------------------------- books.vendor_1099 #[test] fn the_1099_worksheet_carries_full_tins_only_when_asked() { let mut m = books("worksheet"); m.w9("Acme Design", json!({"tin": SSN, "tin_type": "ssn"})); m.pay("Acme Design", "2500.00"); m.ok("books.add_vendor", json!({"name": "Rent Co"})); m.ok( "books.set_vendor_1099", json!({"vendor": "Rent Co", "form_1099": "misc", "decided_by": "Test Owner"}), ); m.w9( "Rent Co", json!({"tin_last4": "9105", "kept_at": "CPA portal"}), ); m.pay("Rent Co", "3000.00"); let plain = m.read_only("books.vendor_1099", json!({"year": 2026})); assert!(!holds_tin(plain.to_string().as_bytes(), SSN_DIGITS)); assert!(plain["warning"].is_null()); let full = m.read_only( "books.vendor_1099", json!({"year": 2026, "include_tins": true}), ); assert!(full["warning"].as_str().unwrap().contains("CPA"), "{full}"); let line = |name: &str| { full["vendors"] .as_array() .unwrap() .iter() .find(|v| v["name"] == name) .unwrap() .clone() }; let acme = line("Acme Design"); assert_eq!(acme["tin"], SSN); assert_eq!(acme["tin_type"], "ssn"); assert!(acme["tin_note"].is_null()); let rent = line("Rent Co"); assert!(rent["tin"].is_null()); assert!( rent["tin_note"] .as_str() .unwrap() .contains("not in the vault"), "{rent}" ); assert_eq!(rent["tin_last4"], "9105"); assert_eq!( full["warning"], "This worksheet holds full TINs, for the CPA; keep it out of the books." ); // No CSV unless asked. assert!(plain["csv"].is_null() && full["csv"].is_null(), "{full}"); // Asked, the CSV is handed over: with TINs, named for them. let out = m.read_only( "books.vendor_1099", json!({"year": 2026, "include_tins": true, "csv": true}), ); assert_eq!(out["csv"]["file_name"], "1099-worksheet-2026-with-tins.csv"); assert_eq!(out["csv"]["mime_type"], "text/csv"); let text = String::from_utf8(m.handed(&out["csv"])).unwrap(); assert!(text.contains(SSN), "{text}"); assert!(text.starts_with( "vendor_id,name,decision_1099,form_1099,reason_kind,reason,decided_by,decided_on,effective,decisions_in_year,paid,excluded_card,undetermined,not_reportable,status,reportable,w9_on_file,tin_last4,tin,tin_type,tin_note\n" )); let rows = csv_rows(text.as_bytes()); let acme = rows.iter().find(|r| r["name"] == "Acme Design").unwrap(); assert_eq!(acme["tin"], SSN); assert_eq!(acme["tin_type"], "ssn"); let rent = rows.iter().find(|r| r["name"] == "Rent Co").unwrap(); assert_eq!(rent["tin"], ""); assert!(rent["tin_note"].contains("not in the vault"), "{rent:?}"); // Without TINs: last fours only, and no TIN columns. let out = m.read_only("books.vendor_1099", json!({"year": 2026, "csv": true})); assert_eq!(out["csv"]["file_name"], "1099-worksheet-2026.csv"); let bytes = m.handed(&out["csv"]); assert!(!holds_tin(&bytes, SSN_DIGITS)); let text = String::from_utf8(bytes).unwrap(); assert!( text.starts_with("vendor_id,name,decision_1099,form_1099,reason_kind,reason,decided_by,decided_on,effective,decisions_in_year,paid,excluded_card,undetermined,not_reportable,status,reportable,w9_on_file,tin_last4\n"), "{text}" ); } // ------------------------------------------------- books.purge_tin #[test] fn purge_deletes_the_vault_records_and_journals_why() { let mut m = books("purge"); let acme = m.party_id("Acme Design"); let src = m.w9_file(SSN); let up7 = m.w9_upload(&src); m.w9( "Acme Design", json!({"tin": SSN, "tin_type": "ssn", "w9_upload_id": up7}), ); // Clearing while the vault holds it: refused. let why = m .tool( "books.set_w9", json!({"party": "Acme Design", "clear": true}), ) .err_text(); assert!(why.contains("books.purge_tin"), "{why}"); m.refuse( "books.purge_tin", json!({"party": "Acme Design", "reason": " ", "confirmed": true}), ); m.refuse( "books.purge_tin", json!({"party": "Nobody", "reason": "x", "confirmed": true}), ); // A preview changes nothing. let journal = m.journal(); let p = m.ok( "books.purge_tin", json!({"party": "Acme Design", "reason": "retention over"}), ); assert_eq!(p["status"], "preview"); assert_eq!(p["tin_in_vault"], true); assert_eq!(p["w9_file_in_vault"], true); assert_eq!(p["tin_last4"], "2846"); assert!(m.journal() == journal); assert!(m.vault().join(format!("tin-{acme}.bin")).exists()); // Without the key, confirmed: gone, and journaled. std::fs::remove_file(m.key()).unwrap(); let p = m.ok( "books.purge_tin", json!({"party": "Acme Design", "reason": "retention over", "confirmed": true}), ); assert_eq!(p["status"], "purged"); assert_eq!(p["journaled"], true); assert!(!m.vault().join(format!("tin-{acme}.bin")).exists()); assert!(!m.vault().join(format!("w9-{acme}.bin")).exists()); let facts = m.journaled_w9(acme); assert!(facts["vault_record"].is_null(), "{facts}"); assert_eq!(facts["tin_last4"], "2846", "the last four stay"); assert!( facts["kept_at"] .as_str() .unwrap() .contains("retention over"), "{facts}" ); assert_eq!( m.party("Acme Design", 2026)["party"]["w9"]["in_vault"], false ); // Nothing left: refused. Clearing works now. m.refuse( "books.purge_tin", json!({"party": "Acme Design", "reason": "again", "confirmed": true}), ); m.ok( "books.set_w9", json!({"party": "Acme Design", "clear": true}), ); assert_eq!(m.ok("books.verify", json!({}))["status"], "ok"); } // ------------------------------------------------- the rule above all /// A session that stores TINs (an SSN and an EIN, and a W-9 file showing /// one) and then uses the books every way it can: no tool output, error, /// or log, and no byte of the journal, the database, the stored files, or /// the viewer's requests holds either TIN in any formatting, except /// books.vendor_1099 with include_tins. The vault holds them only /// encrypted. #[test] fn no_full_tin_leaves_the_vault_except_the_worksheet_when_asked() { let mut m = books_with( "no-leak", "[[company.members]]\nname = \"Mo\"\nshare = \"100%\"\ncapital_account = \"3000\"\n", ); let src = m.w9_file(SSN); let up8 = m.w9_upload(&src); m.w9( "Acme Design", json!({"tin": SSN, "tin_type": "ssn", "w9_upload_id": up8}), ); m.w9("Rent Co", json!({"tin": EIN, "tin_type": "ein"})); m.ok("books.add_vendor", json!({"name": "Rent Co"})); m.ok( "books.set_vendor_1099", json!({"vendor": "Rent Co", "form_1099": "misc", "decided_by": "Test Owner"}), ); m.pay("Acme Design", "2500.00"); m.pay("Rent Co", "3000.00"); let e = m.post( "2026-03-02", json!({"party": "Acme Design"}), &[("6999", "debit", "1.00"), ("1000", "credit", "1.00")], ); // Refusals, which must not echo what they were given. for args in [ json!({"party": "Acme Design", "received_on": "2026-01-05", "entity_type": "x", "tin": SSN, "tin_type": "ein", "tin_last4": "0000"}), json!({"party": "Acme Design", "received_on": "2026-01-05", "entity_type": "x", "tin": EIN, "tin_type": "ssn", "tin_last4": "2846"}), json!({"party": "Acme Design", "received_on": "2026-01-05", "entity_type": "x", "tin": SSN_DIGITS.parse::().unwrap() as f64, "tin_type": "ssn"}), json!({"party": "Acme Design", "clear": true}), ] { m.refuse("books.set_w9", args); } // A TIN typed into another field is refused, and not echoed. m.refuse( "books.update_party", json!({"party": "Acme Design", "notes": format!("ssn {SSN}")}), ); m.refuse("books.add_party", json!({"name": format!("Acme {EIN}")})); // A member's SSN, by books.set_tin, and refusals of it. m.add_party("Mo", &["member"]); m.ok( "books.set_tin", json!({"party": "Mo", "tin": MEMBER_SSN, "tin_type": "ssn"}), ); for args in [ json!({"party": "Mo", "tin": MEMBER_SSN, "tin_type": "itin"}), json!({"party": "Mo", "tin": MEMBER_SSN_DIGITS.parse::().unwrap() as f64, "tin_type": "ssn"}), json!({"party": "Nobody", "tin": MEMBER_SSN, "tin_type": "ssn"}), json!({"party": "Rent Co", "tin": MEMBER_SSN, "tin_type": "ssn"}), ] { m.refuse("books.set_tin", args); } // Everything else, read and write. for (tool, args) in [ ("books.parties", json!({})), ("books.parties", json!({"role": "contractor", "year": 2026})), ("books.party", json!({"party": "Acme Design"})), ("books.party", json!({"party": "Rent Co", "year": 2026})), ("books.list_vendors", json!({})), ("books.vendor_1099", json!({"year": 2026})), ("books.get_entry", json!({"entry_id": e})), ("books.search_entries", json!({"text": "Acme"})), ("books.digest", json!({"since": "2026-01-01"})), ("books.trial_balance", json!({})), ("books.list_receipts", json!({"include_withdrawn": true})), ( "books.attachments", json!({"subject": {"kind": "party", "id": "Acme Design"}}), ), ("books.settings", json!({})), ("books.verify", json!({})), ("books.show", json!({"party": "Acme Design"})), ( "books.export", json!({"report": "journal", "format": "csv"}), ), ( "books.export", json!({"report": "journal", "format": "json"}), ), ( "books.export_cpa_package", json!({"as_of": "2026-12-31", "cash_accounts": ["1000"], "include_tins": false}), ), ("books.party", json!({"party": "Mo"})), ("books.parties", json!({"role": "member"})), ( "books.purge_tin", json!({"party": "Rent Co", "reason": "preview only"}), ), ("books.get_w9", json!({"party": "Acme Design"})), ( "books.update_party", json!({"party": "Acme Design", "notes": "W-9 in the vault"}), ), ] { m.ok(tool, args); } let full = m.read_only( "books.vendor_1099", json!({"year": 2026, "include_tins": true}), ); assert!( holds_tin(full.to_string().as_bytes(), SSN_DIGITS), "the worksheet, asked" ); assert!(holds_tin(full.to_string().as_bytes(), EIN_DIGITS)); let csv = m.read_only( "books.vendor_1099", json!({"year": 2026, "include_tins": true, "csv": true}), ); let csv = m.handed(&csv["csv"]); assert!(holds_tin(&csv, SSN_DIGITS) && holds_tin(&csv, EIN_DIGITS)); // The package, TINs included (the default). let pkg = m.read_only( "books.export_cpa_package", json!({"as_of": "2026-12-31", "cash_accounts": ["1000"]}), ); assert_eq!(pkg["tins_included"], true); let pkg = unzip(&m.handed(&pkg["file"])); let holds_any = |b: &[u8]| { holds_tin(b, SSN_DIGITS) || holds_tin(b, EIN_DIGITS) || holds_tin(b, MEMBER_SSN_DIGITS) }; // Every response but the worksheet's with include_tins, and every // file handed over but those that carry TINs for the CPA by design // (D-29): the W-9 file, the worksheet CSV with TINs, the package with // them (a zip's files read out of it). let (mut leaks, mut scanned): (Vec, Vec) = (Vec::new(), Vec::new()); for (tool, resp) in m.transcript.clone() { let mut files = Vec::new(); let resp: Value = serde_json::from_str(&resp).unwrap(); take_files(&mut m, &resp, &mut files); let rest = resp.to_string(); let worksheet = tool == "books.vendor_1099" && rest.contains("\"warning\":\"This worksheet"); if !worksheet && holds_any(rest.as_bytes()) { leaks.push(format!("{tool}: {rest}")); } for (name, bytes) in files { let by_design = match tool.as_str() { "books.get_w9" => name == "w9.pdf", "books.vendor_1099" => name == "1099-worksheet-2026-with-tins.csv", "books.export_cpa_package" => rest.contains("\"tins_included\":true"), _ => false, }; let inside = if name.ends_with(".zip") { unzip(&bytes).into_values().any(|b| holds_any(&b)) } else { holds_any(&bytes) }; if inside && !by_design { leaks.push(format!("{tool}: the file {name}")); } scanned.push(name); } } assert!(leaks.is_empty(), "full TINs in tool output: {leaks:?}"); // The handed-over files were scanned: two packages (without TINs, // with them), the W-9 file, the worksheet CSV, and the journal's two // exports (D-46: handed over too). assert_eq!(scanned.len(), 6, "{scanned:?}"); assert!(m.transcript.len() > 40, "the session used the books"); // The server's log. let log = m.server_log(); assert!(!holds_tin(&log, SSN_DIGITS) && !holds_tin(&log, EIN_DIGITS)); assert!(!holds_tin(&log, MEMBER_SSN_DIGITS)); // The package carries them all: the member's in the K-1 worksheet, // the payees' in the 1099 worksheet. assert!(holds_tin(&pkg["k1-members.csv"], MEMBER_SSN_DIGITS)); let w1099 = &pkg["1099-worksheet-2026.csv"]; assert!(holds_tin(w1099, SSN_DIGITS) && holds_tin(w1099, EIN_DIGITS)); for (name, bytes) in &pkg { if name != "k1-members.csv" && name != "1099-worksheet-2026.csv" { assert!( !holds_tin(bytes, SSN_DIGITS) && !holds_tin(bytes, EIN_DIGITS) && !holds_tin(bytes, MEMBER_SSN_DIGITS), "{name} in the package holds a TIN" ); } } // Every byte in the books' folder outside the vault: the journal, the // database (and its WAL), the stored files, the access log, the books' // own backup, the handed files' records; but the handed files' bytes, // kept for their download (D-29: the package and worksheet with TINs, // for the CPA, by design) and scanned above through it. let books = m.books_dir(); let handed = handed_exports(&m); let mut seen = Vec::new(); for f in files_under(&books) { if f.starts_with(m.vault()) || handed.contains(&f) { continue; } let bytes = std::fs::read(&f).unwrap(); assert!( !holds_tin(&bytes, SSN_DIGITS) && !holds_tin(&bytes, EIN_DIGITS) && !holds_tin(&bytes, MEMBER_SSN_DIGITS), "{} holds a full TIN", f.display() ); seen.push(f.file_name().unwrap().to_string_lossy().into_owned()); } for must in ["journal.jsonl".to_string(), ledger_store::db_file()] { assert!(seen.contains(&must), "{must} scanned: {seen:?}"); } // The vault holds them encrypted only. let vault_files = files_under(&m.vault()); assert!(vault_files.len() >= 4, "{vault_files:?}"); for f in vault_files { let bytes = std::fs::read(&f).unwrap(); assert!( !holds_tin(&bytes, SSN_DIGITS) && !holds_tin(&bytes, EIN_DIGITS) && !holds_tin(&bytes, MEMBER_SSN_DIGITS), "{} holds a TIN in the clear", f.display() ); } } // ------------------------------------------------- books.set_tin, any role /// The TIN facts a party's last `party.tin_set` journaled. impl Mcp { fn journaled_tin(&self, party: u64) -> Value { String::from_utf8(self.journal()) .unwrap() .lines() .filter_map(|l| serde_json::from_str::(l).ok()) .filter(|v| v["type"] == "party.tin_set" || v["line_type"] == "party.tin_set") .rfind(|v| v["payload"]["id"] == json!(party)) .map(|v| v["payload"]["facts"].clone()) .expect("a party.tin_set") } } /// Any role may have its TIN in the vault: a member (for the K-1), an /// employee, a customer, a party with no role at all. The books journal /// its type, last four, and record hash; the output shows the last four. #[test] fn set_tin_takes_any_party_members_included() { let mut m = books("set-tin"); for (name, roles) in [ ("Mo", vec!["member"]), ("Pat", vec!["employee"]), ("Nobody In Particular", vec![]), ] { let id = m.add_party(name, &roles); let p = m.ok( "books.set_tin", json!({"party": name, "tin": MEMBER_SSN, "tin_type": "ssn"}), ); assert_eq!(p["tin"]["last4"], "4915", "{p}"); assert_eq!(p["tin"]["tin_type"], "ssn"); assert_eq!(p["tin"]["in_vault"], true); assert!(!holds_tin(p.to_string().as_bytes(), MEMBER_SSN_DIGITS)); assert!(m.vault().join(format!("tin-{id}.bin")).exists()); let facts = m.journaled_tin(id); assert_eq!(facts["last4"], "4915"); assert_eq!(facts["tin_type"], "ssn"); assert_eq!(facts["vault_record"].as_str().unwrap().len(), 64); assert_eq!(m.party(name, 2026)["party"]["tin"]["last4"], "4915"); } // A customer (its role comes with its record). m.ok("books.add_customer", json!({"name": "Client"})); let p = m.ok( "books.set_tin", json!({"party": "Client", "tin": EIN, "tin_type": "ein"}), ); assert_eq!(p["tin"]["last4"], "9105"); assert!(p["roles"].as_array().unwrap().contains(&json!("customer"))); // An EIN for a member that is a company; a replacement TIN. let mo = m.party_id("Mo"); let first = m.journaled_tin(mo)["vault_record"].clone(); m.ok( "books.set_tin", json!({"party": "Mo", "tin": EIN, "tin_type": "ein"}), ); let facts = m.journaled_tin(mo); assert_eq!( (facts["last4"].clone(), facts["tin_type"].clone()), (json!("9105"), json!("ein")) ); assert_ne!(facts["vault_record"], first); assert!(!holds_tin(&m.journal(), EIN_DIGITS)); assert_eq!(m.ok("books.verify", json!({}))["status"], "ok"); } #[test] fn set_tin_refusals_write_nothing() { let mut m = books("set-tin-refusals"); m.add_party("Mo", &["member"]); // A W-9 payee whose W-9 facts name another last four. m.w9( "Acme Design", json!({"tin_last4": "1111", "kept_at": "binder"}), ); let acme = m.party_id("Acme Design"); for (party, tin, kind) in [ ("Mo", json!("57193284"), "ssn"), // eight digits ("Mo", json!("902-67-2846"), "ssn"), // SSN starting 9 ("Mo", json!(SSN), "itin"), // not an ITIN ("Mo", json!(SSN), "passport"), // no such type ("Mo", json!(291122846.0), "ssn"), // a float ("Nobody", json!(SSN), "ssn"), // no such party ("Acme Design", json!(SSN), "ssn"), // W-9 says 1111 ] { let why = m.refuse( "books.set_tin", json!({"party": party, "tin": tin, "tin_type": kind}), ); assert!(!holds_tin(why.as_bytes(), SSN_DIGITS), "{why}"); } m.refuse("books.set_tin", json!({"party": "Mo", "tin_type": "ssn"})); assert!(!m.vault().join(format!("tin-{acme}.bin")).exists()); assert!(!m.key().exists(), "no key made for a refusal"); assert_eq!(m.count("party.tin_set"), 0); } /// A W-9 with a TIN records the party's TIN facts too, and the two never /// disagree: set_tin agreeing with the W-9 moves its vault record along; a /// W-9 with a new last four replaces the TIN facts. #[test] fn w9_and_tin_facts_agree() { let mut m = books("w9-tin"); let acme = m.party_id("Acme Design"); m.w9("Acme Design", json!({"tin": SSN, "tin_type": "ssn"})); let p = m.party("Acme Design", 2026); assert_eq!(p["party"]["tin"]["last4"], "2846"); assert_eq!(p["party"]["tin"]["in_vault"], true); let (w9, tin) = (m.journaled_w9(acme), m.journaled_tin(acme)); assert_eq!(w9["vault_record"], tin["vault_record"]); // books.set_tin with the same number: both facts follow the new record. m.ok( "books.set_tin", json!({"party": "Acme Design", "tin": SSN, "tin_type": "ssn"}), ); let (w9b, tinb) = (m.journaled_w9(acme), m.journaled_tin(acme)); assert_ne!(tinb["vault_record"], tin["vault_record"], "a fresh nonce"); assert_eq!(w9b["vault_record"], tinb["vault_record"]); // W-9 facts without a TIN that disagree with the TIN facts: refused. m.refuse( "books.set_w9", json!({"party": "Acme Design", "received_on": "2026-01-05", "entity_type": "x", "tin_last4": "1111", "kept_at": "binder"}), ); // A new W-9 with a new TIN: the TIN facts are replaced (cleared first). let before = m.count("party.tin_set"); m.w9( "Acme Design", json!({"tin": "620-37-1111", "tin_type": "ssn"}), ); assert_eq!(m.count("party.tin_set"), before + 2, "cleared, then set"); assert_eq!(m.journaled_tin(acme)["last4"], "1111"); assert_eq!(m.journaled_w9(acme)["tin_last4"], "1111"); m.pay("Acme Design", "2500.00"); let v = m.read_only( "books.vendor_1099", json!({"year": 2026, "include_tins": true}), ); assert_eq!(v["vendors"][0]["tin"], "620-37-1111"); assert_eq!(m.ok("books.verify", json!({}))["status"], "ok"); } /// books.purge_tin covers TIN facts: a member's record goes, the books /// keep the last four and say when and why. #[test] fn purge_covers_tin_facts() { let mut m = books("purge-tin-facts"); let mo = m.add_party("Mo", &["member"]); m.ok( "books.set_tin", json!({"party": "Mo", "tin": MEMBER_SSN, "tin_type": "ssn"}), ); let p = m.ok( "books.purge_tin", json!({"party": "Mo", "reason": "left the company"}), ); assert_eq!(p["status"], "preview"); assert_eq!(p["tin_in_vault"], true); assert_eq!(p["tin_last4"], "4915"); let p = m.ok( "books.purge_tin", json!({"party": "Mo", "reason": "left the company", "confirmed": true}), ); assert_eq!(p["journaled"], true); assert!(!m.vault().join(format!("tin-{mo}.bin")).exists()); let facts = m.journaled_tin(mo); assert!(facts["vault_record"].is_null(), "{facts}"); assert_eq!(facts["last4"], "4915"); assert!( facts["purged"] .as_str() .unwrap() .contains("left the company") ); let party = m.party("Mo", 2026); assert_eq!(party["party"]["tin"]["in_vault"], false); m.refuse( "books.purge_tin", json!({"party": "Mo", "reason": "again", "confirmed": true}), ); assert_eq!(m.ok("books.verify", json!({}))["status"], "ok"); } // ------------------------------------------------- the CPA package const MEMBERS: &str = "[[company.members]]\nname = \"Tobin Example\"\nshare = \"50%\"\ncapital_account = \"3000\"\n\ [[company.members]]\nname = \"Mo Example\"\nshare = \"50%\"\ncapital_account = \"3001\"\n"; /// Books for the K-1 worksheet, hand-worked: /// /// ```text /// 2025-06-01 Dr 1000 2000 / Cr 3000 1000, Cr 3001 1000 both contribute cash /// 2025-07-01 Dr 1000 400 / Cr 4000 400 2025 income, 200 each /// 2026-02-01 Dr 1500 600 / Cr 3000 600 Tobin: equipment /// 2026-03-01 Dr 1000 500 / Cr 3001 500 Mo: cash /// 2026-03-15 Dr 6999 100 / Cr 3000 100 Tobin paid an expense /// 2026-04-01 Dr 1000 1000 / Cr 4000 1000 revenue /// 2026-05-01 Dr 3000 150, Dr 3001 150 / Cr 1000 300 distributions /// 2026-03-01 Dr 6000 2500 / Cr 1000 2500 Acme Design (m.pay) /// 2026: net income 1000 - 100 - 2500 = -1600, -800 each. /// Tobin: beginning 1200, cash 100, property 600, income -800, /// distributions 150, ending 950. /// Mo: beginning 1200, cash 500, property 0, income -800, /// distributions 150, ending 750. /// Check: bank 1100 + equipment 600 = 1700 = 950 + 750. /// ``` /// /// Tobin's party has his SSN in the vault; Mo's has none. Acme Design /// (NEC, an EIN in the vault) is paid 2,500.00 in 2026. fn k1_books(name: &str) -> Mcp { let mut m = books_with(name, MEMBERS); m.accounts(&[ ("1500", "Equipment", "asset"), ("3001", "Capital Mo", "equity"), ("4000", "App Revenue", "revenue"), ]); m.add_party("Tobin Example", &["member"]); m.add_party("Mo Example", &["member"]); m.ok( "books.set_tin", json!({"party": "Tobin Example", "tin": MEMBER_SSN, "tin_type": "ssn"}), ); m.w9("Acme Design", json!({"tin": EIN, "tin_type": "ein"})); let none = json!({}); m.post( "2025-06-01", none.clone(), &[ ("1000", "debit", "2000.00"), ("3000", "credit", "1000.00"), ("3001", "credit", "1000.00"), ], ); m.post( "2025-07-01", none.clone(), &[("1000", "debit", "400.00"), ("4000", "credit", "400.00")], ); m.post( "2026-02-01", none.clone(), &[("1500", "debit", "600.00"), ("3000", "credit", "600.00")], ); m.post( "2026-03-01", none.clone(), &[("1000", "debit", "500.00"), ("3001", "credit", "500.00")], ); m.post( "2026-03-15", none.clone(), &[("6999", "debit", "100.00"), ("3000", "credit", "100.00")], ); m.post( "2026-04-01", none.clone(), &[("1000", "debit", "1000.00"), ("4000", "credit", "1000.00")], ); m.post( "2026-05-01", none, &[ ("3000", "debit", "150.00"), ("3001", "debit", "150.00"), ("1000", "credit", "300.00"), ], ); m.pay("Acme Design", "2500.00"); m } /// The package carries the K-1 worksheet (one row per member: party, /// share, capital account, TIN, and the hand-worked capital figures) and /// the 1099 worksheet with full TINs, handed over and never kept in the /// books; a missing TIN is a gap, never a failure. #[test] fn the_cpa_package_carries_the_k1_and_1099_worksheets_with_tins() { let mut m = k1_books("k1"); let out = m.read_only( "books.export_cpa_package", json!({"as_of": "2026-12-31", "cash_accounts": ["1000"]}), ); assert_eq!(out["file"]["file_name"], "cpa-package-2026-12-31.zip"); assert_eq!(out["file"]["mime_type"], "application/zip"); assert_eq!(out["tins_included"], true); let note = out["note"].as_str().unwrap(); assert!( note.contains("full TINs") && note.contains("1 TIN(s) are missing (tin_gaps)"), "{note}" ); let gaps: Vec<&str> = out["tin_gaps"] .as_array() .unwrap() .iter() .map(|g| g.as_str().unwrap()) .collect(); assert_eq!(gaps.len(), 1, "{gaps:?}"); assert!(gaps[0].contains("Mo Example") && gaps[0].contains("TIN not in the vault")); assert!(!holds_tin(out.to_string().as_bytes(), MEMBER_SSN_DIGITS)); assert!(!holds_tin(out.to_string().as_bytes(), EIN_DIGITS)); let pkg = unzip(&m.handed(&out["file"])); let k1 = csv_rows(&pkg["k1-members.csv"]); assert_eq!(k1.len(), 3, "two members and their total"); let (tobin, mo) = (&k1[0], &k1[1]); // The total row: not a K-1; ties to the balance sheet (1,700.00). let total = &k1[2]; assert_eq!(total["member"], "Total, all members (not a K-1)"); assert_eq!(total["tin"], ""); for (col, v) in [ ("beginning_capital", "2400.00"), ("contributions_cash", "600.00"), ("contributions_property", "600.00"), ("net_income", "-1600.00"), ("distributions", "300.00"), ("other", "0.00"), ("ending_capital", "1700.00"), ] { assert_eq!(total[col], v, "{col}"); } let sid = m.party_id("Tobin Example"); let kid = m.party_id("Mo Example"); for (row, want) in [ ( tobin, [ ("member", "Tobin Example"), ("party_id", sid.to_string().as_str()), ("share", "0.50"), ("capital_account", "3000"), ("tin", MEMBER_SSN), ("tin_type", "ssn"), ("tin_note", ""), ("beginning_capital", "1200.00"), ("contributions_cash", "100.00"), ("contributions_property", "600.00"), ("net_income", "-800.00"), ("distributions", "150.00"), ("other", "0.00"), ("ending_capital", "950.00"), ], ), ( mo, [ ("member", "Mo Example"), ("party_id", kid.to_string().as_str()), ("share", "0.50"), ("capital_account", "3001"), ("tin", ""), ("tin_type", ""), ("tin_note", "TIN not in the vault"), ("beginning_capital", "1200.00"), ("contributions_cash", "500.00"), ("contributions_property", "0.00"), ("net_income", "-800.00"), ("distributions", "150.00"), ("other", "0.00"), ("ending_capital", "750.00"), ], ), ] { for (col, v) in want { let got = row .get(col) .unwrap_or_else(|| panic!("no column {col}: {row:?}")); if col == "tin_note" && !v.is_empty() { assert!(got.starts_with(v), "{col}: {got}"); } else if col == "share" { assert_eq!( got.trim_end_matches('0').trim_end_matches('.'), "0.5", "{col}" ); } else { assert_eq!(got, v, "{col} of {row:?}"); } } } // The 1099 worksheet: the payee's full TIN. let w = csv_rows(&pkg["1099-worksheet-2026.csv"]); let acme = w.iter().find(|r| r["name"] == "Acme Design").unwrap(); assert_eq!(acme["tin"], EIN); assert_eq!(acme["tin_type"], "ein"); assert_eq!(acme["tin_last4"], "9105"); assert_eq!(acme["reportable"], "true"); let readme = String::from_utf8(pkg["README.txt"].clone()).unwrap(); assert!( readme.contains("k1-members.csv") && readme.contains("Mo Example"), "{readme}" ); assert!(!holds_tin(readme.as_bytes(), MEMBER_SSN_DIGITS)); // Nothing of it in the books' folder, but the file handed over, kept // for its download (D-29), and nothing in exports/ but what was handed. let books = m.books_dir(); let handed = handed_exports(&m); for f in files_under(&books) { if f.starts_with(m.vault()) || handed.contains(&f) { continue; } let b = std::fs::read(&f).unwrap(); assert!( !holds_tin(&b, MEMBER_SSN_DIGITS) && !holds_tin(&b, EIN_DIGITS), "{}", f.display() ); assert!(!f.to_string_lossy().ends_with(".zip"), "{}", f.display()); } // Without TINs: last fours only. let out = m.read_only( "books.export_cpa_package", json!({"as_of": "2026-12-31", "cash_accounts": ["1000"], "include_tins": false}), ); assert_eq!(out["tins_included"], false); assert_eq!(out["note"], "The package, with last fours only."); assert!(out["tin_gaps"].as_array().unwrap().is_empty(), "{out}"); let plain = unzip(&m.handed(&out["file"])); let k1 = csv_rows(&plain["k1-members.csv"]); assert_eq!(k1[0]["tin_last4"], "4915"); assert!(!k1[0].contains_key("tin")); assert_eq!(k1[0]["ending_capital"], "950.00"); // "50%" in the settings prints by its value, at two places, as // it would had the books kept it as "0.5". assert_eq!(k1[0]["share"], "0.50"); assert_eq!(k1[1]["share"], "0.50"); assert_eq!(k1[2]["share"], "1.00", "the members' total"); for bytes in plain.values() { assert!(!holds_tin(bytes, MEMBER_SSN_DIGITS) && !holds_tin(bytes, EIN_DIGITS)); } } /// The package, with TINs or without, is handed over (by its download, /// D-29) and never written: no file appears in the books' folder outside /// the vault (but the file handed over, kept for its download), or /// anywhere else. #[test] fn a_package_is_handed_over_and_never_written() { let mut m = k1_books("k1-handed"); let books = m.books_dir(); let (books_before, outside_before) = (paths_outside_vault(&m, &books), m.outside_files()); for tins in [true, false] { let out = m.read_only( "books.export_cpa_package", json!({"as_of": "2026-12-31", "cash_accounts": ["1000"], "include_tins": tins}), ); assert_eq!(out["tins_included"], tins); assert_eq!(out["file"]["file_name"], "cpa-package-2026-12-31.zip"); let pkg = unzip(&m.handed(&out["file"])); assert!(pkg.contains_key("README.txt"), "include_tins {tins}"); assert_eq!( holds_tin(&pkg["k1-members.csv"], MEMBER_SSN_DIGITS), tins, "include_tins {tins}" ); } assert_eq!(paths_outside_vault(&m, &books), books_before); assert_eq!(m.outside_files(), outside_before); } /// A locked vault never fails the package: every TIN row says why, and /// tin_gaps lists each member and payee. #[test] fn a_locked_vault_still_builds_the_package() { let mut m = k1_books("k1-locked"); std::fs::remove_file(m.key()).unwrap(); let out = m.read_only( "books.export_cpa_package", json!({"as_of": "2026-12-31", "cash_accounts": ["1000"]}), ); assert_eq!(out["tins_included"], true); let gaps = out["tin_gaps"].as_array().unwrap(); assert_eq!(gaps.len(), 3, "two members and Acme: {gaps:?}"); assert!( gaps.iter().all(|g| g.as_str().unwrap().contains("locked")), "{gaps:?}" ); assert!(!m.key().exists(), "reading never makes a key"); let pkg = unzip(&m.handed(&out["file"])); let k1 = csv_rows(&pkg["k1-members.csv"]); let members: Vec<_> = k1 .iter() .filter(|r| !r["member"].starts_with("Total")) .collect(); assert_eq!(members.len(), 2); assert!( members .iter() .all(|r| r["tin"].is_empty() && r["tin_note"].contains("locked")) ); assert_eq!(k1[0]["ending_capital"], "950.00", "the numbers are there"); let w = csv_rows(&pkg["1099-worksheet-2026.csv"]); assert!(w.iter().all(|r| r["tin"].is_empty())); } /// B-26 ("No tool output, error, or log holds a full TIN"): a TIN put in /// the wrong field is refused, and the refusal never repeats it, however /// it was written: as a party name, a bare number where a string goes, or /// a share's percent. #[test] fn refusals_never_echo_a_tin() { let mut m = books("echo"); let id = m.post( "2026-02-01", json!({}), &[("6999", "debit", "100.00"), ("1000", "credit", "100.00")], ); for (tool, args) in [ ( "books.set_tin", json!({"party": MEMBER_SSN, "tin": MEMBER_SSN, "tin_type": "ssn"}), ), ( "books.set_tin", json!({"party": MEMBER_SSN_DIGITS.parse::().unwrap(), "tin": MEMBER_SSN, "tin_type": "ssn"}), ), ( "books.purge_tin", json!({"party": "241.40.4915", "reason": "x", "confirmed": true}), ), ( "books.set_entry_party", json!({"entry_id": id, "parties": [ {"party": "Acme Design", "percent": MEMBER_SSN}, {"party": MEMBER_SSN, "amount": "1.00"}, ]}), ), ] { let why = m.refuse(tool, args.clone()); assert!( !holds_tin(why.as_bytes(), MEMBER_SSN_DIGITS), "{tool} {args} echoed the TIN: {why}" ); } } /// B-26 for resource reads: a refused `resources/read` quotes the URI it /// was given, in its message and its data, and never repeats a TIN in it, /// however the URI carries one (a receipt id, a handbook section, an /// unknown path, a digest's `since`). #[test] fn resource_refusals_never_echo_a_tin() { let mut m = books("resource-echo"); for uri in [ format!("books://receipts/{MEMBER_SSN_DIGITS}"), format!("books://receipts/{MEMBER_SSN}"), format!("books://handbook/{MEMBER_SSN}"), format!("books://handbook/{MEMBER_SSN_DIGITS}"), format!("books://{MEMBER_SSN_DIGITS}"), format!("books://reports/{MEMBER_SSN}"), format!("books://digest?since={MEMBER_SSN}"), format!("books://digest?since={MEMBER_SSN_DIGITS}"), ] { let resp = m.rpc("resources/read", json!({"uri": uri})); let err = resp .get("error") .unwrap_or_else(|| panic!("{uri} should be refused: {resp}")); assert!( !holds_tin(err.to_string().as_bytes(), MEMBER_SSN_DIGITS), "{uri} echoed the TIN: {err}" ); assert!(!err["message"].as_str().unwrap().is_empty(), "{err}"); } } /// k1::member_party: a vendor whose display name is a member's name, with /// a W-9 EIN in the vault, never puts its TIN on the member's K-1 row: /// the member's TIN is a gap. #[test] fn a_k1_row_never_carries_another_partys_tin() { let mut m = k1_books("k1-lookalike"); m.ok( "books.update_party", json!({"party": "Mo Example", "archived": true}), ); m.add_party("KE Consulting LLC", &["contractor"]); m.ok( "books.update_party", json!({"party": "KE Consulting LLC", "display_name": "Mo Example"}), ); m.w9( "KE Consulting LLC", json!({"tin": "12-3456789", "tin_type": "ein"}), ); let out = m.read_only( "books.export_cpa_package", json!({"as_of": "2026-12-31", "cash_accounts": ["1000"]}), ); let gaps = out["tin_gaps"].to_string(); assert!(gaps.contains("member Mo Example"), "{gaps}"); let pkg = unzip(&m.handed(&out["file"])); let k1 = csv_rows(&pkg["k1-members.csv"]); let mo = k1.iter().find(|r| r["member"] == "Mo Example").unwrap(); assert_eq!(mo["tin"], "", "{mo:?}"); assert_eq!(mo["party_id"], "", "{mo:?}"); assert!(!holds_tin(&pkg["k1-members.csv"], "123456789")); } /// Members who file a joint individual return (files_jointly_with on /// both): the README says each gets their own K-1 and both go on one Form /// 1040; books.settings reports it; a one-sided setting is a settings error. #[test] fn joint_filers_are_noted_in_the_package() { let joint = "[[company.members]]\nname = \"Tobin Example\"\nshare = \"50%\"\ncapital_account = \"3000\"\nfiles_jointly_with = \"Mo Example\"\n\ [[company.members]]\nname = \"Mo Example\"\nshare = \"50%\"\ncapital_account = \"3001\"\nfiles_jointly_with = \"Tobin Example\"\n"; let mut m = books_with("joint", joint); m.accounts(&[("3001", "Capital Mo", "equity")]); let s = m.read_only("books.settings", json!({})); assert_eq!(s["members"][0]["files_jointly_with"], "Mo Example"); assert_eq!(s["members"][1]["files_jointly_with"], "Tobin Example"); let out = m.read_only( "books.export_cpa_package", json!({"as_of": "2026-12-31", "cash_accounts": ["1000"]}), ); let pkg = unzip(&m.handed(&out["file"])); let readme = String::from_utf8(pkg["README.txt"].clone()).unwrap(); let note = "Tobin Example and Mo Example file a joint individual return: each receives their own Schedule K-1; both are reported on the one Form 1040 (Schedule E, Part II)."; assert_eq!(readme.matches(note).count(), 1, "once per pair: {readme}"); } /// The vault's files and their bytes, by path. fn vault_bytes(m: &Mcp) -> std::collections::BTreeMap> { files_under(&m.vault()) .into_iter() .map(|p| { let bytes = std::fs::read(&p).unwrap(); (p, bytes) }) .collect() } /// MCP's idempotentHint ("calling it again with the same arguments has no /// further effect"; plan/served.md 4.9): a tool says so only when it is /// true of the books. Each tool here is called twice with the same /// arguments on a fixture where the first call does something; the hint is /// set exactly when the second call leaves the journal and the TIN vault /// byte for byte as the first left them. Every tool marked idempotent is in /// the table, so a new one is checked too. #[test] fn idempotent_tools_change_nothing_when_repeated() { let mut m = books("idempotent"); m.accounts(&[ ("1200", "Receivables", "asset"), ("1300", "Prepaid", "asset"), ("1500", "Equipment", "asset"), ("1501", "Accumulated Depreciation", "asset"), ("6100", "Depreciation", "expense"), ("6300", "Insurance", "expense"), ]); m.add_party("Dana Employee", &["employee"]); m.ok("books.add_project", json!({"name": "Harbor"})); let entry = m.post( "2026-02-01", json!({}), &[("6000", "debit", "100.00"), ("1000", "credit", "100.00")], ); let asset = m.ok( "books.register_asset", json!({"description": "Laptop", "purchase_date": "2026-01-15", "cost": "2400.00", "useful_life_months": 24, "cost_account": "1500", "accum_depr_account": "1501", "expense_account": "6100"}), )["asset_id"] .as_u64() .unwrap(); m.ok( "books.create_schedule", json!({"kind": "prepaid_expense", "description": "Insurance", "total": "300.00", "start": "2026-01-01", "months": 3, "balance_account": "1300", "pl_account": "6300"}), ); m.ok( "books.create_recurring", json!({"description": "Rent", "frequency": "monthly", "start": "2026-01-01", "lines": [{"account": "6999", "side": "debit", "amount": "50.00"}, {"account": "1000", "side": "credit", "amount": "50.00"}]}), ); let category = m.read_only("books.tax_categories", json!({}))["categories"][0]["key"] .as_str() .unwrap() .to_string(); m.ok( "books.add_instrument", json!({"kind": "member_card", "label": "Riley's Visa", "account": "1000"}), ); let calls: Vec<(&str, Value)> = vec![ ("books.verify", json!({"blobs": true})), ("books.back_up_now", json!({})), ( "books.set_setting", json!({"changes": [{"key": "company.timezone", "value": "America/Denver"}], "decided_by": "Owner"}), ), ( "books.run_schedules", json!({"through": "2026-03-31", "confirmed": true}), ), ("books.run_recurring", json!({"through": "2026-03-15"})), ( "books.set_vendor_1099", json!({"vendor": "Acme Design", "form_1099": "misc", "decided_by": "Test Owner"}), ), ( "books.set_entry_vendor", json!({"entry_id": entry, "vendor": "Acme Design"}), ), ( "books.set_entry_instrument", json!({"entry_id": entry, "instrument": "Riley's Visa"}), ), ( "books.label_asset", json!({"asset": asset.to_string(), "serial": "SN-1"}), ), ( "books.set_asset_tax_basis", json!({"asset": asset.to_string(), "rule": "cost"}), ), ( "books.assign_role", json!({"role": "receivables", "account": "1200"}), ), ( "books.set_tax_category", json!({"account": "6000", "category": category}), ), ( "books.run_depreciation", json!({"period": "2026-03", "asset_id": asset}), ), ( "books.mark_cleared", json!({"entry_id": entry, "line_index": 1, "cleared": true}), ), ( "books.update_party", json!({"party": "Rent Co", "notes": "pays on the 1st"}), ), ( "books.set_party_roles", json!({"party": "Rent Co", "add": ["financial_institution"]}), ), ( "books.set_w9", json!({"party": "Acme Design", "received_on": "2026-01-05", "entity_type": "individual/sole proprietor", "tin": SSN, "tin_type": "ssn"}), ), ( "books.set_w9", json!({"party": "Rent Co", "received_on": "2026-01-05", "entity_type": "llc", "tin_last4": "9105", "kept_at": "the office safe"}), ), ( "books.set_tin", json!({"party": "Dana Employee", "tin": MEMBER_SSN, "tin_type": "ssn"}), ), ( "books.set_w4", json!({"party": "Dana Employee", "received_on": "2026-01-05", "state": "UT", "kept_at": "the office safe"}), ), ( "books.set_entry_party", json!({"entry_id": entry, "party": "Acme Design"}), ), ( "books.set_fx_rate", json!({"currency": "EUR", "date": "2026-01-02", "rate": "1.10"}), ), ( "books.update_project", json!({"project": "Harbor", "kind": "client", "status": "paused"}), ), ]; let list = m.rpc("tools/list", json!({})); let marked: std::collections::BTreeSet = list["result"]["tools"] .as_array() .unwrap() .iter() .filter(|t| t["annotations"]["idempotentHint"] == json!(true)) .map(|t| t["name"].as_str().unwrap().to_string()) .collect(); for name in &marked { assert!( calls.iter().any(|(n, _)| n == name), "{name} is marked idempotent: add it to this table" ); } let mut repeats_change: std::collections::BTreeMap<&str, bool> = Default::default(); for (name, args) in &calls { m.ok(name, args.clone()); let (journal, vault) = (m.journal(), vault_bytes(&m)); m.ok(name, args.clone()); let changed = m.journal() != journal || vault_bytes(&m) != vault; *repeats_change.entry(name).or_default() |= changed; } let wrong: Vec = repeats_change .into_iter() .filter(|(name, changed)| marked.contains(*name) == *changed) .map(|(name, changed)| { format!( "{name}: marked idempotent {}, but a repeat {} the books", marked.contains(name), if changed { "changes" } else { "leaves alone" }, ) }) .collect(); assert!(wrong.is_empty(), "{wrong:#?}"); } // ------------------------------------------------- defect 11 (P3) /// Defect 11 (audit F28): `books.parties` showed a member's TIN as /// missing (`tin_last4: null`) because it read W-9 facts only, while /// `books.party` showed the TIN facts' last four. The directory's last /// four is the TIN facts', else the W-9 facts' (they never disagree: /// parties INVARIANT 2a), null with neither, and kept after a purge /// (the facts keep the last four). #[test] fn defect11_the_directory_shows_a_members_tin_last4() { let mut m = books("defect11"); m.add_party("Mo", &["member"]); m.add_party("Nobody In Particular", &[]); m.ok( "books.set_tin", json!({"party": "Mo", "tin": MEMBER_SSN, "tin_type": "ssn"}), ); assert!( !m.w9( "Acme Design", json!({"tin_last4": "6789", "kept_at": "binder"}) ) .refused() ); let row = |m: &mut Mcp, name: &str| -> Value { let list = m.read_only("books.parties", json!({"year": 2026})); list["parties"] .as_array() .unwrap() .iter() .find(|p| p["name"] == name) .unwrap_or_else(|| panic!("{name} in {list}")) .clone() }; let mo = row(&mut m, "Mo"); assert_eq!( (mo["tin_last4"].clone(), mo["w9_on_file"].clone()), (json!("4915"), json!(false)) ); assert_eq!( m.party("Mo", 2026)["party"]["tin"]["last4"], mo["tin_last4"] ); let acme = row(&mut m, "Acme Design"); assert_eq!( (acme["tin_last4"].clone(), acme["w9_on_file"].clone()), (json!("6789"), json!(true)) ); assert!(row(&mut m, "Nobody In Particular")["tin_last4"].is_null()); m.ok( "books.purge_tin", json!({"party": "Mo", "reason": "left the company", "confirmed": true}), ); assert_eq!(row(&mut m, "Mo")["tin_last4"], "4915"); let list = m.read_only("books.parties", json!({})); assert!(!holds_tin(list.to_string().as_bytes(), MEMBER_SSN_DIGITS)); }