feat(mcp): void, control check, payment method and reference (P4b S1, part B)
check / check (push) Canceled after 0s

books.void_document voids an invoice or bill that should never have
existed by reversing its own entry with the reason (refused while a
payment stands on it, naming that payment). books.control_check ties
receivables and payables to their documents (B-23, B-38 INVARIANT 1):
balance, documents' total, lines that are no document's, the documents
needing attention; books.verify checks the identity and books.digest
lists its problems (control_problems). books.receive_payment and
books.pay_bill take instrument (tagged in the same call) and reference
(in the description), and answer the document's derived remaining and
status. The books record payments; they never collect or make them
(D-45). Docs, handbook and STATUS; the over-the-wire test
documents_void_and_tie_out_over_the_wire; the happy-path journey covers
both tools' output schemas. The processor fee waits for S6.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01T3gXMVt7XJPi6ens1or9Dj
This commit is contained in:
swaitsandClaude Opus 5.5 committed 2026-10-24 12:22:53 -06:00
1 parent ebb5fcb46f
commit 07f213c9e5
8 files changed
+502 -44

No files matched your search

+1 -1
View File
@@ -125,7 +125,7 @@ fn mcp_end_to_end() {
// 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(), 118, "expected 118 tools, got {}", tools.len());
assert_eq!(tools.len(), 120, "expected 120 tools, got {}", tools.len());
// Files come in as tool input: books.begin_upload, never a local path.
assert!(tools.iter().any(|t| t["name"] == "books.begin_upload"));
assert!(!tools.iter().any(|t| t["name"] == "books.import_local_file"));
+114
View File
@@ -878,6 +878,19 @@ fn happy_path(name: &str) -> Mcp {
);
m.ok("books.ar_aging", json!({"as_of": "2026-06-30"}));
m.ok("books.ap_aging", json!({"as_of": "2026-06-30"}));
// P4b S1: a document voided, and the control accounts' tie-out.
let dup = m.ok(
"books.create_invoice",
json!({"party_id": customer, "date": "2026-02-02", "due_date": "2026-03-02",
"lines": [{"description": "Consulting, entered twice", "amount": "500.00"}]}),
)["id"]
.as_u64()
.unwrap();
m.ok(
"books.void_document",
json!({"kind": "invoice", "id": dup, "date": "2026-02-02", "reason": "entered twice"}),
);
m.ok("books.control_check", json!({"as_of": "2026-06-30"}));
// Fixed assets.
let registered = m.ok(
@@ -2503,3 +2516,104 @@ fn db_digest(path: &Path) -> String {
}
ledger_store::journal::sha256_hex(dump.as_bytes())
}
/// P4b S1 over the real server (rules.md B-38, D-45): a payment carries
/// its method and reference and answers with the invoice's derived
/// figures; an invoice with a payment standing can't be voided (the
/// refusal names the payment); one entered twice is voided and shows as
/// void; a hand line to receivables is refused; and the control check
/// ties receivables to the open invoices, with nothing for the digest.
#[test]
fn documents_void_and_tie_out_over_the_wire() {
let dir = test_dir("p4b_s1");
let toml = books_toml(&dir, "600.00", "");
let mut m = Mcp::start(&dir, &toml);
m.accounts(BASE_CHART);
let customer = m.ok("books.add_customer", json!({"name": "Client"}))["id"]
.as_u64()
.unwrap();
m.ok(
"books.add_instrument",
json!({"kind": "bank", "label": "Harbor checking", "last4": "1234", "account": "1000"}),
);
let inv = m.ok(
"books.create_invoice",
json!({"party_id": customer, "date": "2026-02-01", "due_date": "2026-03-01",
"lines": [{"description": "Consulting", "amount": "500.00"}]}),
)["id"]
.as_u64()
.unwrap();
let paid = m.ok(
"books.receive_payment",
json!({"document_id": inv, "date": "2026-02-10", "amount": "200.00",
"cash_account": "1000", "instrument": "Harbor checking", "reference": "tr_8812"}),
);
assert_eq!(paid["remaining"], "300.00", "{paid}");
assert_eq!(paid["status"], "partial", "{paid}");
let payment = paid["entry_id"].as_u64().unwrap();
let entry = m.ok("books.get_entry", json!({"entry_id": payment}));
assert!(
entry["description"].as_str().unwrap().contains("tr_8812"),
"{entry}"
);
assert_eq!(entry["instrument"], "Harbor checking", "{entry}");
let why = m
.tool(
"books.void_document",
json!({"kind": "invoice", "id": inv, "date": "2026-02-11", "reason": "wrong client"}),
)
.err_text();
assert!(why.contains(&format!("entry {payment}")), "{why}");
let dup = m.ok(
"books.create_invoice",
json!({"party_id": customer, "date": "2026-02-02", "due_date": "2026-03-02",
"lines": [{"description": "Consulting, entered twice", "amount": "500.00"}]}),
)["id"]
.as_u64()
.unwrap();
let void = m.ok(
"books.void_document",
json!({"kind": "invoice", "id": dup, "date": "2026-02-02", "reason": "entered twice"}),
);
assert_eq!(void["status"], "void", "{void}");
let e = m.ok(
"books.draft_entry",
json!({"date": "2026-02-15", "description": "By hand", "no_party_reason": "test"}),
)["entry_id"]
.as_u64()
.unwrap();
assert!(
m.tool(
"books.add_line",
json!({"entry_id": e, "account": "1200", "side": "debit", "amount": "50.00"}),
)
.refused()
);
let check = m.ok("books.control_check", json!({"as_of": "2026-06-30"}));
assert_eq!(check["agrees"], true, "{check}");
let ar = check["accounts"]
.as_array()
.unwrap()
.iter()
.find(|a| a["role"] == "receivables")
.cloned()
.unwrap();
assert_eq!(ar["code"], "1200", "{check}");
assert_eq!(ar["balance"], "300.00", "{check}");
assert_eq!(ar["documents_total"], "300.00", "{check}");
assert_eq!(ar["difference"], "0.00", "{check}");
let digest = m.ok("books.digest", json!({"since": "2026-01-01"}));
assert_eq!(digest["control_problems"], json!([]), "{digest}");
let verified = m.ok("books.verify", json!({}));
assert!(
verified["checked"].as_array().unwrap().iter().any(|c| c
.as_str()
.unwrap()
.starts_with("receivables and payables equal")),
"{verified}"
);
}