Attachments, statements, reconciliations, TIN vault, counterparty policy

MCP: books.link_attachment, unlink_attachment, attachments; link_to on
attach_receipt and file_document; record_statement, statements;
books.reconcile previews and, confirmed against a recorded statement,
records the reconciliation; void_reconciliation, reconciliations;
import_bank_csv links its source file; digest lists unreconciled
accounts.

TIN vault: books.set_w9 takes the TIN and the form into the encrypted
vault (last four in the books); books.vendor_1099 include_tins and
save_csv_to for the CPA's copy; books.get_w9; books.purge_tin. A leak
test scans every tool output, the journal, the database, and the blobs.

[books] parties = off | suggest | require (default suggest): every
preview names the entry's party or suggests likely ones; require refuses
to post until it names one or is marked as having none (entry.no_party).

Viewer: documents on every subject page; statements and reconciliation
history per account; reconciliation pages; reconcile and
no-counterparty attention items; party money by period (paid out,
received in, net, what for, by month); Needs attention gets its own
full-width section.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GWVo7x3UHw5kwCUaWxiejX
This commit is contained in:
swaitsandClaude Opus 5.5 committed 2026-10-15 15:42:37 -06:00
1 parent a299077991
commit 08ce558ed3
41 files changed
+8463 -465

No files matched your search

+1 -1
View File
@@ -147,7 +147,7 @@ fn mcp_end_to_end_over_stdio() {
// Tool listing: the narrow tools; chart is read-only.
let list = mcp.request("tools/list", json!({}));
let tools = list["result"]["tools"].as_array().unwrap();
assert_eq!(tools.len(), 82, "expected 82 tools, got {}", tools.len());
assert_eq!(tools.len(), 91, "expected 91 tools, got {}", tools.len());
let chart = tools.iter().find(|t| t["name"] == "books.chart").unwrap();
assert_eq!(chart["annotations"]["readOnlyHint"], true);
assert!(
File diff suppressed because it is too large. Load diff
+102 -4
View File
@@ -175,6 +175,14 @@ fn test_dir(name: &str) -> PathBuf {
dir
}
/// A directory beside the books dir `dir`, outside it (vault keys, files
/// with TINs).
fn outside(dir: &Path) -> PathBuf {
let d = PathBuf::from(format!("{}-outside", dir.display()));
std::fs::create_dir_all(&d).unwrap();
d
}
/// Write a `wanderbooks.toml` with the given receipt threshold and extra
/// TOML (appended under `[books]` unless it opens its own table).
fn books_toml(dir: &Path, threshold: &str, extra: &str) -> PathBuf {
@@ -201,6 +209,9 @@ fn spawn_child(dir: &Path, toml: &Path) -> Child {
// books.show writes a viewer request here; keep it out of the
// user's real runtime dir.
.env("XDG_RUNTIME_DIR", &run)
// The TIN vault's default key file is under XDG_CONFIG_HOME: never
// the user's real one (the happy path names its own anyway).
.env("XDG_CONFIG_HOME", outside(dir).join("config"))
.stdin(Stdio::piped())
.stdout(Stdio::piped())
.stderr(Stdio::null())
@@ -572,9 +583,13 @@ fn happy_path(name: &str) -> Mcp {
let toml = books_toml(
&dir,
"1000000.00",
"financing_accounts = [\"2100\"]\n\
&format!(
"financing_accounts = [\"2100\"]\n\
[[company.members]]\nname = \"A\"\nshare = \"50%\"\ncapital_account = \"3000\"\n\
[[company.members]]\nname = \"B\"\nshare = \"50%\"\ncapital_account = \"3001\"\n",
[[company.members]]\nname = \"B\"\nshare = \"50%\"\ncapital_account = \"3001\"\n\
[vault]\nkey_file = \"{}\"\n",
outside(&dir).join("vault.key").display()
),
);
let mut m = Mcp::start(&dir, &toml);
@@ -675,6 +690,18 @@ fn happy_path(name: &str) -> Mcp {
json!({"party": "Landlord", "received_on": "2026-01-03", "entity_type": "LLC (partnership)",
"tin_last4": "4321", "kept_at": "CPA portal"}),
);
let w9_pdf = outside(&dir).join("landlord-w9.pdf");
std::fs::write(&w9_pdf, b"%PDF-1.4 W-9 EIN 79-9714321").unwrap();
m.ok(
"books.set_w9",
json!({"party": "Landlord", "received_on": "2026-01-03", "entity_type": "LLC (partnership)",
"tin": "79-9714321", "tin_type": "ein", "w9_file_path": w9_pdf.to_str().unwrap()}),
);
m.ok(
"books.get_w9",
json!({"party": "Landlord",
"save_to": outside(&dir).join("landlord-w9-copy.pdf").to_str().unwrap()}),
);
m.ok("books.parties", json!({"role": "landlord", "year": 2026}));
// Owner contribution: draft, lines, receipt, preview, post.
@@ -748,6 +775,25 @@ fn happy_path(name: &str) -> Mcp {
)["attachment_id"]
.as_u64()
.unwrap();
let filed = m.ok(
"books.file_document",
json!({"category": "contract", "file_name": "lease.txt", "mime_type": "text/plain",
"content_base64": b64(b"lease"), "link_to": [{"kind": "company"}]}),
)["attachment_id"]
.as_u64()
.unwrap();
m.ok(
"books.link_attachment",
json!({"attachment_id": filed, "subject": {"kind": "party", "id": "Landlord"}}),
);
m.ok(
"books.attachments",
json!({"subject": {"kind": "party", "id": "Landlord"}}),
);
m.ok(
"books.unlink_attachment",
json!({"attachment_id": filed, "subject": {"kind": "company"}, "reason": "a lease, not a formation document"}),
);
let local = dir.join("local-receipt.txt");
std::fs::write(&local, b"a local receipt").unwrap();
m.ok(
@@ -899,15 +945,54 @@ fn happy_path(name: &str) -> Mcp {
);
// Bank import and reconciliation.
let csv = "Date,Description,Amount\n2026-03-05,Coffee,-4.50\n";
let source = m.ok(
"books.file_document",
json!({"category": "import_source", "file_name": "bank.csv", "mime_type": "text/csv",
"content_base64": b64(csv.as_bytes())}),
)["attachment_id"]
.as_u64()
.unwrap();
m.ok(
"books.import_bank_csv",
json!({"csv_text": "Date,Description,Amount\n2026-03-05,Coffee,-4.50\n",
"bank_account_code": "1000", "default_offset_code": "6999"}),
json!({"csv_text": csv, "bank_account_code": "1000", "default_offset_code": "6999",
"source_attachment_id": source}),
);
m.ok(
"books.reconcile",
json!({"account": "1000", "statement_date": "2026-12-31", "statement_balance": "100.00"}),
);
// January's statement: the contribution and the rent cleared.
m.ok(
"books.mark_cleared",
json!({"entry_id": e1, "line_index": 0, "cleared": true}),
);
let stmt = m.ok(
"books.file_document",
json!({"category": "statement", "file_name": "2026-01.pdf", "mime_type": "application/pdf",
"content_base64": b64(b"%PDF statement")}),
)["attachment_id"]
.as_u64()
.unwrap();
m.ok(
"books.record_statement",
json!({"attachment_id": stmt, "account": "1000", "period_start": "2026-01-01",
"period_end": "2026-01-31", "opening_balance": "0.00", "closing_balance": "9900.00"}),
);
m.ok("books.statements", json!({}));
m.ok("books.reconcile", json!({"statement_attachment": stmt}));
let rec = m.ok(
"books.reconcile",
json!({"statement_attachment": stmt, "confirmed": true}),
)["reconciliation"]["reconciliation_id"]
.as_u64()
.unwrap();
m.ok("books.reconciliations", json!({"account": "1000"}));
m.ok("books.show", json!({"reconciliation": rec}));
m.ok(
"books.void_reconciliation",
json!({"reconciliation_id": rec, "reason": "redo after the March import"}),
);
// Periods.
let p = m.ok(
@@ -943,6 +1028,19 @@ fn happy_path(name: &str) -> Mcp {
m.ok("books.digest", json!({"since": "2026-01-01"}));
m.ok("books.search_entries", json!({"text": "rent", "limit": 10}));
m.ok("books.vendor_1099", json!({"year": 2026}));
m.ok(
"books.vendor_1099",
json!({"year": 2026, "include_tins": true,
"save_csv_to": outside(&dir).join("1099.csv").to_str().unwrap()}),
);
m.ok(
"books.purge_tin",
json!({"party": "Landlord", "reason": "test purge"}),
);
m.ok(
"books.purge_tin",
json!({"party": "Landlord", "reason": "test purge", "confirmed": true}),
);
m.ok("books.party", json!({"party": "Landlord", "year": 2026}));
m.ok("books.party", json!({"party": "Pat", "year": 2026}));
m.ok("books.party", json!({"party": "Client"}));
+166
View File
@@ -1147,3 +1147,169 @@ fn no_party_output_holds_a_full_tin() {
assert!(digits.iter().all(|&n| n < 9), "{text}");
}
}
// ---------------------------------------------------------------- [books] parties
/// Books with the chart and extra `[books]` TOML.
fn books_with(name: &str, extra: &str) -> Mcp {
let dir = test_dir(name);
let toml = books_toml(&dir, "Spec Co", extra);
let mut m = Mcp::start(&dir, &toml);
m.accounts(CHART);
m
}
/// Oracle: PostEntryOut party fields and the "suggest" default
/// (docs/parties-and-attachments.md "Counterparties"): an untagged preview
/// names likely parties (description, then the same description on an
/// earlier tagged entry); nothing is refused.
#[test]
fn suggest_names_likely_parties_and_refuses_nothing() {
let mut m = books("suggest");
assert_eq!(
m.read_only("books.settings", json!({}))["parties"],
"suggest"
);
let apple = m.add_party("Apple Inc.", &[]);
let id = m.ok(
"books.draft_entry",
json!({"date": "2026-03-01", "description": "APPLE.COM/BILL iCloud"}),
)["entry_id"]
.as_u64()
.unwrap();
m.ok(
"books.add_line",
json!({"entry_id": id, "account": "6999", "side": "debit", "amount": "2.99"}),
);
m.ok(
"books.add_line",
json!({"entry_id": id, "account": "2050", "side": "credit", "amount": "2.99"}),
);
let pv = m.ok("books.post_entry", json!({"entry_id": id}));
assert_eq!(pv["party_policy"], "suggest");
assert_eq!(pv["party_missing"], true);
assert_eq!(pv["party_suggestions"][0]["party_id"], apple);
assert_eq!(
pv["party_suggestions"][0]["why"],
"named in the description"
);
m.ok(
"books.post_entry",
json!({"entry_id": id, "confirmed": true}),
);
// Tagged: the next entry with the same description is suggested from it.
m.ok(
"books.set_entry_party",
json!({"entry_id": id, "party": apple.to_string()}),
);
let other = m.add_party("Zed", &[]);
let _ = other;
let id2 = m.ok(
"books.draft_entry",
json!({"date": "2026-04-01", "description": "APPLE.COM/BILL iCloud"}),
)["entry_id"]
.as_u64()
.unwrap();
m.ok(
"books.add_line",
json!({"entry_id": id2, "account": "6999", "side": "debit", "amount": "2.99"}),
);
m.ok(
"books.add_line",
json!({"entry_id": id2, "account": "2050", "side": "credit", "amount": "2.99"}),
);
let pv = m.ok("books.post_entry", json!({"entry_id": id2}));
let s = pv["party_suggestions"].as_array().unwrap();
assert_eq!(s.len(), 1, "one party, listed once: {s:?}");
assert_eq!(s[0]["party_id"], apple);
}
/// Oracle: "require": posting is refused (party_required) and journals
/// nothing until the entry names a party or is marked as having none;
/// no_party_reason is refused alongside a party.
#[test]
fn require_refuses_until_tagged_or_marked_none() {
let mut m = books_with("require", "parties = \"require\"\n");
assert_eq!(
m.read_only("books.settings", json!({}))["parties"],
"require"
);
let bank = m.add_party("Bluevine", &["financial_institution"]);
let id = m.draft(
"2026-03-01",
json!({}),
&[("6999", "debit", "5.00"), ("1000", "credit", "5.00")],
);
let before = m.journal();
let e = m.refuse(
"books.post_entry",
json!({"entry_id": id, "confirmed": true}),
);
assert!(e.contains("party_required"), "{e}");
assert_eq!(m.journal(), before, "refusal journals nothing");
m.ok(
"books.set_entry_party",
json!({"entry_id": id, "party": bank.to_string()}),
);
let pv = m.ok(
"books.post_entry",
json!({"entry_id": id, "confirmed": true}),
);
assert_eq!(pv["status"], "posted");
assert_eq!(pv["party_id"], bank);
// Marked none at draft time: posts, and the reason is journaled.
let t = m.draft(
"2026-03-02",
json!({"no_party_reason": "transfer between own accounts"}),
&[("1300", "debit", "1.00"), ("1000", "credit", "1.00")],
);
let pv = m.ok(
"books.post_entry",
json!({"entry_id": t, "confirmed": true}),
);
assert_eq!(pv["no_party_reason"], "transfer between own accounts");
assert_eq!(pv["party_missing"], false);
assert_eq!(m.count("entry.no_party"), 1);
// Both at once is refused.
let e = m.refuse(
"books.draft_entry",
json!({"date": "2026-03-03", "description": "x", "party": "Bluevine", "no_party_reason": "none"}),
);
assert!(e.contains("no_party_reason"), "{e}");
let e = m.refuse(
"books.set_entry_party",
json!({"entry_id": t, "party": "Bluevine", "no_party_reason": "none"}),
);
assert!(e.contains("not both"), "{e}");
// Tagging later clears the mark; marking again needs no party.
m.ok(
"books.set_entry_party",
json!({"entry_id": t, "party": "Bluevine"}),
);
m.ok(
"books.set_entry_party",
json!({"entry_id": t, "no_party_reason": "own-account transfer"}),
);
let r = m.read_only("books.verify", json!({}));
assert_eq!(r["status"], "ok", "{r}");
}
/// Oracle: "off": the preview never raises a party; a bad value is a
/// settings error.
#[test]
fn off_never_raises_it() {
let mut m = books_with("off", "parties = \"off\"\n");
m.add_party("Apple", &[]);
let id = m.draft(
"2026-03-01",
json!({}),
&[("6999", "debit", "5.00"), ("1000", "credit", "5.00")],
);
let pv = m.ok("books.post_entry", json!({"entry_id": id}));
assert_eq!(pv["party_missing"], false);
assert!(pv["party_suggestions"].as_array().unwrap().is_empty());
m.ok(
"books.post_entry",
json!({"entry_id": id, "confirmed": true}),
);
}
File diff suppressed because it is too large. Load diff