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:
1 parent
a299077991
commit
08ce558ed3
41 files changed
+8463
-465
No files matched your search
@@ -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
@@ -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"}));
|
||||
|
||||
@@ -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
Reference in new issue
Block a user