Files
wanderbooks/crates/ledger-cli/tests/mcp_journeys.rs
T
swaitsandClaude Opus 5.5 7741e96eb3
check / check (push) Canceled after 0s
refactor!: one way to serve books: the stdio server is gone (D-44)
The owner (2026-10-23): "We should probably just delete the stdio path
altogether. THere's no point in that. And it sitting there unused will
just lead to bitrot. Nuke it?"

Books are served only by the books server (`wanderbooks server`),
reached by agents through the bridge (`wanderbooks mcp --book ID`),
which stays.

Removed: `wanderbooks dev-stdio`, the `ledger-mcp` binary, and
`ledger_mcp::run` (stdio and `--http` over a bare data dir), with all
that only that mode had:
- files the books make coming back inline: `HandedFile.content_base64`
  is gone, and `download` and `expires_at` are always there;
- books with no access log or backups: `books.access_log`,
  `books.backup` and `books.back_up_now` are always offered, by scope;
- the settings file read and reloaded (`legacy_files`), the vault key
  named by a file or `--vault-key-file`, and the books dir's
  key-inside-the-books and git checks (with `paths.rs`);
- `wire.rs` (the bridge frames an agent's lines itself);
- `books.settings`' file fields and `books.start`'s `settings_source`.

`BooksServer` is built one way, from `Served` (a book as the books
server serves it), in production and in the unit tests. An unknown tool
is now a protocol error (mcp-tools.md). Inline file input is unchanged.

Then the owner's three decisions (2026-10-23, the question tool):
(a) `books.import_settings` is removed entirely (118 tools): old books
come in whole by `wanderbooks books adopt`, which reads their settings
file once; ledger-config keeps its settings-file parsing only as far as
adoption uses it (`load` and the path-reading `effective_receipt_policy`
are gone), and `books.chart_template` gives its settings as
`books.set_setting` changes. (b) The viewer's loose-books-folder mode is
removed (`--data-dir`, `--books-toml`, `--vault-key-file`, the settings
file and config.json followed): it opens only books of the data root,
reading settings from the journal and the vault from the book's own
`vault/` and `keys/`. (c) `wanderbooks dev-stdio` has no stub: it is an
unknown command like any other word. Also: an upload's expiry past the
calendar is an error, never an empty string.

Tests: every suite that drove the stdio server moved to
crates/ledger-cli/tests/ and drives a real `wanderbooks server` on a
data root of its own (tests/common), handed files by their download;
mcp_stdio.rs is mcp_journeys.rs. Tests of what is gone are deleted and
listed in docs/testing.md. Docs: plan D-44 (D-18 superseded), STATUS,
testing, rules, mcp-interface, the handbook, README; justfile recipes.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01T3gXMVt7XJPi6ens1or9Dj
2026-10-23 19:45:18 -06:00

3261 lines
121 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! End-to-end journeys: drive a real `wanderbooks server` with raw
//! JSON-RPC, as an agent reaches it (common.rs), exercising draft →
//! preview → receipt policy → post, and the rest of an agent's day.
use common::{Books, Wire};
use serde_json::{Value, json};
mod common;
/// One agent's session with the test's books, over the books server's
/// socket (common.rs).
struct Mcp {
wire: Wire,
next_id: u64,
}
impl Mcp {
/// A new session with `books` (initialized by the test itself).
fn spawn(books: &Books) -> Self {
Self {
wire: books.wire(),
next_id: 1,
}
}
fn request(&mut self, method: &str, params: Value) -> Value {
let resp = self.request_raw(method, params);
assert!(
resp.get("error").is_none(),
"JSON-RPC error for {method}: {}",
resp["error"]
);
resp
}
fn request_raw(&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.wire.send(&msg);
let resp = self
.wire
.recv(common::TIMEOUT)
.unwrap_or_else(|| panic!("no answer to {method}: {:?}", self.wire.refused));
assert_eq!(resp["id"], id, "id mismatch in {resp}");
resp
}
fn notify(&mut self, method: &str) {
self.wire
.send(&json!({"jsonrpc": "2.0", "method": method, "params": {}}));
}
/// Call a tool; panic on tool error. Returns structuredContent.
fn call(&mut self, tool: &str, args: Value) -> Value {
let resp = self.request("tools/call", json!({"name": tool, "arguments": args}));
let result = &resp["result"];
assert_ne!(
result.get("isError"),
Some(&json!(true)),
"tool {tool} returned an error: {}",
result["content"]
);
result.get("structuredContent").cloned().unwrap_or_else(|| {
let text = result["content"][0]["text"].as_str().unwrap();
serde_json::from_str(text).unwrap()
})
}
/// Call a tool expecting an error; returns the error text.
fn call_err(&mut self, tool: &str, args: Value) -> String {
let resp = self.request("tools/call", json!({"name": tool, "arguments": args}));
let result = &resp["result"];
assert_eq!(
result.get("isError"),
Some(&json!(true)),
"expected {tool} to fail, got {result}"
);
result["content"][0]["text"].as_str().unwrap().to_string()
}
/// The bytes of a file a tool handed over (a `HandedFile`): fetched by
/// its download, matching the size and SHA-256 it states.
fn handed(&mut self, file: &Value) -> Vec<u8> {
assert!(file["expires_at"].is_string(), "{file}");
self.wire.fetch(file)
}
}
#[test]
fn mcp_end_to_end() {
let books = Books::new("main");
let mut mcp = Mcp::spawn(&books);
// Initialize: the server identifies itself and delivers the contract.
let init = mcp.request(
"initialize",
json!({
"protocolVersion": "2025-06-18",
"capabilities": {},
"clientInfo": {"name": "smoke-test", "version": "0.1.0"},
}),
);
assert_eq!(init["result"]["serverInfo"]["name"], "wanderbooks");
let instructions = init["result"]["instructions"].as_str().unwrap();
assert!(instructions.contains("bookkeeping agent"));
assert!(instructions.contains("decimal strings"));
mcp.notify("notifications/initialized");
// 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());
// 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"));
let chart = tools.iter().find(|t| t["name"] == "books.chart").unwrap();
assert_eq!(chart["annotations"]["readOnlyHint"], true);
assert!(
chart["description"]
.as_str()
.unwrap()
.contains("chart of accounts")
);
// Fresh books: empty chart.
let chart_out = mcp.call("books.chart", json!({}));
assert_eq!(chart_out["accounts"].as_array().unwrap().len(), 0);
assert_eq!(chart_out["base_currency"], "USD");
// Build a chart.
for (code, name, typ) in [
("1000", "Cash", "asset"),
("3000", "Owner's Equity", "equity"),
("4000", "Service Revenue", "revenue"),
("6000", "Rent Expense", "expense"),
] {
let out = mcp.call(
"books.add_account",
json!({"code": code, "name": name, "account_type": typ}),
);
assert!(out["account_id"].as_u64().unwrap() > 0);
}
// Duplicate code is rejected.
let err = mcp.call_err(
"books.add_account",
json!({"code": "1000", "name": "Cash 2", "account_type": "asset"}),
);
assert!(err.contains("already in use"), "got: {err}");
// Draft → lines → preview.
let draft = mcp.call(
"books.draft_entry",
json!({"date": "2026-01-15", "description": "January rent"}),
);
let entry_id = draft["entry_id"].as_u64().unwrap();
mcp.call(
"books.add_line",
json!({"entry_id": entry_id, "account": "6000", "side": "debit", "amount": "100.00"}),
);
mcp.call(
"books.add_line",
json!({"entry_id": entry_id, "account": "1000", "side": "credit", "amount": "100.00"}),
);
let preview = mcp.call("books.post_entry", json!({"entry_id": entry_id}));
assert_eq!(preview["status"], "preview");
assert_eq!(preview["total"], "100.00");
assert_eq!(preview["receipt_required"], true);
assert_eq!(preview["receipt_missing"], true);
assert_eq!(preview["lines"].as_array().unwrap().len(), 2);
// Resulting balances: rent expense goes 0 → 100.
let rent_line = preview["lines"]
.as_array()
.unwrap()
.iter()
.find(|l| l["code"] == "6000")
.unwrap();
assert_eq!(rent_line["balance_before"], "0.00");
assert_eq!(rent_line["balance_after"], "100.00");
// Confirmed post without a receipt is rejected: >$25, no receipt.
let err = mcp.call_err(
"books.post_entry",
json!({"entry_id": entry_id, "confirmed": true}),
);
let err_json: serde_json::Value = serde_json::from_str(&err).unwrap();
assert_eq!(err_json["code"], "receipt_required");
assert_eq!(err_json["remedy_tool"], "books.attach_receipt");
// Attach the receipt, then the confirmed post succeeds.
let png = base64_like(b"fake-png-bytes");
let attached = mcp.call(
"books.attach_receipt",
json!({
"entry_id": entry_id,
"file_name": "rent.png",
"mime_type": "image/png",
"content_base64": png,
"sha256": sha(b"fake-png-bytes"),
}),
);
assert_eq!(attached["deduplicated"], false);
assert_eq!(attached["sha256"].as_str().unwrap().len(), 64);
let posted = mcp.call(
"books.post_entry",
json!({"entry_id": entry_id, "confirmed": true}),
);
assert_eq!(posted["status"], "posted");
assert_eq!(posted["receipt_missing"], false);
// Reports agree.
let tb = mcp.call("books.trial_balance", json!({}));
assert_eq!(tb["balanced"], true);
assert_eq!(tb["total_debits"], "100.00");
assert_eq!(tb["total_credits"], "100.00");
let pnl = mcp.call(
"books.profit_and_loss",
json!({"start": "2026-01-01", "end": "2026-01-31"}),
);
assert_eq!(pnl["expenses"], "100.00");
assert_eq!(pnl["net_income"], "-100.00");
let bs = mcp.call("books.balance_sheet", json!({"as_of": "2026-01-31"}));
assert_eq!(bs["balanced"], true);
// Digest and search see the entry.
let digest = mcp.call("books.digest", json!({"since": "2026-01-01"}));
assert_eq!(digest["entries"].as_array().unwrap().len(), 1);
assert_eq!(digest["entries"][0]["receipt_missing"], false);
// Stale legal rules ride along with the digest. Which ones depends on
// the server's clock, so assert only what holds on any date.
for rule in digest["stale_rules"]
.as_array()
.expect("stale_rules is a list")
{
assert!(rule["name"].is_string() && rule["source"].is_string());
}
let search = mcp.call("books.search_entries", json!({"text": "rent"}));
assert_eq!(search["entries"].as_array().unwrap().len(), 1);
assert_eq!(search["entries"][0]["receipt_count"], 1);
// Export works.
let export = mcp.call(
"books.export",
json!({"format": "csv", "report": "journal", "start": "2026-01-01", "end": "2026-12-31"}),
);
assert!(export["content"].as_str().unwrap().contains("January rent"));
// Reversal: preview then post; the book still balances.
let rev_preview = mcp.call(
"books.reverse_entry",
json!({"entry_id": entry_id, "date": "2026-01-16", "description": "Reverse rent"}),
);
assert_eq!(rev_preview["status"], "preview");
let rev = mcp.call(
"books.reverse_entry",
json!({"entry_id": entry_id, "date": "2026-01-16", "description": "Reverse rent", "confirmed": true}),
);
assert_eq!(rev["status"], "posted");
let tb2 = mcp.call("books.trial_balance", json!({}));
assert_eq!(tb2["balanced"], true);
assert_eq!(tb2["total_debits"], "0.00");
}
#[test]
fn mcp_assets_end_to_end() {
let books = Books::new("assets");
let mut mcp = Mcp::spawn(&books);
let init = mcp.request(
"initialize",
json!({
"protocolVersion": "2025-06-18",
"capabilities": {},
"clientInfo": {"name": "smoke-test", "version": "0.1.0"},
}),
);
assert_eq!(init["result"]["serverInfo"]["name"], "wanderbooks");
mcp.notify("notifications/initialized");
// Chart for the asset: cost, contra, expense, bank, gain.
for (code, name, typ) in [
("1500", "Equipment", "asset"),
("1501", "Accumulated Depreciation", "asset"),
("6100", "Depreciation Expense", "expense"),
("1000", "Bank", "asset"),
("4900", "Gain on Disposal", "revenue"),
] {
mcp.call(
"books.add_account",
json!({"code": code, "name": name, "account_type": typ}),
);
}
// Register the van: 60,000, 60-month life, bought 2026-01-15.
let reg = mcp.call(
"books.register_asset",
json!({
"description": "Sprinter van",
"purchase_date": "2026-01-15",
"cost": "60000.00",
"useful_life_months": 60,
"cost_account": "1500",
"accum_depr_account": "1501",
"expense_account": "6100",
}),
);
let asset_id = reg["asset_id"].as_u64().unwrap();
// Schedule: 61 calendar months, lifetime total exactly 60,000.
let sched = mcp.call("books.asset_schedule", json!({"asset_id": asset_id}));
let lines = sched["schedule"].as_array().unwrap();
assert_eq!(lines.len(), 61);
assert_eq!(lines[0]["period"], "2026-01");
assert_eq!(lines[0]["amount"], "548.39");
assert_eq!(lines[60]["period"], "2031-01");
let total: f64 = lines
.iter()
.map(|l| l["amount"].as_str().unwrap().parse::<f64>().unwrap())
.sum();
assert!((total - 60000.0).abs() < 0.005, "total was {total}");
assert_eq!(sched["status"], "active");
// List: one active asset.
let list = mcp.call("books.list_assets", json!({}));
assert_eq!(list["assets"].as_array().unwrap().len(), 1);
assert_eq!(list["assets"][0]["status"], "active");
// Run January: posts 548.39.
let run1 = mcp.call(
"books.run_depreciation",
json!({"period": "2026-01", "asset_id": asset_id}),
);
assert_eq!(run1["runs"][0]["amount"], "548.39");
assert_eq!(run1["runs"][0]["already_posted"], false);
let entry_id = run1["runs"][0]["entry_id"].as_u64().unwrap();
// Idempotent: second run returns the same entry.
let run2 = mcp.call(
"books.run_depreciation",
json!({"period": "2026-01", "asset_id": asset_id}),
);
assert_eq!(run2["runs"][0]["already_posted"], true);
assert_eq!(run2["runs"][0]["entry_id"], entry_id);
// Run-all for February: picks up the van, posts a full 1,000.
let run_all = mcp.call("books.run_depreciation", json!({"period": "2026-02"}));
assert_eq!(run_all["runs"][0]["amount"], "1000.00");
assert_eq!(run_all["skipped"].as_array().unwrap().len(), 0);
// And again: already posted.
let run_all2 = mcp.call("books.run_depreciation", json!({"period": "2026-02"}));
assert_eq!(run_all2["runs"][0]["already_posted"], true);
// Dispose preview: book value 60000 − 1548.39 = 58451.61;
// proceeds 60000 → gain 1548.39. Receipt required.
let preview = mcp.call(
"books.dispose_asset",
json!({
"asset_id": asset_id,
"date": "2026-02-28",
"proceeds": "60000",
"proceeds_account": "1000",
"gain_loss_account": "4900",
}),
);
assert_eq!(preview["status"], "preview");
assert_eq!(preview["book_value"], "58451.61");
assert_eq!(preview["gain_loss"], "1548.39");
assert_eq!(preview["final_depreciation"], "0.00");
assert_eq!(preview["receipt_required"], true);
// Confirmed without the bill of sale: rejected.
let err = mcp.call_err(
"books.dispose_asset",
json!({
"asset_id": asset_id,
"date": "2026-02-28",
"proceeds": "60000",
"proceeds_account": "1000",
"gain_loss_account": "4900",
"confirmed": true,
}),
);
let err_json: serde_json::Value = serde_json::from_str(&err).unwrap();
assert_eq!(err_json["code"], "receipt_required");
assert_eq!(err_json["remedy_tool"], "books.file_document");
// File the bill of sale, then confirm with its attachment id.
let bill = mcp.call(
"books.file_document",
json!({
"category": "legal",
"file_name": "bill-of-sale.pdf",
"mime_type": "application/pdf",
"content_base64": base64_like(b"fake bill of sale"),
"sha256": sha(b"fake bill of sale"),
}),
);
let aid = bill["attachment_id"].as_u64().unwrap();
let posted = mcp.call(
"books.dispose_asset",
json!({
"asset_id": asset_id,
"date": "2026-02-28",
"proceeds": "60000",
"proceeds_account": "1000",
"gain_loss_account": "4900",
"confirmed": true,
"receipt_attachment_ids": [aid],
}),
);
assert_eq!(posted["status"], "posted");
assert_eq!(posted["gain_loss"], "1548.39");
// The bill of sale is linked to the disposal entry.
let receipts = mcp.call(
"books.list_receipts",
json!({"entry_id": posted["entry_id"]}),
);
assert_eq!(receipts["receipts"].as_array().unwrap().len(), 1);
// Asset is disposed; further depreciation is refused.
let list2 = mcp.call("books.list_assets", json!({}));
assert_eq!(list2["assets"][0]["status"], "disposed");
let err2 = mcp.call_err(
"books.run_depreciation",
json!({"period": "2026-03", "asset_id": asset_id}),
);
assert!(err2.contains("disposed"), "got: {err2}");
// The books still balance.
let tb = mcp.call("books.trial_balance", json!({}));
assert_eq!(tb["balanced"], true);
}
#[test]
fn mcp_receipt_policy_rejects_split_entries() {
let books = Books::new("split");
let mut mcp = Mcp::spawn(&books);
let init = mcp.request(
"initialize",
json!({
"protocolVersion": "2025-06-18",
"capabilities": {},
"clientInfo": {"name": "smoke-test", "version": "0.1.0"},
}),
);
assert_eq!(init["result"]["serverInfo"]["name"], "wanderbooks");
mcp.notify("notifications/initialized");
for (code, name, typ) in [("1000", "Cash", "asset"), ("6000", "Meals", "expense")] {
mcp.call(
"books.add_account",
json!({"code": code, "name": name, "account_type": typ}),
);
}
// Post a $25 entry (at the threshold, no receipt needed) and attach a
// receipt to it so the setup is clean.
let e1 = mcp.call(
"books.draft_entry",
json!({"date": "2026-03-01", "description": "Client dinner"}),
);
let id1 = e1["entry_id"].as_u64().unwrap();
mcp.call(
"books.add_line",
json!({"entry_id": id1, "account": "6000", "side": "debit", "amount": "25.00"}),
);
mcp.call(
"books.add_line",
json!({"entry_id": id1, "account": "1000", "side": "credit", "amount": "25.00"}),
);
let p1 = mcp.call(
"books.post_entry",
json!({"entry_id": id1, "confirmed": true}),
);
assert_eq!(p1["status"], "posted");
// A second $25 entry, same day, same description, same accounts: the
// group totals $50, so the policy bites.
let e2 = mcp.call(
"books.draft_entry",
json!({"date": "2026-03-01", "description": "Client dinner"}),
);
let id2 = e2["entry_id"].as_u64().unwrap();
mcp.call(
"books.add_line",
json!({"entry_id": id2, "account": "6000", "side": "debit", "amount": "25.00"}),
);
mcp.call(
"books.add_line",
json!({"entry_id": id2, "account": "1000", "side": "credit", "amount": "25.00"}),
);
let preview = mcp.call("books.post_entry", json!({"entry_id": id2}));
assert_eq!(preview["receipt_required"], true);
assert_eq!(preview["split_detected"].as_array().unwrap().len(), 1);
assert_eq!(preview["split_detected"][0], id1);
// Confirming without a receipt fails with the structured error.
let err = mcp.call_err(
"books.post_entry",
json!({"entry_id": id2, "confirmed": true}),
);
let err_json: serde_json::Value = serde_json::from_str(&err).unwrap();
assert_eq!(err_json["code"], "receipt_required");
assert_eq!(err_json["split_group"].as_array().unwrap().len(), 1);
// A different description is not a split: posts fine.
let e3 = mcp.call(
"books.draft_entry",
json!({"date": "2026-03-01", "description": "Taxi to airport"}),
);
let id3 = e3["entry_id"].as_u64().unwrap();
mcp.call(
"books.add_line",
json!({"entry_id": id3, "account": "6000", "side": "debit", "amount": "25.00"}),
);
mcp.call(
"books.add_line",
json!({"entry_id": id3, "account": "1000", "side": "credit", "amount": "25.00"}),
);
let p3 = mcp.call(
"books.post_entry",
json!({"entry_id": id3, "confirmed": true}),
);
assert_eq!(p3["status"], "posted");
}
#[test]
fn mcp_receipt_corrections() {
let books = Books::new("receipt-corrections");
let mut mcp = Mcp::spawn(&books);
mcp.request(
"initialize",
json!({
"protocolVersion": "2025-06-18",
"capabilities": {},
"clientInfo": {"name": "smoke-test", "version": "0.1.0"},
}),
);
mcp.notify("notifications/initialized");
for (code, name, typ) in [("1000", "Cash", "asset"), ("6000", "Software", "expense")] {
mcp.call(
"books.add_account",
json!({"code": code, "name": name, "account_type": typ}),
);
}
let post = |mcp: &mut Mcp, desc: &str, amount: &str, receipt: &[u8]| -> (u64, u64) {
let e = mcp.call(
"books.draft_entry",
json!({"date": "2026-04-01", "description": desc}),
);
let id = e["entry_id"].as_u64().unwrap();
mcp.call(
"books.add_line",
json!({"entry_id": id, "account": "6000", "side": "debit", "amount": amount}),
);
mcp.call(
"books.add_line",
json!({"entry_id": id, "account": "1000", "side": "credit", "amount": amount}),
);
let att = mcp.call(
"books.attach_receipt",
json!({"entry_id": id, "file_name": "receipt.eml", "mime_type": "message/rfc822",
"content_base64": base64_like(receipt),
"sha256": sha(receipt)}),
);
let posted = mcp.call(
"books.post_entry",
json!({"entry_id": id, "confirmed": true}),
);
assert_eq!(posted["status"], "posted");
(id, att["attachment_id"].as_u64().unwrap())
};
// A $100 entry whose only receipt is an .eml.
let (big, eml) = post(&mut mcp, "Annual license", "100.00", b"eml-bytes");
// Unlinking its only receipt would break the policy: refused, with the remedy.
let err = mcp.call_err(
"books.unlink_receipt",
json!({"attachment_id": eml, "reason": "replacing with PDF"}),
);
let err_json: serde_json::Value = serde_json::from_str(&err).unwrap();
assert_eq!(err_json["code"], "receipt_required");
assert_eq!(err_json["entry_id"], big);
assert_eq!(err_json["remedy_tool"], "books.attach_receipt");
// Replace: attach the PDF first, then unlink the .eml.
let pdf = mcp.call(
"books.attach_receipt",
json!({"entry_id": big, "file_name": "receipt.pdf", "mime_type": "application/pdf",
"content_base64": base64_like(b"pdf-bytes"),
"sha256": sha(b"pdf-bytes")}),
)["attachment_id"]
.as_u64()
.unwrap();
let un = mcp.call(
"books.unlink_receipt",
json!({"attachment_id": eml, "reason": "replaced by the PDF render"}),
);
assert_eq!(un["entry_id"], big);
assert_eq!(un["remaining_receipts"], 1);
// A linked receipt can't be withdrawn; an unlinked one can, with a reason.
let err = mcp.call_err(
"books.withdraw_attachment",
json!({"attachment_id": pdf, "reason": "x"}),
);
assert!(err.contains("unlink it first"), "{err}");
let err = mcp.call_err(
"books.withdraw_attachment",
json!({"attachment_id": eml, "reason": " "}),
);
assert!(err.contains("reason"), "{err}");
mcp.call(
"books.withdraw_attachment",
json!({"attachment_id": eml, "reason": "file PDFs, not .eml"}),
);
// Listings hide the withdrawn .eml unless asked; the entry keeps the PDF.
let listed = mcp.call("books.list_receipts", json!({}));
let ids: Vec<u64> = listed["receipts"]
.as_array()
.unwrap()
.iter()
.map(|r| r["attachment_id"].as_u64().unwrap())
.collect();
assert_eq!(ids, vec![pdf]);
let all = mcp.call("books.list_receipts", json!({"include_withdrawn": true}));
let gone = all["receipts"]
.as_array()
.unwrap()
.iter()
.find(|r| r["attachment_id"] == eml)
.unwrap()
.clone();
assert_eq!(gone["withdrawn"], true);
assert_eq!(gone["withdrawn_reason"], "file PDFs, not .eml");
assert_eq!(gone["entry_id"], serde_json::Value::Null);
let entry = mcp.call("books.get_entry", json!({"entry_id": big}));
assert_eq!(entry["receipts"].as_array().unwrap().len(), 1);
assert_eq!(entry["receipts"][0]["attachment_id"], pdf);
// A withdrawn attachment can never be linked again.
let err = mcp.call_err(
"books.link_receipt",
json!({"attachment_id": eml, "entry_id": big}),
);
assert!(err.contains("withdrawn"), "{err}");
// Under the threshold, the last receipt may go.
let (small, small_eml) = post(&mut mcp, "Domain renewal", "10.00", b"small-eml");
let un = mcp.call(
"books.unlink_receipt",
json!({"attachment_id": small_eml, "reason": "wrong file"}),
);
assert_eq!(un["entry_id"], small);
assert_eq!(un["remaining_receipts"], 0);
// The books never moved.
let tb = mcp.call("books.trial_balance", json!({}));
assert_eq!(tb["balanced"], true);
assert_eq!(tb["total_debits"], "110.00");
}
#[test]
fn mcp_resources() {
let books = Books::new("resources");
let mut mcp = Mcp::spawn(&books);
let init = mcp.request(
"initialize",
json!({
"protocolVersion": "2025-06-18",
"capabilities": {},
"clientInfo": {"name": "smoke-test", "version": "0.1.0"},
}),
);
assert_eq!(init["result"]["serverInfo"]["name"], "wanderbooks");
// Resources are advertised.
assert!(!init["result"]["capabilities"]["resources"].is_null());
mcp.notify("notifications/initialized");
mcp.call(
"books.add_account",
json!({"code": "1000", "name": "Cash", "account_type": "asset"}),
);
// List resources.
let list = mcp.request("resources/list", json!({}));
let uris: Vec<&str> = list["result"]["resources"]
.as_array()
.unwrap()
.iter()
.map(|r| r["uri"].as_str().unwrap())
.collect();
assert!(uris.contains(&"books://chart"));
assert!(uris.contains(&"books://reports/trial-balance"));
assert!(uris.contains(&"books://reports/balance-sheet"));
assert!(uris.contains(&"books://reports/profit-and-loss"));
assert!(uris.contains(&"books://digest"));
// Read the chart and trial balance as resources.
let chart = mcp.request("resources/read", json!({"uri": "books://chart"}));
let text = chart["result"]["contents"][0]["text"].as_str().unwrap();
let chart_json: serde_json::Value = serde_json::from_str(text).unwrap();
assert_eq!(chart_json["accounts"].as_array().unwrap().len(), 1);
let tb = mcp.request(
"resources/read",
json!({"uri": "books://reports/trial-balance"}),
);
let tb_text = tb["result"]["contents"][0]["text"].as_str().unwrap();
let tb_json: serde_json::Value = serde_json::from_str(tb_text).unwrap();
assert_eq!(tb_json["balanced"], true);
// Unknown resource → JSON-RPC error.
let resp = mcp.request_raw("resources/read", json!({"uri": "books://nope"}));
assert!(resp.get("error").is_some());
}
/// Start a session: initialize, then initialized.
fn init(mcp: &mut Mcp) -> Value {
let init = mcp.request(
"initialize",
json!({
"protocolVersion": "2025-06-18",
"capabilities": {},
"clientInfo": {"name": "smoke-test", "version": "0.1.0"},
}),
);
mcp.notify("notifications/initialized");
init
}
/// Outputs that carry full TINs (books.get_w9, books.vendor_1099 with
/// include_tins, books.export_cpa_package with TINs) come back as files
/// handed over (D-29: no tool takes a path), and each is refused by its
/// bytes when given back to be filed: a TIN-bearing file can't be filed
/// back in as a receipt or document and read out through
/// books.get_receipt (S7 of the P0 review, 2026-10-16).
#[test]
fn tin_bearing_files_are_refused_when_given_back_to_be_filed() {
let books = Books::new("tin-files");
let w9 = b"%PDF-1.4 W-9 TIN 291-12-2846";
let mut mcp = Mcp::spawn(&books);
init(&mut mcp);
mcp.call(
"books.add_account",
json!({"code": "1000", "name": "Cash", "account_type": "asset"}),
);
// The 1099 worksheet counts payments out of the books' cash accounts.
mcp.call(
"books.set_setting",
json!({"changes": [{"key": "books.cash_accounts", "value": ["1000"]}], "decided_by": "Test Owner"}),
);
mcp.call(
"books.add_party",
json!({"name": "Acme", "roles": ["contractor"]}),
);
mcp.call(
"books.set_w9",
json!({"party": "Acme", "received_on": "2026-01-03", "entity_type": "Individual",
"tin": "291-12-2846", "tin_type": "ssn",
"w9_content_base64": base64_like(w9), "w9_sha256": sha(w9)}),
);
// Each comes back by its download.
let got = mcp.call("books.get_w9", json!({"party": "Acme"}));
let w9_back = mcp.handed(&got["file"]);
assert_eq!(w9_back, w9, "the W-9 file, as stored");
let ws = mcp.call(
"books.vendor_1099",
json!({"year": 2026, "include_tins": true, "csv": true}),
);
assert_eq!(ws["csv"]["file_name"], "1099-worksheet-2026-with-tins.csv");
let pkg = mcp.call(
"books.export_cpa_package",
json!({"cash_accounts": ["1000"]}),
);
assert_eq!(pkg["tins_included"], true);
let handed_over = [w9_back, mcp.handed(&ws["csv"]), mcp.handed(&pkg["file"])];
// Given back, each is refused by its bytes, by every way a file is
// filed.
let journal = books.journal();
for (n, bytes) in handed_over.iter().enumerate() {
for (tool, args) in [
(
"books.attach_receipt",
json!({"file_name": "receipt.pdf", "mime_type": "application/pdf",
"content_base64": base64_like(bytes),
"sha256": sha(bytes)}),
),
(
"books.file_document",
json!({"category": "statement", "file_name": "statement.csv",
"mime_type": "text/csv",
"content_base64": base64_like(bytes),
"sha256": sha(bytes)}),
),
] {
let why = mcp.call_err(tool, args);
assert!(
why.contains("wrote with full TINs"),
"{tool} file {n}: {why}"
);
}
}
assert_eq!(books.journal(), journal, "a refusal stored something");
// Without TINs, the 1099 worksheet is an ordinary file: it may be
// filed like any other.
let plain = mcp.call("books.vendor_1099", json!({"year": 2026, "csv": true}));
assert!(plain["warning"].is_null(), "{plain}");
assert_eq!(plain["csv"]["file_name"], "1099-worksheet-2026.csv");
let bytes = mcp.handed(&plain["csv"]);
mcp.call(
"books.file_document",
json!({"category": "statement", "file_name": "plain.csv", "mime_type": "text/csv",
"content_base64": base64_like(&bytes), "sha256": sha(&bytes)}),
);
}
/// The connect text and the receipt rule show a few account codes of each
/// kind, shortened, and say books.settings lists them: it does, every one,
/// uncut, so no account the rule names is out of the agent's reach.
#[test]
fn every_receipt_account_left_out_of_the_connect_text_is_in_books_settings() {
let long = "6000-A-CODE-LONGER-THAN-ANY-RULE-SHOWS-IT";
let required: Vec<String> = std::iter::once(long.to_string())
.chain((1..20).map(|i| format!("{}", 6000 + i)))
.collect();
let exempt: Vec<String> = (0..12).map(|i| format!("{}", 1100 + i)).collect();
let books = Books::new("receipt-accounts");
books.settle_with(
"",
Some(
&json!({"receipt_policy": {"require_receipts_above": "0.00",
"receipt_required_accounts": required, "receipt_exempt_accounts": exempt}})
.to_string(),
),
);
let mut mcp = Mcp::spawn(&books);
let init = mcp.request(
"initialize",
json!({
"protocolVersion": "2025-06-18",
"capabilities": {},
"clientInfo": {"name": "smoke-test", "version": "0.1.0"},
}),
);
mcp.notify("notifications/initialized");
let connect = init["result"]["instructions"].as_str().unwrap().to_string();
assert!(connect.contains("books.settings lists"), "{connect}");
let settings = mcp.call("books.settings", json!({}));
let rule = settings["receipt_rule"].as_str().unwrap();
assert!(rule.contains("books.settings lists them"), "{rule}");
let listed = |key: &str| -> Vec<String> {
settings[key]
.as_array()
.unwrap_or_else(|| panic!("books.settings has no {key}: {settings}"))
.iter()
.map(|c| c.as_str().unwrap().to_string())
.collect()
};
assert_eq!(listed("receipt_required_accounts"), required);
assert_eq!(listed("receipt_exempt_accounts"), exempt);
// Some codes really are left out of the connect text and the rule,
// and the long one is shown cut there, whole here.
let left_out: Vec<&String> = required
.iter()
.chain(&exempt)
.filter(|c| !connect.contains(c.as_str()))
.collect();
assert!(left_out.len() > 20, "{connect}");
assert!(!rule.contains(long), "{rule}");
for code in left_out {
assert!(
listed("receipt_required_accounts").contains(code)
|| listed("receipt_exempt_accounts").contains(code),
"{code} is in neither the connect text nor books.settings"
);
}
}
/// A bare `books://digest` (no `since`) reads this month so far: an entry
/// dated this month is in it, one dated last month is not (plan P0).
/// "This month" is the server's: read from its own bare digest, never this
/// process's clock, and the run starts over on fresh books if the month
/// turns while it runs (midnight on the 1st).
#[test]
fn digest_resource_bare_reads() {
for attempt in 0..3 {
if digest_bare_reads_once(attempt) {
return;
}
}
panic!("the month kept turning while the test ran");
}
/// One run of `digest_resource_bare_reads`: false if the server's month
/// turned during it (nothing is checked then).
fn digest_bare_reads_once(attempt: u32) -> bool {
let books = Books::new(&format!("digest-bare-{attempt}"));
let mut mcp = Mcp::spawn(&books);
mcp.request(
"initialize",
json!({
"protocolVersion": "2025-06-18",
"capabilities": {},
"clientInfo": {"name": "smoke-test", "version": "0.1.0"},
}),
);
mcp.notify("notifications/initialized");
let since = |mcp: &mut Mcp, uri: &str| -> Value {
let read = mcp.request("resources/read", json!({"uri": uri}));
let text = read["result"]["contents"][0]["text"].as_str().unwrap();
serde_json::from_str(text).unwrap()
};
// The server's first day of this month.
let first: chrono::NaiveDate = since(&mut mcp, "books://digest")["since"]
.as_str()
.unwrap()
.parse()
.unwrap();
assert_eq!(chrono::Datelike::day(&first), 1, "{first}");
let before = first.pred_opt().unwrap();
for (code, name, typ) in [("1000", "Cash", "asset"), ("6000", "Fees", "expense")] {
mcp.call(
"books.add_account",
json!({"code": code, "name": name, "account_type": typ}),
);
}
for (date, what) in [(first, "this month"), (before, "last month")] {
let id = mcp.call(
"books.draft_entry",
json!({"date": date.to_string(), "description": what}),
)["entry_id"]
.as_u64()
.unwrap();
for (side, code) in [("debit", "6000"), ("credit", "1000")] {
mcp.call(
"books.add_line",
json!({"entry_id": id, "account": code, "side": side, "amount": "5.00"}),
);
}
mcp.call(
"books.post_entry",
json!({"entry_id": id, "confirmed": true}),
);
}
// Bare, and with an empty since=, alike (the default date itself is
// pinned on every day of a month in server.rs's unit test).
for uri in ["books://digest", "books://digest?since="] {
let digest = since(&mut mcp, uri);
if digest["since"] != first.to_string() {
return false;
}
let seen: Vec<&str> = digest["entries"]
.as_array()
.unwrap()
.iter()
.map(|e| e["description"].as_str().unwrap())
.collect();
assert_eq!(seen, ["this month"], "{uri}");
}
true
}
/// The handbook is one tool and one resource per section, with the
/// receipt rule in force rendered in; the connect text points to it.
#[test]
fn handbook_tool_and_resources() {
let books = Books::new("handbook");
let mut mcp = Mcp::spawn(&books);
let init = mcp.request(
"initialize",
json!({
"protocolVersion": "2025-06-18",
"capabilities": {},
"clientInfo": {"name": "smoke-test", "version": "0.1.0"},
}),
);
let instructions = init["result"]["instructions"].as_str().unwrap();
assert!(instructions.chars().count() <= 1400);
// The first 512 characters (not bytes: a name may be multibyte).
let lead: String = instructions.chars().take(512).collect();
assert!(lead.contains("books.handbook"));
mcp.notify("notifications/initialized");
let index = mcp.call("books.handbook", json!({}));
let sections = index["sections"].as_array().unwrap();
assert_eq!(sections[0]["id"], "contract");
assert!(index["read_first"].as_str().unwrap().contains("contract"));
assert_eq!(index["section"], Value::Null);
// The schema names every section, and "all".
let list = mcp.request("tools/list", json!({}));
let tool = list["result"]["tools"]
.as_array()
.unwrap()
.iter()
.find(|t| t["name"] == "books.handbook")
.unwrap()
.clone();
let said = tool["inputSchema"]["properties"]["section"]["description"]
.as_str()
.unwrap()
.to_string();
for s in sections {
let id = s["id"].as_str().unwrap();
assert!(said.contains(id), "the schema doesn't name {id}: {said}");
}
assert!(said.contains("\"all\""), "{said}");
// Section "all": every section's text, in order, under its own title.
let all = mcp.call("books.handbook", json!({"section": "all"}));
assert_eq!(all["section"]["title"], "The whole handbook");
let text = all["section"]["text"].as_str().unwrap();
let mut at = 0;
for s in sections {
let one = mcp.call("books.handbook", json!({"section": s["id"]}));
// A section is titled by its heading, as the index lists it.
assert_eq!(one["section"]["title"], s["title"]);
let part = one["section"]["text"].as_str().unwrap();
assert_eq!(
part.lines().next().unwrap(),
format!("# {}", s["title"].as_str().unwrap())
);
at += text[at..].find(part).expect("a section missing from all") + part.len();
}
let one = mcp.call(
"books.handbook",
json!({"section": "evidence-and-receipts"}),
);
let text = one["section"]["text"].as_str().unwrap();
assert!(text.contains("needs at least one receipt"), "{text}");
assert!(!text.contains("{evidence_rule}"));
let why = mcp.call_err("books.handbook", json!({"section": "nope"}));
assert!(why.contains("contract"), "{why}");
let list = mcp.request("resources/list", json!({}));
let uris: Vec<&str> = list["result"]["resources"]
.as_array()
.unwrap()
.iter()
.map(|r| r["uri"].as_str().unwrap())
.collect();
assert!(uris.contains(&"books://handbook"));
for s in sections {
let uri = s["uri"].as_str().unwrap();
assert!(uris.contains(&uri), "{uri} not listed");
let read = mcp.request("resources/read", json!({"uri": uri}));
let c = &read["result"]["contents"][0];
assert_eq!(c["mimeType"], "text/markdown");
assert_eq!(
c["text"].as_str().unwrap().chars().count() as u64,
s["chars"].as_u64().unwrap()
);
}
let read = mcp.request("resources/read", json!({"uri": "books://handbook"}));
let idx: Value =
serde_json::from_str(read["result"]["contents"][0]["text"].as_str().unwrap()).unwrap();
assert_eq!(idx["sections"].as_array().unwrap().len(), sections.len());
let resp = mcp.request_raw("resources/read", json!({"uri": "books://handbook/nope"}));
assert!(resp.get("error").is_some());
}
/// The SHA-256 (hex) a file given inline comes with.
fn sha(bytes: &[u8]) -> String {
ledger_store::journal::sha256_hex(bytes)
}
fn base64_like(bytes: &[u8]) -> String {
const ALPHABET: &[u8; 64] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";
let mut out = String::new();
for chunk in bytes.chunks(3) {
let mut n: u32 = 0;
for (i, &b) in chunk.iter().enumerate() {
n |= (b as u32) << (16 - 8 * i);
}
let pad = 3 - chunk.len();
for i in 0..4 - pad {
out.push(ALPHABET[((n >> (18 - 6 * i)) & 63) as usize] as char);
}
for _ in 0..pad {
out.push('=');
}
}
out
}
#[test]
fn mcp_import_export() {
let books = Books::new("import_export");
let mut mcp = Mcp::spawn(&books);
mcp.request(
"initialize",
json!({
"protocolVersion": "2025-06-18",
"capabilities": {},
"clientInfo": {"name": "smoke-test", "version": "0.1.0"},
}),
);
mcp.notify("notifications/initialized");
let chart = mcp.call(
"books.import_chart_csv",
json!({"csv_text": "code,name,type,parent_code\n1000,Cash,asset,\n4000,Revenue,revenue,\n6010,Café 北京,expense,\n"}),
);
assert_eq!(chart["count"], 3);
// The IIF is plain ASCII text with CR LF line endings, inline; what it
// writes differently from the books is in warnings (rules.md B-34).
let iif = mcp.call(
"books.export",
json!({"format": "iif", "report": "journal"}),
);
let text = iif["content"].as_str().unwrap();
assert!(text.contains("!TRNS") && text.is_ascii() && text.ends_with("\r\n"));
assert!(
text.contains("\r\nACCNT\tCafe Bei Jing\tEXP\t6010\r\n"),
"{text}"
);
assert!(iif.get("content_encoding").is_none(), "{iif}");
assert_eq!(
iif["warnings"],
json!(["account 6010's name \"Café 北京\" is written \"Cafe Bei Jing\""])
);
let xlsx = mcp.call(
"books.export",
json!({"format": "xlsx", "report": "trial_balance"}),
);
assert_eq!(xlsx["content_encoding"], "base64");
assert!(xlsx["content"].as_str().unwrap().starts_with("UEs"));
}
#[test]
fn mcp_statement_pdfs_and_cpa_package() {
use base64::Engine;
let b64 = base64::engine::general_purpose::STANDARD;
let books = Books::new("statement-pdfs");
let mut mcp = Mcp::spawn(&books);
mcp.request(
"initialize",
json!({
"protocolVersion": "2025-06-18",
"capabilities": {},
"clientInfo": {"name": "smoke-test", "version": "0.1.0"},
}),
);
mcp.notify("notifications/initialized");
mcp.call(
"books.import_chart_csv",
json!({"csv_text": "code,name,type,parent_code\n1000,Cash,asset,\n3000,Owner Capital,equity,\n6000,Software,expense,\n"}),
);
for (desc, debit, credit, amount) in [
("Owner contribution", "1000", "3000", "1000.00"),
("Domain", "6000", "1000", "12.00"),
] {
let e = mcp.call(
"books.draft_entry",
json!({"date": "2026-10-05", "description": desc}),
);
let id = e["entry_id"].as_u64().unwrap();
mcp.call(
"books.add_line",
json!({"entry_id": id, "account": debit, "side": "debit", "amount": amount}),
);
mcp.call(
"books.add_line",
json!({"entry_id": id, "account": credit, "side": "credit", "amount": amount}),
);
mcp.call(
"books.attach_receipt",
json!({"entry_id": id, "file_name": "r.pdf", "mime_type": "application/pdf",
"content_base64": base64_like(desc.as_bytes()),
"sha256": sha(desc.as_bytes())}),
);
mcp.call(
"books.post_entry",
json!({"entry_id": id, "confirmed": true}),
);
}
// All three statements export as PDF.
for (report, extra) in [
("profit_and_loss", json!({})),
("balance_sheet", json!({})),
("cash_flow", json!({"cash_accounts": ["1000"]})),
("trial_balance", json!({"as_of": "2026-10-31"})),
] {
let mut args = json!({"format": "pdf", "report": report});
args.as_object_mut()
.unwrap()
.extend(extra.as_object().unwrap().clone());
let out = mcp.call("books.export", args);
assert_eq!(out["content_encoding"], "base64");
let bytes = b64.decode(out["content"].as_str().unwrap()).unwrap();
assert!(bytes.starts_with(b"%PDF"), "{report} is not a PDF");
}
// The cash flow statement reconciles to named cash accounts: required.
let err = mcp.call_err(
"books.export",
json!({"format": "pdf", "report": "cash_flow"}),
);
assert!(err.contains("cash_accounts"), "{err}");
// The ledger and journal are workbooks, not printable statements.
let err = mcp.call_err(
"books.export",
json!({"format": "pdf", "report": "general_ledger"}),
);
assert!(err.contains("not pdf"), "{err}");
// The CPA package carries all three statements.
let err = mcp.call_err(
"books.export_cpa_package",
json!({"as_of": "2026-10-31", "include_tins": false}),
);
assert!(err.contains("cash_accounts"), "{err}");
// The package's 1099 worksheet is books.vendor_1099's for the
// package's accounts: the request's, else the settings' (here both
// name checking).
mcp.call(
"books.set_setting",
json!({"changes": [{"key": "books.cash_accounts", "value": ["1000"]}], "decided_by": "Test Owner"}),
);
let pkg = mcp.call(
"books.export_cpa_package",
json!({"as_of": "2026-10-31", "cash_accounts": ["1000"], "include_tins": false}),
);
assert_eq!(pkg["file"]["file_name"], "cpa-package-2026-10-31.zip");
assert_eq!(pkg["file"]["mime_type"], "application/zip");
assert_eq!(pkg["tins_included"], false);
let zip_bytes = mcp.handed(&pkg["file"]);
let mut zip = zip::ZipArchive::new(std::io::Cursor::new(zip_bytes)).unwrap();
let mut names: Vec<String> = (0..zip.len())
.map(|i| zip.by_index(i).unwrap().name().unwrap().to_string())
.collect();
names.sort();
let (receipts, rest): (Vec<&String>, Vec<&String>) =
names.iter().partition(|n| n.starts_with("receipts/"));
assert_eq!(
rest,
[
"1099-worksheet-2026.csv",
"README.txt",
"balance-sheet.pdf",
"balance-sheet.xlsx",
"cash-flow.pdf",
"cash-flow.xlsx",
"general-journal.iif",
"general-journal.xlsx",
"general-ledger.xlsx",
"profit-and-loss.pdf",
"profit-and-loss.xlsx",
"tax-summary.pdf",
"tax-summary.xlsx",
"trial-balance.pdf",
"trial-balance.xlsx"
]
);
// Every receipt behind the period's entries, plus an index tying them
// to their entries.
assert!(receipts.iter().any(|n| *n == "receipts/index.csv"));
let files = receipts.len() - 1;
assert!(files > 0, "receipts included");
let mut index = String::new();
std::io::Read::read_to_string(&mut zip.by_name("receipts/index.csv").unwrap(), &mut index)
.unwrap();
assert_eq!(
index.lines().count(),
files + 1,
"one index row per receipt"
);
let mut readme = String::new();
std::io::Read::read_to_string(&mut zip.by_name("README.txt").unwrap(), &mut readme).unwrap();
assert!(readme.contains("hash-chained journal"), "{readme}");
let mut cf = Vec::new();
std::io::Read::read_to_end(&mut zip.by_name("cash-flow.pdf").unwrap(), &mut cf).unwrap();
assert!(cf.starts_with(b"%PDF"));
// Without receipts, a lighter package.
let light = mcp.call(
"books.export_cpa_package",
json!({"as_of": "2026-10-31", "cash_accounts": ["1000"], "include_receipts": false, "include_tins": false}),
);
let light = zip::ZipArchive::new(std::io::Cursor::new(mcp.handed(&light["file"]))).unwrap();
assert_eq!(light.len(), rest.len());
}
#[test]
fn mcp_period_statements() {
// A rejected receipt policy would show up as a failed post below: the
// settings lower the threshold from 25.00 to 5.00.
let books = Books::with(
"period-statements",
"USD",
Some(
"[company]\nname = \"Test Co\"\nfiscal_year_start = \"02-01\"\ntax_classification = \"partnership\"\n[[company.members]]\nname = \"Owner\"\nshare = \"100%\"\ncapital_account = \"3000\"\n[books]\nreceipt_threshold = \"5.00\"\n",
),
);
let mut mcp = Mcp::spawn(&books);
mcp.request(
"initialize",
json!({
"protocolVersion": "2025-06-18",
"capabilities": {},
"clientInfo": {"name": "smoke-test", "version": "0.1.0"},
}),
);
mcp.notify("notifications/initialized");
mcp.call(
"books.import_chart_csv",
json!({"csv_text": "code,name,type,parent_code\n1000,Cash,asset,\n3000,Capital,equity,\n4000,Sales,revenue,\n6000,Software,expense,\n"}),
);
let post = |mcp: &mut Mcp, date: &str, dr: &str, cr: &str, amount: &str| {
let e = mcp.call(
"books.draft_entry",
json!({"date": date, "description": "x"}),
);
let id = e["entry_id"].as_u64().unwrap();
mcp.call(
"books.add_line",
json!({"entry_id": id, "account": dr, "side": "debit", "amount": amount}),
);
mcp.call(
"books.add_line",
json!({"entry_id": id, "account": cr, "side": "credit", "amount": amount}),
);
id
};
let id = post(&mut mcp, "2026-01-02", "1000", "3000", "1000.00");
// 1000.00 > 5.00 (the toml threshold): a receipt is required.
let err = mcp.call_err(
"books.post_entry",
json!({"entry_id": id, "confirmed": true}),
);
assert!(err.contains("5.00"), "{err}");
mcp.call(
"books.attach_receipt",
json!({"entry_id": id, "file_name": "r.pdf", "mime_type": "application/pdf",
"content_base64": base64_like(b"r"),
"sha256": sha(b"r")}),
);
mcp.call(
"books.post_entry",
json!({"entry_id": id, "confirmed": true}),
);
for (date, dr, cr, amt) in [
("2026-01-20", "1000", "4000", "4.00"),
("2026-02-05", "6000", "1000", "3.00"),
("2026-02-09", "1000", "4000", "2.50"),
] {
let id = post(&mut mcp, date, dr, cr, amt);
mcp.call(
"books.post_entry",
json!({"entry_id": id, "confirmed": true}),
);
}
// February only: 2.50 sales, 3.00 software.
let feb = mcp.call(
"books.profit_and_loss",
json!({"start": "2026-02-01", "end": "2026-02-28"}),
);
assert_eq!(feb["revenue"], "2.50");
assert_eq!(feb["expenses"], "3.00");
assert_eq!(feb["net_income"], "-0.50");
assert_eq!(feb["expense_accounts"][0]["code"], "6000");
assert_eq!(feb["expense_accounts"][0]["amount"], "3.00");
// No start (rules.md B-30): the fiscal year that holds the end, to
// it. The fiscal year starts 02-01, so January's 4.00 of sales is
// last year's, and February's 2.50 is this one's.
let ytd = mcp.call("books.profit_and_loss", json!({"end": "2026-02-28"}));
assert_eq!(ytd["start"], "2026-02-01");
assert_eq!(ytd["revenue"], "2.50");
let last = mcp.call("books.profit_and_loss", json!({"end": "2026-01-31"}));
assert_eq!(last["start"], "2025-02-01");
assert_eq!(last["revenue"], "4.00");
// As of Jan 31: cash 1,004.00 = capital 1,000.00 + earnings 4.00.
let jan = mcp.call("books.balance_sheet", json!({"as_of": "2026-01-31"}));
assert_eq!(jan["assets"], "1004.00");
assert_eq!(jan["current_earnings"], "4.00");
assert_eq!(jan["equity_accounts"][0]["amount"], "1000.00");
assert_eq!(jan["balanced"], true);
// Trial balance as of Jan 31: Dr cash 1,004.00 = Cr capital + sales.
let tb = mcp.call("books.trial_balance", json!({"as_of": "2026-01-31"}));
assert_eq!(tb["as_of"], "2026-01-31");
assert_eq!(tb["total_debits"], "1004.00");
// General ledger for February: cash starts at 1,004.00, pays 3.00 for
// software, takes in 2.50 of sales, ends at 1,003.50.
let gl = mcp.call(
"books.export",
json!({"format": "json", "report": "general_ledger", "start": "2026-02-01", "end": "2026-02-28"}),
);
let gl: serde_json::Value = serde_json::from_str(gl["content"].as_str().unwrap()).unwrap();
let cash = &gl["accounts"][0];
assert_eq!(cash["beginning_balance"], "1004.00");
assert_eq!(cash["postings"][0]["split"], "6000 Software");
assert_eq!(cash["postings"][0]["balance"], "1001.00");
assert_eq!(cash["postings"][1]["split"], "4000 Sales");
assert_eq!(cash["ending_balance"], "1003.50");
// Members' equity, Jan-Feb: the one member contributed 1,000.00 and
// earned 3.50 (6.50 sales - 3.00 software), ending at balance-sheet
// equity, 1,003.50.
let me = mcp.call(
"books.export",
json!({"format": "json", "report": "members_equity", "start": "2026-01-01", "end": "2026-02-28"}),
);
let me: serde_json::Value = serde_json::from_str(me["content"].as_str().unwrap()).unwrap();
assert_eq!(me["members"][0]["name"], "Owner");
assert_eq!(me["total"]["contributions"], "1000.00");
assert_eq!(me["total"]["net_income"], "3.50");
assert_eq!(me["total"]["ending"], "1003.50");
let pdf = mcp.call(
"books.export",
json!({"format": "pdf", "report": "members_equity", "end": "2026-02-28"}),
);
assert_eq!(pdf["content_encoding"], "base64");
// Closing January: a one-member LLC closes into the member's capital
// account, not Retained Earnings. The preview is exactly what posts.
let jan = mcp.call(
"books.create_period",
json!({"name": "2026-01", "start": "2026-01-01", "end": "2026-01-31"}),
);
let jan_id = jan["period_id"].as_u64().unwrap();
let preview = mcp.call(
"books.close_period",
json!({"period_id": jan_id, "confirmed": false}),
);
assert_eq!(
preview["lines"],
json!(["Debit 4000 4.00", "Credit 3000 4.00"])
);
let closed = mcp.call(
"books.close_period",
json!({"period_id": jan_id, "confirmed": true}),
);
assert_eq!(closed["lines"], preview["lines"]);
let jan_bs = mcp.call("books.balance_sheet", json!({"as_of": "2026-01-31"}));
assert_eq!(jan_bs["equity_accounts"][0]["amount"], "1004.00");
assert_eq!(jan_bs["current_earnings"], "0.00");
// The close is income, not a contribution.
let me = mcp.call(
"books.export",
json!({"format": "json", "report": "members_equity", "start": "2026-01-01", "end": "2026-01-31"}),
);
let me: serde_json::Value = serde_json::from_str(me["content"].as_str().unwrap()).unwrap();
assert_eq!(me["total"]["contributions"], "1000.00");
assert_eq!(me["total"]["net_income"], "4.00");
assert_eq!(me["total"]["ending"], "1004.00");
// Tax categories: filtered to the partnership return.
let cats = mcp.call("books.tax_categories", json!({}));
assert_eq!(cats["form"], "Form 1065");
let keys: Vec<&str> = cats["categories"]
.as_array()
.unwrap()
.iter()
.map(|c| c["key"].as_str().unwrap())
.collect();
assert!(keys.contains(&"guaranteed_payments"));
assert!(
!keys.contains(&"officer_compensation"),
"a corporate category"
);
mcp.call(
"books.set_tax_category",
json!({"account": "4000", "category": "gross_receipts"}),
);
mcp.call(
"books.set_tax_category",
json!({"account": "6000", "category": "other_deductions"}),
);
let err = mcp.call_err(
"books.set_tax_category",
json!({"account": "4000", "category": "meals"}),
);
assert!(err.contains("expense"), "{err}");
let chart = mcp.call("books.chart", json!({}));
let sales = chart["accounts"]
.as_array()
.unwrap()
.iter()
.find(|a| a["code"] == "4000")
.unwrap()
.clone();
assert_eq!(sales["tax_category"], "gross_receipts");
// Jan-Feb: 6.50 of sales, 3.00 of software, all categorized.
let ts = mcp.call(
"books.export",
json!({"format": "json", "report": "tax_summary", "start": "2026-01-01", "end": "2026-02-28"}),
);
let ts: serde_json::Value = serde_json::from_str(ts["content"].as_str().unwrap()).unwrap();
assert_eq!(ts["form"], "Form 1065");
assert_eq!(ts["income"][0]["book"], "6.50");
assert_eq!(ts["deductions"][0]["category"], "other_deductions");
assert_eq!(ts["uncategorized"].as_array().unwrap().len(), 0);
assert_eq!(ts["tax_net"], "3.50");
// A chart import validates categories before creating anything.
let err = mcp.call_err(
"books.import_chart_csv",
json!({"csv_text": "code,name,type,parent_code,tax_category\n6100,Meals,expense,,meals\n4100,Fees,revenue,,commissions_fees\n"}),
);
assert!(err.contains("4100"), "{err}");
let chart = mcp.call("books.chart", json!({}));
assert!(
chart["accounts"]
.as_array()
.unwrap()
.iter()
.all(|a| a["code"] != "6100")
);
}
#[test]
fn mcp_partnership_from_template() {
let init = |mcp: &mut Mcp| {
mcp.request(
"initialize",
json!({
"protocolVersion": "2025-06-18",
"capabilities": {},
"clientInfo": {"name": "smoke-test", "version": "0.1.0"},
}),
);
mcp.notify("notifications/initialized");
};
// 1. Ask for a template (any server will do).
let scratch_books = Books::new("template-scratch");
let mut scratch = Mcp::spawn(&scratch_books);
init(&mut scratch);
let t = scratch.call(
"books.chart_template",
json!({"entity_type": "multi-member LLC", "owners": ["Ann", "Bo"]}),
);
let settings = t["settings"].clone();
let members = settings
.as_array()
.unwrap()
.iter()
.find(|c| c["key"] == "company.members")
.unwrap_or_else(|| panic!("{settings}"));
assert_eq!(
members["value"][1]["distribution_account"],
json!("3101"),
"{settings}"
);
let err = scratch.call_err("books.chart_template", json!({"entity_type": "LLC"}));
assert!(err.contains("entity_type"), "{err}");
// 2. Its chart becomes the chart; its settings, the company's
// (books.set_setting, the one way settings change).
let books = Books::with(
"template",
"USD",
Some("[books]\nreceipt_threshold = \"10000\"\n"),
);
let mut mcp = Mcp::spawn(&books);
init(&mut mcp);
mcp.call("books.import_chart_csv", json!({"csv_text": t["csv"]}));
mcp.call(
"books.set_setting",
json!({"changes": settings, "decided_by": "Ann"}),
);
let post = |mcp: &mut Mcp, date: &str, dr: &str, cr: &str, amount: &str| {
let e = mcp.call(
"books.draft_entry",
json!({"date": date, "description": "x"}),
);
let id = e["entry_id"].as_u64().unwrap();
mcp.call(
"books.add_line",
json!({"entry_id": id, "account": dr, "side": "debit", "amount": amount}),
);
mcp.call(
"books.add_line",
json!({"entry_id": id, "account": cr, "side": "credit", "amount": amount}),
);
mcp.call(
"books.post_entry",
json!({"entry_id": id, "confirmed": true}),
);
};
post(&mut mcp, "2026-01-02", "1000", "3000", "1000.00");
post(&mut mcp, "2026-01-02", "1000", "3001", "1000.00");
post(&mut mcp, "2026-01-10", "1000", "4000", "400.00");
post(&mut mcp, "2026-01-20", "3101", "1000", "50.00");
// 3. Members' equity sees Bo's distribution in his distributions account.
let me = mcp.call(
"books.export",
json!({"format": "json", "report": "members_equity", "start": "2026-01-01", "end": "2026-01-31"}),
);
let me: serde_json::Value = serde_json::from_str(me["content"].as_str().unwrap()).unwrap();
assert_eq!(me["members"][1]["distributions"], "50.00");
assert_eq!(me["members"][1]["ending"], "1150.00");
// 4. Closing January: 200 each to capital, Bo's distributions swept in.
let jan = mcp.call(
"books.create_period",
json!({"name": "2026-01", "start": "2026-01-01", "end": "2026-01-31"}),
);
let closed = mcp.call(
"books.close_period",
json!({"period_id": jan["period_id"], "confirmed": true}),
);
assert_eq!(
closed["lines"],
json!([
"Debit 4000 400.00",
"Credit 3000 200.00",
"Credit 3001 200.00",
"Credit 3101 50.00",
"Debit 3001 50.00"
])
);
let bs = mcp.call("books.balance_sheet", json!({"as_of": "2026-01-31"}));
let equity: Vec<(String, String)> = bs["equity_accounts"]
.as_array()
.unwrap()
.iter()
.map(|a| {
(
a["code"].as_str().unwrap().to_string(),
a["amount"].as_str().unwrap().to_string(),
)
})
.collect();
assert_eq!(
equity,
[
("3000".into(), "1200.00".into()),
("3001".into(), "1150.00".into())
]
);
}
/// Contract (rules.md B-13, D-10; docs/mcp-interface.md close_period and
/// list_periods): a close to the members shows each member's running
/// split, the same in the preview as once confirmed, and the periods say
/// which rule they closed under. Two members at 50% each, hand-computed:
/// January's cent is 0.005 each exactly, the tie to Ann (listed first):
/// Ann 0.01, Bo 0.00. February's cent makes the running 0.02, 0.01 each,
/// so February gives Bo 0.01. After a restart, both closes count from
/// close 1, the books verify, and the statement of members' equity shows
/// 0.01 each.
#[test]
fn close_period_shows_each_members_running_split() {
let scratch_books = Books::new("members-split-scratch");
let mut scratch = Mcp::spawn(&scratch_books);
init(&mut scratch);
let t = scratch.call(
"books.chart_template",
json!({"entity_type": "multi-member LLC", "owners": ["Ann", "Bo"]}),
);
let books = Books::with(
"members-split",
"USD",
Some("[books]\nreceipt_threshold = \"10000\"\n"),
);
let spawn = |books: &Books| {
let mut mcp = Mcp::spawn(books);
init(&mut mcp);
mcp
};
let mut mcp = spawn(&books);
mcp.call("books.import_chart_csv", json!({"csv_text": t["csv"]}));
mcp.call(
"books.set_setting",
json!({"changes": t["settings"], "decided_by": "Ann"}),
);
let sale = |mcp: &mut Mcp, date: &str| {
let e = mcp.call(
"books.draft_entry",
json!({"date": date, "description": "a cent"}),
);
let id = e["entry_id"].as_u64().unwrap();
for (account, side) in [("1000", "debit"), ("4000", "credit")] {
mcp.call(
"books.add_line",
json!({"entry_id": id, "account": account, "side": side, "amount": "0.01"}),
);
}
mcp.call(
"books.post_entry",
json!({"entry_id": id, "confirmed": true}),
);
};
let member = |name: &str, code: &str, this: &str, running: &str, exact: &str| {
json!({"name": name, "capital_account": code, "share": "0.50",
"this_close": this, "running": running, "exact": exact})
};
sale(&mut mcp, "2026-01-10");
let jan = mcp.call(
"books.create_period",
json!({"name": "2026-01", "start": "2026-01-01", "end": "2026-01-31"}),
);
let preview = mcp.call(
"books.close_period",
json!({"period_id": jan["period_id"], "confirmed": false}),
);
assert_eq!(
preview["members"],
json!({"since_close": 1, "members": [
member("Ann", "3000", "0.01", "0.01", "0.005"),
member("Bo", "3001", "0.00", "0.00", "0.005"),
]})
);
assert_eq!(
preview["lines"],
json!(["Debit 4000 0.01", "Credit 3000 0.01"])
);
let closed = mcp.call(
"books.close_period",
json!({"period_id": jan["period_id"], "confirmed": true}),
);
assert_eq!(closed["members"], preview["members"]);
assert_eq!(closed["lines"], preview["lines"]);
sale(&mut mcp, "2026-02-10");
let feb = mcp.call(
"books.create_period",
json!({"name": "2026-02", "start": "2026-02-01", "end": "2026-02-28"}),
);
let closed = mcp.call(
"books.close_period",
json!({"period_id": feb["period_id"], "confirmed": true}),
);
assert_eq!(
closed["members"],
json!({"since_close": 1, "members": [
member("Ann", "3000", "0.00", "0.01", "0.01"),
member("Bo", "3001", "0.01", "0.01", "0.01"),
]})
);
assert_eq!(
closed["lines"],
json!(["Debit 4000 0.01", "Credit 3001 0.01"])
);
drop(mcp);
books.restart();
let mut mcp = spawn(&books);
assert_eq!(mcp.call("books.verify", json!({}))["status"], "ok");
let periods = mcp.call("books.list_periods", json!({}));
let rules: Vec<&Value> = periods["periods"]
.as_array()
.unwrap()
.iter()
.map(|p| &p["close_rule"])
.collect();
assert_eq!(
rules,
[
&json!("members_cumulative_since:1"),
&json!("members_cumulative_since:1")
]
);
let me = mcp.call(
"books.export",
json!({"format": "json", "report": "members_equity", "start": "2026-01-01", "end": "2026-02-28"}),
);
let me: Value = serde_json::from_str(me["content"].as_str().unwrap()).unwrap();
assert_eq!(me["members"][0]["ending"], "0.01");
assert_eq!(me["members"][1]["ending"], "0.01");
}
/// Contract (rules.md B-13, D-10; docs/mcp-interface.md list_periods and
/// close_period) on books an earlier version wrote: ledger-store's
/// old-books fixture x_member_closes (two members at 50%, A with a
/// distributions account; three closes untagged, three
/// "largest_remainder", then "cumulative" broken by a close to retained
/// earnings), left in a data root in P2's format. list_periods names each
/// close's rule; December's first close under the rule since P3 counts
/// from close 1 and carries the true-up, refused (naming its feature)
/// until the owner upgrades the books, then the same as previewed.
/// By hand (the fixture's writer): the members closed 0.12, A 0.07 and B
/// 0.05 of it (exact 0.06 each). December earns 0.02: the running 0.14
/// splits 0.07 each, less 0.07 / 0.05 given, so Ann gets 0.00 and Bo
/// 0.02, both at their exact 0.07.
#[test]
fn old_books_name_each_closes_rule_and_true_up_at_the_next_close() {
// Books P1 kept, which P2 moved to its journal format (format 2, none
// of the features since), left in a data root, with their members set
// as an agent sets them: books waiting for their format upgrade.
let fixture = std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
.join("../ledger-store/tests/fixtures/old-books/x_member_closes");
let books = Books::placed("members-old", &fixture);
books.stop();
ledger_store::Store::open(&books.dir())
.unwrap()
.declare_journal_format(2, &[])
.unwrap();
books.start();
books.settle(
"[company]\ntax_form = \"1065\"\n\n\
[[company.members]]\nname = \"Ann\"\nshare = \"50%\"\ncapital_account = \"3000\"\n\
distribution_account = \"3100\"\n\n\
[[company.members]]\nname = \"Bo\"\nshare = \"50%\"\ncapital_account = \"3001\"\n\n\
[books]\nreceipt_threshold = \"10000\"\n",
);
let mut mcp = Mcp::spawn(&books);
init(&mut mcp);
let rules = |mcp: &mut Mcp| -> Vec<Value> {
mcp.call("books.list_periods", json!({}))["periods"]
.as_array()
.unwrap()
.iter()
.map(|p| p["close_rule"].clone())
.collect()
};
let old = [
"members_last_absorbs",
"members_last_absorbs",
"members_last_absorbs",
"members_per_close",
"members_per_close",
"members_per_close",
"members_run",
"members_run",
];
let mut want: Vec<Value> = old.iter().map(|r| json!(r)).collect();
want.extend([Value::Null, json!("members_run"), json!("members_run")]);
assert_eq!(rules(&mut mcp), want);
let e = mcp.call(
"books.draft_entry",
json!({"date": "2026-12-10", "description": "two cents"}),
);
let id = e["entry_id"].as_u64().unwrap();
for (account, side) in [("1000", "debit"), ("4000", "credit")] {
mcp.call(
"books.add_line",
json!({"entry_id": id, "account": account, "side": side, "amount": "0.02"}),
);
}
mcp.call(
"books.post_entry",
json!({"entry_id": id, "confirmed": true}),
);
let dec = mcp.call(
"books.create_period",
json!({"name": "2026-12", "start": "2026-12-01", "end": "2026-12-31"}),
);
let preview = mcp.call(
"books.close_period",
json!({"period_id": dec["period_id"], "confirmed": false}),
);
let member = |name: &str, code: &str, this: &str| {
json!({"name": name, "capital_account": code, "share": "0.50",
"this_close": this, "running": "0.07", "exact": "0.07"})
};
assert_eq!(
preview["members"],
json!({"since_close": 1, "members": [
member("Ann", "3000", "0.00"),
member("Bo", "3001", "0.02"),
]})
);
assert_eq!(
preview["lines"],
json!(["Debit 4000 0.02", "Credit 3001 0.02"])
);
// Books an older version wrote: the close counted from its regime's
// start waits for the owner's go-ahead (D-39), which no tool gives.
let refused = mcp.call_err(
"books.close_period",
json!({"period_id": dec["period_id"], "confirmed": true}),
);
assert!(
refused.starts_with("format_upgrade_pending:") && refused.contains("members_cumulative"),
"{refused}"
);
// The owner's go-ahead, as the app gives it (ledger-gui owner.rs, after
// the passphrase and a verified backup): the server stopped, the books
// moved to this version's format, and served again.
drop(mcp);
books.stop();
ledger_store::Store::open(&books.dir())
.unwrap()
.upgrade_format()
.unwrap()
.expect("old books wait for the upgrade");
books.start();
let mut mcp = Mcp::spawn(&books);
init(&mut mcp);
let closed = mcp.call(
"books.close_period",
json!({"period_id": dec["period_id"], "confirmed": true}),
);
assert_eq!(closed["members"], preview["members"]);
assert_eq!(closed["lines"], preview["lines"]);
want.push(json!("members_cumulative_since:1"));
assert_eq!(rules(&mut mcp), want);
assert_eq!(mcp.call("books.verify", json!({}))["status"], "ok");
}
#[test]
fn mcp_schedules_and_recurring() {
let books = Books::new("schedules");
let init = |mcp: &mut Mcp| {
mcp.request(
"initialize",
json!({
"protocolVersion": "2025-06-18",
"capabilities": {},
"clientInfo": {"name": "smoke-test", "version": "0.1.0"},
}),
);
mcp.notify("notifications/initialized");
};
let mut mcp = Mcp::spawn(&books);
init(&mut mcp);
for (code, name, typ) in [
("1000", "Bank", "asset"),
("1300", "Prepaid Expenses", "asset"),
("6300", "Insurance", "expense"),
("6600", "Rent", "expense"),
] {
mcp.call(
"books.add_account",
json!({"code": code, "name": name, "account_type": typ}),
);
}
// 1,000.00 of insurance over 3 equal months: 333.33, 333.33, 333.34
// (an equal-months schedule starts on the 1st, D-37).
let s = mcp.call(
"books.create_schedule",
json!({
"kind": "prepaid_expense",
"description": "Liability insurance",
"total": "1000.00",
"start": "2026-11-01",
"months": 3,
"balance_account": "1300",
"pl_account": "6300",
"convention": "monthly_even",
}),
);
assert_eq!(s["convention"], "monthly_even");
let amounts: Vec<&str> = s["months"]
.as_array()
.unwrap()
.iter()
.map(|m| m["amount"].as_str().unwrap())
.collect();
assert_eq!(amounts, ["333.33", "333.33", "333.34"]);
let err = mcp.call_err(
"books.create_schedule",
json!({
"kind": "prepaid_expense", "description": "x", "total": "10",
"start": "2026-11-01", "months": 2,
"balance_account": "6300", "pl_account": "6300",
}),
);
assert!(err.contains("Asset"), "{err}");
// Preview posts nothing; confirmed posts November and December.
let preview = mcp.call("books.run_schedules", json!({"through": "2026-12-31"}));
assert_eq!(preview["status"], "preview");
assert_eq!(preview["postings"].as_array().unwrap().len(), 2);
let pl = mcp.call(
"books.profit_and_loss",
json!({"start": "2026-11-01", "end": "2026-12-31"}),
);
assert_eq!(pl["expenses"], "0.00");
let run = mcp.call(
"books.run_schedules",
json!({"through": "2026-12-31", "confirmed": true}),
);
assert_eq!(run["status"], "posted");
assert_eq!(run["postings"][1]["date"], "2026-12-31");
let again = mcp.call(
"books.run_schedules",
json!({"through": "2026-12-31", "confirmed": true}),
);
assert!(
again["postings"].as_array().unwrap().is_empty(),
"idempotent"
);
// Monthly rent: drafts only, never posted.
mcp.call(
"books.create_recurring",
json!({
"description": "Office rent",
"frequency": "monthly",
"start": "2026-11-01",
"lines": [
{"account": "6600", "side": "debit", "amount": "2000.00"},
{"account": "1000", "side": "credit", "amount": "2000.00"},
],
}),
);
let drafts = mcp.call("books.run_recurring", json!({"through": "2026-12-15"}));
assert_eq!(drafts["drafts"].as_array().unwrap().len(), 2);
let pl = mcp.call(
"books.profit_and_loss",
json!({"start": "2026-11-01", "end": "2026-12-31"}),
);
assert_eq!(pl["expenses"], "666.66", "rent drafts don't post");
drop(mcp);
// Restart: everything replays from the journal.
books.restart();
let mut mcp = Mcp::spawn(&books);
init(&mut mcp);
let list = mcp.call("books.list_schedules", json!({}));
assert_eq!(list["schedules"][0]["remaining"], "333.34");
let rec = mcp.call("books.list_recurring", json!({}));
assert_eq!(
rec["recurring"][0]["generated"],
json!(["2026-11-01", "2026-12-01"])
);
let more = mcp.call("books.run_recurring", json!({"through": "2026-12-31"}));
assert!(more["drafts"].as_array().unwrap().is_empty(), "idempotent");
}
#[test]
fn mcp_payout_gross_up() {
let books = Books::new("payout");
let mut mcp = Mcp::spawn(&books);
mcp.request(
"initialize",
json!({
"protocolVersion": "2025-06-18",
"capabilities": {},
"clientInfo": {"name": "smoke-test", "version": "0.1.0"},
}),
);
mcp.notify("notifications/initialized");
for (code, name, typ) in [
("1000", "Bank", "asset"),
("4000", "App Sales", "revenue"),
("4100", "Refunds", "expense"),
("6000", "Platform Fees", "expense"),
] {
mcp.call(
"books.add_account",
json!({"code": code, "name": name, "account_type": typ}),
);
}
mcp.call("books.add_class", json!({"name": "App A"}));
mcp.call("books.add_class", json!({"name": "App B"}));
// 1,000.00 + 500.00 gross, 225.00 commission, a 9.99 refund: 1,265.01.
let mut payout = json!({
"date": "2026-11-30",
"description": "App Store payout, November 2026",
"deposit_account": "1000",
"deposit": "1265.00",
"revenue": [
{"account": "4000", "amount": "1000.00", "class": "App A"},
{"account": "4000", "amount": "500.00", "class": "App B"},
],
"fees": [
{"account": "6000", "amount": "150.00", "class": "App A"},
{"account": "6000", "amount": "75.00", "class": "App B"},
],
"adjustments": [
{"account": "4100", "side": "debit", "amount": "9.99", "class": "App A"},
],
});
let err = mcp.call_err("books.record_payout", payout.clone());
assert!(err.contains("off by -0.01"), "{err}");
payout["deposit"] = json!("1265.01");
let out = mcp.call("books.record_payout", payout);
assert_eq!(out["status"], "draft");
assert_eq!(out["gross"], "1500.00");
assert_eq!(out["adjustments"], "-9.99");
assert_eq!(
out["lines"],
json!([
"Debit 1000 1265.01",
"Debit 6000 150.00",
"Debit 6000 75.00",
"Debit 4100 9.99",
"Credit 4000 1000.00",
"Credit 4000 500.00"
])
);
// The statement is the receipt; then it posts like any entry.
let entry_id = out["entry_id"].as_u64().unwrap();
mcp.call(
"books.attach_receipt",
json!({
"entry_id": entry_id,
"file_name": "payout.pdf",
"mime_type": "application/pdf",
"content_base64": base64_like(b"%PDF-1.4 statement"),
"sha256": sha(b"%PDF-1.4 statement"),
}),
);
mcp.call(
"books.post_entry",
json!({"entry_id": entry_id, "confirmed": true}),
);
let by_class = mcp.call(
"books.profit_and_loss_by_class",
json!({"start": "2026-11-01", "end": "2026-11-30"}),
);
let net: Vec<(String, String)> = by_class["classes"]
.as_array()
.unwrap()
.iter()
.map(|c| {
(
c["class"].as_str().unwrap_or("").to_string(),
c["net_income"].as_str().unwrap().to_string(),
)
})
.collect();
assert_eq!(
net,
[
("App A".into(), "840.01".into()),
("App B".into(), "425.00".into())
]
);
}
#[test]
fn mcp_vendor_1099() {
let books = Books::new("form1099");
let init = |mcp: &mut Mcp| {
mcp.request(
"initialize",
json!({
"protocolVersion": "2025-06-18",
"capabilities": {},
"clientInfo": {"name": "smoke-test", "version": "0.1.0"},
}),
);
mcp.notify("notifications/initialized");
};
let mut mcp = Mcp::spawn(&books);
init(&mut mcp);
for (code, name, typ) in [
("1000", "Bank", "asset"),
("6000", "Contract Labor", "expense"),
] {
mcp.call(
"books.add_account",
json!({"code": code, "name": name, "account_type": typ}),
);
}
// The worksheet counts payments out of the books' cash accounts.
mcp.call(
"books.set_setting",
json!({"changes": [{"key": "books.cash_accounts", "value": ["1000"]}], "decided_by": "Test Owner"}),
);
mcp.call("books.add_vendor", json!({"name": "Dana Designs"}));
let v = mcp.call(
"books.set_vendor_1099",
json!({"vendor": "dana designs", "form_1099": "nec", "decided_by": "Test Owner"}),
);
assert_eq!(v["form_1099"], "1099-NEC");
assert_eq!(v["decision_1099"]["decided_by"], "Test Owner");
let err = mcp.call_err(
"books.set_vendor_1099",
json!({"vendor": "Dana Designs", "form_1099": "k", "decided_by": "Test Owner"}),
);
assert!(
err.contains("expected nec, misc, not_reportable, or undetermined"),
"{err}"
);
// A 2,500.00 check to Dana, tagged at draft time.
let e = mcp.call(
"books.draft_entry",
json!({"date": "2026-05-01", "description": "Logo", "vendor": "Dana Designs"}),
);
let id = e["entry_id"].as_u64().unwrap();
for (account, side) in [("6000", "debit"), ("1000", "credit")] {
mcp.call(
"books.add_line",
json!({"entry_id": id, "account": account, "side": side, "amount": "2500.00"}),
);
}
mcp.call(
"books.attach_receipt",
json!({
"entry_id": id,
"file_name": "invoice.pdf",
"mime_type": "application/pdf",
"content_base64": base64_like(b"%PDF-1.4 invoice"),
"sha256": sha(b"%PDF-1.4 invoice"),
}),
);
mcp.call(
"books.post_entry",
json!({"entry_id": id, "confirmed": true}),
);
let got = mcp.call("books.get_entry", json!({"entry_id": id}));
assert_eq!(got["vendor"], "Dana Designs");
let r = mcp.call("books.vendor_1099", json!({"year": 2026}));
assert_eq!(r["threshold"], "2000.00");
assert_eq!(r["vendors"][0]["paid"], "2500.00");
assert_eq!(r["vendors"][0]["reportable"], true);
assert_eq!(r["missing_w9"], json!(["Dana Designs"]));
// An override above what was paid: not reportable.
let high = mcp.call(
"books.vendor_1099",
json!({"year": 2026, "threshold": "3000"}),
);
assert_eq!(high["vendors"][0]["reportable"], false);
// W-9 arrives: the flag alone, the decision kept.
mcp.call(
"books.set_vendor_1099",
json!({"vendor": "Dana Designs", "tin_on_file": true}),
);
drop(mcp);
books.restart();
let mut mcp = Mcp::spawn(&books);
init(&mut mcp);
let r = mcp.call("books.vendor_1099", json!({"year": 2026}));
assert_eq!(r["vendors"][0]["paid"], "2500.00");
assert_eq!(r["missing_w9"], json!([]));
// Clearing the tag removes the payment.
mcp.call("books.set_entry_vendor", json!({"entry_id": id}));
let r = mcp.call("books.vendor_1099", json!({"year": 2026}));
assert_eq!(r["vendors"][0]["paid"], "0.00");
// A year whose threshold isn't known yet: refused, with what to do
// (rules.md B-19). An explicit threshold works.
let why = mcp.call_err("books.vendor_1099", json!({"year": 2027}));
for needle in [
"only through 2026",
"official source",
"irs.gov",
"tell the human",
] {
assert!(why.contains(needle), "missing {needle:?} in: {why}");
}
let r = mcp.call(
"books.vendor_1099",
json!({"year": 2027, "threshold": "2100"}),
);
assert_eq!(r["threshold"], "2100.00");
let vendors = mcp.call("books.list_vendors", json!({}));
assert_eq!(vendors["vendors"][0]["tin_on_file"], true);
// The server made the books' ops/ as they opened: a viewer open from
// before the first books.show is seen by it.
let viewer = ledger_store::ops::Presence::hold(&books.dir()).expect("ops/ made at start");
// Show the entry in the viewer: a request in the books' ops/, never
// journaled.
let shown = mcp.call("books.show", json!({"entry_id": id}));
assert!(shown["shown"].as_str().unwrap().contains(&format!("#{id}")));
assert_eq!(shown["viewer_open"], true);
let cmd = ledger_store::ops::read(&books.dir()).expect("request written");
assert_eq!(
cmd.target,
ledger_store::ops::ViewerTarget::Entry { entry_id: id }
);
// With no viewer, the note says to open one.
drop(viewer);
let shown = mcp.call("books.show", json!({"page": "cash"}));
assert_eq!(shown["viewer_open"], false);
assert!(shown["note"].as_str().unwrap().contains("No viewer"));
let err = mcp.call_err("books.show", json!({"entry_id": 9999}));
assert!(err.contains("unknown entry"), "{err}");
let err = mcp.call_err("books.show", json!({"page": "cash", "search": "x"}));
assert!(err.contains("exactly one"), "{err}");
// The books check out end to end, receipt included.
let v = mcp.call("books.verify", json!({}));
assert_eq!(v["status"], "ok");
assert_eq!(v["blobs_verified"], 1);
assert!(v["events"].as_u64().unwrap() > 10);
}
#[test]
fn mcp_receipts_always_required_when_threshold_is_zero() {
let books = Books::with(
"always-receipts",
"USD",
Some("[books]\nreceipt_threshold = \"0.00\"\n"),
);
let mut mcp = Mcp::spawn(&books);
let init = mcp.request(
"initialize",
json!({
"protocolVersion": "2025-06-18",
"capabilities": {},
"clientInfo": {"name": "smoke-test", "version": "0.1.0"},
}),
);
let instructions = init["result"]["instructions"].as_str().unwrap();
assert!(
instructions.contains("every entry needs at least one receipt"),
"the agent is told the real policy: {instructions}"
);
assert!(!instructions.contains("$25.00"));
mcp.notify("notifications/initialized");
for (code, name, typ) in [("1000", "Bank", "asset"), ("6000", "Fees", "expense")] {
mcp.call(
"books.add_account",
json!({"code": code, "name": name, "account_type": typ}),
);
}
let e = mcp.call(
"books.draft_entry",
json!({"date": "2026-10-01", "description": "Bank fee"}),
);
let id = e["entry_id"].as_u64().unwrap();
for (account, side) in [("6000", "debit"), ("1000", "credit")] {
mcp.call(
"books.add_line",
json!({"entry_id": id, "account": account, "side": side, "amount": "5.00"}),
);
}
let err = mcp.call_err(
"books.post_entry",
json!({"entry_id": id, "confirmed": true}),
);
assert!(
err.contains("receipt"),
"a $5 entry needs a receipt too: {err}"
);
}
/// `books.cash_flow` with no accounts passed reads the roles from
/// `[books]` in wanderbooks.toml (rules.md B-14): the expense the owners
/// paid through 2100 is borrowed from them and spent on its date, each
/// line with `on_behalf` (D-42, rule 11); repaying the advance is
/// financing; a loan to a member is investing; the statement is the direct
/// method's, and it foots.
#[test]
fn cash_flow_reads_its_account_roles_from_the_books_settings() {
let books = Books::with(
"cash-roles",
"USD",
Some(
"[books]\nreceipt_threshold = \"1000000.00\"\ncash_accounts = [\"1000\"]\nfinancing_accounts = [\"2100\"]\ninvesting_accounts = [\"1400\"]\n",
),
);
let mut mcp = Mcp::spawn(&books);
mcp.request(
"initialize",
json!({
"protocolVersion": "2025-06-18",
"capabilities": {},
"clientInfo": {"name": "smoke-test", "version": "0.1.0"},
}),
);
mcp.notify("notifications/initialized");
for (code, name, typ) in [
("1000", "Bank", "asset"),
("1400", "Loan to member", "asset"),
("2100", "Due to owners", "liability"),
("3000", "Capital", "equity"),
("6000", "Software", "expense"),
] {
mcp.call(
"books.add_account",
json!({"code": code, "name": name, "account_type": typ}),
);
}
let mut post = |date: &str, dr: &str, cr: &str, amount: &str| {
let e = mcp.call(
"books.draft_entry",
json!({"date": date, "description": "test"}),
);
let id = e["entry_id"].as_u64().unwrap();
for (account, side) in [(dr, "debit"), (cr, "credit")] {
mcp.call(
"books.add_line",
json!({"entry_id": id, "account": account, "side": side, "amount": amount}),
);
}
mcp.call(
"books.post_entry",
json!({"entry_id": id, "confirmed": true}),
);
};
post("2026-03-01", "1000", "3000", "1000.00");
post("2026-03-05", "6000", "2100", "100.00");
post("2026-03-10", "1400", "1000", "500.00");
post("2026-03-28", "2100", "1000", "100.00");
let cf = mcp.call(
"books.cash_flow",
json!({"start": "2026-03-01", "end": "2026-03-31"}),
);
let amounts = |section: &str| -> Vec<String> {
cf[section]
.as_array()
.unwrap()
.iter()
.map(|l| l["amount"].as_str().unwrap().to_string())
.collect()
};
// Financing by kind: the contribution, what the owners paid for the
// company (borrowed through 2100), then the repayment of 2100.
assert_eq!(amounts("financing"), ["1000.00", "100.00", "-100.00"]);
assert_eq!(cf["financing"][1]["entry"], 2);
assert_eq!(cf["financing"][1]["on_behalf"], "100.00");
assert!(cf["financing"][0].get("on_behalf").is_none());
let kinds: Vec<&str> = cf["financing_by_kind"]
.as_array()
.unwrap()
.iter()
.map(|k| k["kind"].as_str().unwrap())
.collect();
assert_eq!(
kinds,
[
"Contributions from owners — 3000 Capital",
"Proceeds from borrowing — 2100 Due to owners",
"Repayments of borrowing — 2100 Due to owners"
]
);
assert_eq!(amounts("investing"), ["-500.00"]);
assert_eq!(amounts("operating"), ["-100.00"]);
assert_eq!(cf["operating"][0]["on_behalf"], "-100.00");
assert_eq!(cf["operating"][0]["class"], "to_suppliers_and_employees");
assert_eq!(cf["method"], "direct");
assert_eq!(cf["net_change_in_cash"], "400.00");
assert_eq!(cf["reconciles"]["sections_sum"], "400.00");
assert_eq!(cf["reconciles"]["difference"], "0.00");
// Nothing noncash: the software is net income's only item.
assert_eq!(cf["noncash_disclosures"], json!([]));
let r = &cf["operating_reconciliation"];
assert_eq!(r["net_income"], "-100.00");
assert_eq!(r["lines"], json!([]));
assert_eq!(r["net_cash_from_operating"], "-100.00");
for gone in ["net_income", "depreciation", "working_capital"] {
assert!(cf.get(gone).is_none(), "{gone} is gone");
}
}
/// rules.md B-14 rule 2: roles that contradict the chart are refused,
/// naming the setting and the fix, never a statement on a wrong partition.
#[test]
fn cash_flow_refuses_contradictory_roles() {
let books = Books::new("cash-refuse");
let mut mcp = Mcp::spawn(&books);
mcp.request(
"initialize",
json!({
"protocolVersion": "2025-06-18",
"capabilities": {},
"clientInfo": {"name": "smoke-test", "version": "0.1.0"},
}),
);
mcp.notify("notifications/initialized");
for (code, name, typ) in [
("1000", "Bank", "asset"),
("1500", "Equipment", "asset"),
("2100", "Due to owners", "liability"),
] {
mcp.call(
"books.add_account",
json!({"code": code, "name": name, "account_type": typ}),
);
}
let err = mcp.call_err(
"books.cash_flow",
json!({"cash_accounts": ["1000"], "financing_accounts": ["1500"]}),
);
assert!(
err.contains("financing_accounts (from the call or books.financing_accounts) lists 1500"),
"{err}"
);
assert!(err.contains("financing accounts are liabilities"), "{err}");
let err = mcp.call_err(
"books.cash_flow",
json!({"cash_accounts": ["1000", "2100"]}),
);
assert!(err.contains("cash accounts are asset accounts"), "{err}");
let err = mcp.call_err(
"books.cash_flow",
json!({"cash_accounts": ["1000"], "investing_accounts": ["1000"]}),
);
assert!(err.contains("is in both cash_accounts"), "{err}");
}
/// A chart CSV with a bad row anywhere is refused whole: nothing is
/// half-imported (the old checks missed duplicates within the file and a
/// parent listed after its child).
#[test]
fn a_chart_file_with_a_bad_row_imports_nothing() {
let books = Books::new("chart-atomic");
let mut mcp = Mcp::spawn(&books);
mcp.request(
"initialize",
json!({
"protocolVersion": "2025-06-18",
"capabilities": {},
"clientInfo": {"name": "smoke-test", "version": "0.1.0"},
}),
);
mcp.notify("notifications/initialized");
let count = |mcp: &mut Mcp| {
mcp.call("books.chart", json!({}))["accounts"]
.as_array()
.unwrap()
.len()
};
let before = count(&mut mcp);
for bad in [
// A duplicate code within the file.
"code,name,type,parent_code\n1000,Cash,asset,\n1010,Savings,asset,\n1000,Again,asset,\n",
// A parent listed after its child.
"code,name,type,parent_code\n1000,Cash,asset,\n1011,Sub,asset,1010\n1010,Bank,asset,\n",
// A child of another type than its parent.
"code,name,type,parent_code\n1000,Cash,asset,\n4000,Sales,revenue,1000\n",
] {
let err = mcp.call_err("books.import_chart_csv", json!({"csv_text": bad}));
assert!(!err.is_empty());
assert_eq!(count(&mut mcp), before, "half-imported: {bad}");
}
let ok = mcp.call(
"books.import_chart_csv",
json!({"csv_text": "code,name,type,parent_code\n1000,Cash,asset,\n1010,Checking,asset,1000\n"}),
);
assert_eq!(ok["count"], 2);
}
/// Bank CSV import: slash dates that read either way need the caller's
/// order; overlapping rules pick the longest pattern every time; and a
/// transaction that can't become a draft refuses the whole file.
#[test]
fn bank_import_reads_dates_rules_and_files_whole() {
let books = Books::new("bank-import");
let mut mcp = Mcp::spawn(&books);
mcp.request(
"initialize",
json!({
"protocolVersion": "2025-06-18",
"capabilities": {},
"clientInfo": {"name": "smoke-test", "version": "0.1.0"},
}),
);
mcp.notify("notifications/initialized");
for (code, name, typ) in [
("1000", "Bank", "asset"),
("6000", "Other", "expense"),
("6100", "Coffee", "expense"),
("6200", "Coffee beans", "expense"),
] {
mcp.call(
"books.add_account",
json!({"code": code, "name": name, "account_type": typ}),
);
}
let base = |csv: &str| {
json!({
"csv_text": csv,
"bank_account_code": "1000",
"default_offset_code": "6000",
"rules": {"coffee": "6100", "coffee beans": "6200"},
})
};
// 3/4/2026 reads both ways and nothing in the file settles it.
let ambiguous = "Date,Description,Amount\n3/4/2026,Coffee shop,-4.50\n";
let err = mcp.call_err("books.import_bank_csv", base(ambiguous));
assert!(err.contains("either way"), "{err}");
let mut args = base(ambiguous);
args["date_order"] = json!("day_first");
let out = mcp.call("books.import_bank_csv", args);
assert_eq!(out["imported"][0]["date"], "2026-04-03");
// Both patterns match "Coffee beans": the longer wins, every time.
for _ in 0..5 {
let out = mcp.call(
"books.import_bank_csv",
base("Date,Description,Amount\n2026-05-01,Coffee beans 2kg,-30.00\n"),
);
assert_eq!(out["imported"][0]["offset_account"], "6200");
}
// A transaction dated in a closed period refuses the file: nothing of
// it becomes a draft.
let p = mcp.call(
"books.create_period",
json!({"name": "2026-01", "start": "2026-01-01", "end": "2026-01-31"}),
);
mcp.call(
"books.close_period",
json!({"period_id": p["period_id"], "confirmed": true}),
);
let err = mcp.call_err(
"books.import_bank_csv",
base(
"Date,Description,Amount\n2026-06-01,Fine,-1.00\n2026-01-15,In a closed month,-2.00\n",
),
);
assert!(err.contains("closed"), "{err}");
let journal = String::from_utf8(books.journal()).unwrap();
assert!(
!journal.contains("2026-06-01"),
"the first row was journaled"
);
}
/// books.renumber_account moves an account off a control code: payroll then
/// gets its own wages account, the renumbered account keeps its entries,
/// and settings naming the old code are listed.
#[test]
fn renumbering_an_account_keeps_its_history() {
let books = Books::with(
"renumber",
"USD",
Some("[books]\nreceipt_threshold = \"1000000.00\"\ncash_accounts = [\"1000\"]\n"),
);
let mut mcp = Mcp::spawn(&books);
mcp.request(
"initialize",
json!({
"protocolVersion": "2025-06-18",
"capabilities": {},
"clientInfo": {"name": "smoke-test", "version": "0.1.0"},
}),
);
mcp.notify("notifications/initialized");
for (code, name, typ) in [
("1000", "Bank", "asset"),
("2100", "Due to owners", "liability"),
("6100", "Domain & Hosting", "expense"),
] {
mcp.call(
"books.add_account",
json!({"code": code, "name": name, "account_type": typ}),
);
}
let e = mcp.call(
"books.draft_entry",
json!({"date": "2026-03-05", "description": "domain"}),
);
let id = e["entry_id"].as_u64().unwrap();
for (account, side) in [("6100", "debit"), ("2100", "credit")] {
mcp.call(
"books.add_line",
json!({"entry_id": id, "account": account, "side": side, "amount": "12.34"}),
);
}
mcp.call(
"books.post_entry",
json!({"entry_id": id, "confirmed": true}),
);
// Refusals: a code in use; a control code of another type (2110 is
// payroll's federal withholding, a liability).
let err = mcp.call_err(
"books.renumber_account",
json!({"account": "6100", "code": "1000"}),
);
assert!(err.contains("already in use"), "{err}");
let err = mcp.call_err(
"books.renumber_account",
json!({"account": "6100", "code": "2110"}),
);
assert!(err.contains("2110"), "{err}");
let out = mcp.call(
"books.renumber_account",
json!({"account": "6100", "code": "6050"}),
);
assert_eq!(out["previous_code"], "6100");
assert_eq!(out["code"], "6050");
assert_eq!(out["config_mentions"], json!([]));
let chart = mcp.call("books.chart", json!({}));
let acct = chart["accounts"]
.as_array()
.unwrap()
.iter()
.find(|a| a["code"] == "6050")
.expect("renumbered");
assert_eq!(acct["name"], "Domain & Hosting");
assert_eq!(acct["balance"], "12.34");
assert!(
!chart["accounts"]
.as_array()
.unwrap()
.iter()
.any(|a| a["code"] == "6100")
);
// The cash account is named in the books' settings: listed.
let out = mcp.call(
"books.renumber_account",
json!({"account": "1000", "code": "1010"}),
);
assert_eq!(out["config_mentions"], json!(["books.cash_accounts"]));
drop(mcp);
// A restart replays the journal to the same chart.
books.restart();
let mut mcp = Mcp::spawn(&books);
mcp.request(
"initialize",
json!({
"protocolVersion": "2025-06-18",
"capabilities": {},
"clientInfo": {"name": "smoke-test", "version": "0.1.0"},
}),
);
mcp.notify("notifications/initialized");
let chart = mcp.call("books.chart", json!({}));
let codes: Vec<&str> = chart["accounts"]
.as_array()
.unwrap()
.iter()
.map(|a| a["code"].as_str().unwrap())
.collect();
assert!(
codes.contains(&"6050") && codes.contains(&"1010"),
"{codes:?}"
);
assert!(
!codes.contains(&"6100") && !codes.contains(&"1000"),
"{codes:?}"
);
}
/// A session with `books`, settings set from `toml` first, initialized.
fn served(books: &Books, toml: &str) -> Mcp {
books.settle(toml);
let mut mcp = Mcp::spawn(books);
init(&mut mcp);
mcp
}
/// Post one balanced two-line entry.
fn post_pair(mcp: &mut Mcp, date: &str, dr: &str, cr: &str, amount: &str) -> u64 {
let e = mcp.call(
"books.draft_entry",
json!({"date": date, "description": "x", "no_party_reason": "test"}),
);
let id = e["entry_id"].as_u64().unwrap();
for (account, side) in [(dr, "debit"), (cr, "credit")] {
mcp.call(
"books.add_line",
json!({"entry_id": id, "account": account, "side": side, "amount": amount}),
);
}
mcp.call(
"books.post_entry",
json!({"entry_id": id, "confirmed": true}),
);
id
}
/// Defect 16 (rules.md B-30): one default period everywhere. On books with
/// a July fiscal year and a first entry in the fiscal year before, every
/// period report given only an end covers the fiscal year that holds it
/// (2025-07-01 through 2026-03-31, never the first entry's day, never
/// 0001-01-01): profit and loss, by class, cash flow, and the exported tax
/// summary, members' equity, general ledger and general journal (csv and
/// json, whose entries are the period's); the CPA package as of that day
/// covers the same. With no dates at all, every one of them returns the
/// same pair, today's fiscal year to date (the clock read before and
/// after, so midnight can't fail it).
#[test]
fn one_default_period_everywhere_defect_16() {
let books = Books::new("one-default");
let mut mcp = served(
&books,
"[company]\nname = \"Fernhill Labs LLC\"\nfiscal_year_start = \"07-01\"\ntax_classification = \"partnership\"\n[[company.members]]\nname = \"A\"\nshare = \"50%\"\ncapital_account = \"3000\"\n[[company.members]]\nname = \"B\"\nshare = \"50%\"\ncapital_account = \"3001\"\n[books]\nreceipt_threshold = \"1000000.00\"\ncash_accounts = [\"1000\"]\n",
);
mcp.call(
"books.import_chart_csv",
json!({"csv_text": "code,name,type,parent_code,tax_category\n1000,Cash,asset,,\n3000,Capital A,equity,,\n3001,Capital B,equity,,\n4000,Sales,revenue,,gross_receipts\n6000,Software,expense,,other_deductions\n"}),
);
let first = post_pair(&mut mcp, "2025-03-15", "1000", "3000", "500.00");
let mid = post_pair(&mut mcp, "2025-09-10", "1000", "4000", "120.00");
let late = post_pair(&mut mcp, "2026-02-01", "6000", "1000", "20.00");
let _after = post_pair(&mut mcp, "2026-05-01", "1000", "4000", "7.00");
let end = json!({"end": "2026-03-31"});
let pair = |v: &Value| {
(
v["start"].as_str().unwrap().to_string(),
v["end"].as_str().unwrap().to_string(),
)
};
let want = ("2025-07-01".to_string(), "2026-03-31".to_string());
let pl = mcp.call("books.profit_and_loss", end.clone());
assert_eq!(pair(&pl), want);
assert_eq!(pl["revenue"], "120.00");
assert_eq!(pl["expenses"], "20.00");
assert_eq!(
pair(&mcp.call("books.profit_and_loss_by_class", end.clone())),
want
);
assert_eq!(pair(&mcp.call("books.cash_flow", end.clone())), want);
for report in ["tax_summary", "members_equity", "general_ledger"] {
let out = mcp.call(
"books.export",
json!({"report": report, "format": "json", "end": "2026-03-31"}),
);
let v: Value = serde_json::from_str(out["content"].as_str().unwrap()).unwrap();
assert_eq!(pair(&v), want, "{report}");
}
// The general journal: the period's entries, in json and csv alike.
let out = mcp.call(
"books.export",
json!({"report": "general_journal", "format": "json", "end": "2026-03-31"}),
);
let v: Value = serde_json::from_str(out["content"].as_str().unwrap()).unwrap();
let ids: Vec<u64> = v
.as_array()
.unwrap()
.iter()
.map(|e| e["entry_id"].as_u64().unwrap())
.collect();
assert_eq!(ids, [mid, late], "not {first}, not after the end");
let csv = mcp.call(
"books.export",
json!({"report": "journal", "format": "csv", "end": "2026-03-31"}),
);
let rows = csv["content"].as_str().unwrap().lines().count();
assert_eq!(rows, 1 + 4, "header and two entries of two lines");
// An inverted range is refused by the journal too (review R1).
let err = mcp.call_err(
"books.export",
json!({"report": "general_journal", "format": "json", "start": "2026-12-31", "end": "2026-01-01"}),
);
assert!(err.contains("is before start"), "{err}");
// The CPA package as of that day: the same fiscal year to date.
let pkg = mcp.call(
"books.export_cpa_package",
json!({"as_of": "2026-03-31", "include_receipts": false, "include_tins": false}),
);
let mut zip = zip::ZipArchive::new(std::io::Cursor::new(mcp.handed(&pkg["file"]))).unwrap();
let mut readme = String::new();
std::io::Read::read_to_string(&mut zip.by_name("README.txt").unwrap(), &mut readme).unwrap();
assert!(
readme.contains("Fiscal year to date: 2025-07-01 through 2026-03-31."),
"{readme}"
);
// No dates: the same pair from every period report.
let today = || ledger_core::settings::today_in(None);
let before = today();
let mut got = vec![
pair(&mcp.call("books.profit_and_loss", json!({}))),
pair(&mcp.call("books.profit_and_loss_by_class", json!({}))),
pair(&mcp.call("books.cash_flow", json!({}))),
];
for report in ["tax_summary", "members_equity", "general_ledger"] {
let out = mcp.call("books.export", json!({"report": report, "format": "json"}));
let v: Value = serde_json::from_str(out["content"].as_str().unwrap()).unwrap();
got.push(pair(&v));
}
let after = today();
let fy = |t: chrono::NaiveDate| {
let c = ledger_core::settings::Company {
fiscal_year_start: (7, 1),
..Default::default()
};
let (s, _) = ledger_core::calendar::period_defaults(&c, t).statement_range();
(s.to_string(), t.to_string())
};
for p in &got {
assert!(*p == fy(before) || *p == fy(after), "{p:?}");
}
assert!(
got.iter().all(|p| p == &got[0]) || before != after,
"{got:?}"
);
}
/// The entry ids (DOCNUM) of an IIF file's transactions, in file order.
fn iif_docnums(iif: &str) -> Vec<u64> {
let mut docnum = None;
let mut ids = Vec::new();
for line in iif.split("\r\n") {
let f: Vec<&str> = line.split('\t').collect();
match f[0] {
"!TRNS" => docnum = f.iter().position(|c| *c == "DOCNUM"),
"TRNS" => ids.push(f[docnum.expect("!TRNS before TRNS")].parse().unwrap()),
_ => {}
}
}
ids
}
/// The default period (rules.md B-30) against the IIF (B-34, D-36) and
/// the CPA package (B-33): a general journal asked for without dates is
/// the fiscal year to date, but the IIF is never a period. Without dates
/// it holds every posted entry from the first through today, under
/// either name of the report, and a start or end is refused; the
/// package's IIF holds every entry from the first through its as_of, the
/// one before its fiscal year included.
#[test]
fn the_default_period_never_shortens_the_iif_or_the_package() {
let books = Books::new("default-vs-iif");
let mut mcp = served(
&books,
"[company]\nname = \"Fernhill Labs LLC\"\nfiscal_year_start = \"07-01\"\n[books]\nreceipt_threshold = \"1000000.00\"\ncash_accounts = [\"1000\"]\n",
);
mcp.call(
"books.import_chart_csv",
json!({"csv_text": "code,name,type,parent_code,tax_category\n1000,Cash,asset,,\n3000,Capital,equity,,\n4000,Sales,revenue,,gross_receipts\n6000,Software,expense,,other_deductions\n"}),
);
let entries = [
("2025-03-15", "1000", "3000", "500.00"),
("2025-09-10", "1000", "4000", "120.00"),
("2026-02-01", "6000", "1000", "20.00"),
("2026-05-01", "1000", "4000", "7.00"),
]
.map(|(date, dr, cr, amount)| (date, post_pair(&mut mcp, date, dr, cr, amount)));
let every: Vec<u64> = entries.iter().map(|(_, id)| *id).collect();
let today = || ledger_core::settings::today_in(None);
let before = today();
// The general journal without dates: the fiscal year to date.
let out = mcp.call(
"books.export",
json!({"report": "general_journal", "format": "json"}),
);
let after = today();
let v: Value = serde_json::from_str(out["content"].as_str().unwrap()).unwrap();
let journal: Vec<u64> = v
.as_array()
.unwrap()
.iter()
.map(|e| e["entry_id"].as_u64().unwrap())
.collect();
let fytd = |t: chrono::NaiveDate| -> Vec<u64> {
let c = ledger_core::settings::Company {
fiscal_year_start: (7, 1),
..Default::default()
};
let (start, end) = ledger_core::calendar::period_defaults(&c, t).statement_range();
entries
.iter()
.filter(|(date, _)| {
let d = chrono::NaiveDate::parse_from_str(date, "%Y-%m-%d").unwrap();
start <= d && d <= end
})
.map(|(_, id)| *id)
.collect()
};
assert!(
journal == fytd(before) || journal == fytd(after),
"{journal:?}"
);
assert_ne!(journal, every, "the journal's default is a period");
// The IIF without dates: every posted entry through today, by either
// name of the report.
for report in ["general_journal", "journal", "GENERAL_JOURNAL"] {
let iif = mcp.call("books.export", json!({"report": report, "format": "iif"}));
assert_eq!(iif["report"], report.to_lowercase());
assert_eq!(
iif_docnums(iif["content"].as_str().unwrap()),
every,
"{report}"
);
}
for dates in [json!({"start": "2025-07-01"}), json!({"end": "2026-03-31"})] {
let mut args = json!({"report": "general_journal", "format": "iif"});
args.as_object_mut()
.unwrap()
.extend(dates.as_object().unwrap().clone());
let err = mcp.call_err("books.export", args);
assert!(err.contains("iif takes as_of, not start or end"), "{err}");
}
// The package as of 2026-03-31: its statements' fiscal year starts
// 2025-07-01, and its IIF still holds the entry before it.
let pkg = mcp.call(
"books.export_cpa_package",
json!({"as_of": "2026-03-31", "include_receipts": false, "include_tins": false}),
);
let mut zip = zip::ZipArchive::new(std::io::Cursor::new(mcp.handed(&pkg["file"]))).unwrap();
let mut read = |name: &str| {
let mut text = String::new();
std::io::Read::read_to_string(&mut zip.by_name(name).unwrap(), &mut text).unwrap();
text
};
let readme = read("README.txt");
assert!(
readme.contains("Fiscal year to date: 2025-07-01 through 2026-03-31."),
"{readme}"
);
assert_eq!(iif_docnums(&read("general-journal.iif")), every[..3]);
}
/// Defect 15 (rules.md B-31) over the real binary: books.rename_account
/// gives an account a new name and nothing else. The chart shows it; a
/// past period's P&L export prints it; the general journal's IIF declares
/// the account by it; the books verify, the journal grew by the rename
/// and its feature's declaration only; refusals (a blank name, the name
/// it has, a sibling's ignoring capitals, nine digits) write nothing; and
/// a restarted server reads the new name back.
#[test]
fn rename_account_end_to_end_defect_15() {
let books = Books::new("rename-e2e");
let mut mcp = served(
&books,
"[company]\nname = \"Fernhill Labs LLC\"\n[books]\nreceipt_threshold = \"1000000.00\"\ncash_accounts = [\"1000\"]\n",
);
mcp.call(
"books.import_chart_csv",
json!({"csv_text": "code,name,type,parent_code,tax_category\n1000,Business Checking,asset,,\n3000,Capital,equity,,\n6000,Operating,expense,,other_deductions\n6150,Software,expense,6000,other_deductions\n6160,Hosting,expense,6000,other_deductions\n"}),
);
post_pair(&mut mcp, "2025-11-04", "1000", "3000", "800.00");
post_pair(&mut mcp, "2025-11-20", "6150", "1000", "29.00");
let verified = mcp.call("books.verify", json!({"blobs": false}));
let events = verified["events"].as_u64().unwrap();
for (name, says) in [
("", "must not be empty"),
(" Software ", "already named"),
("HOSTING", "same parent"),
("Card 1234 5678 9", "nine digits"),
] {
let err = mcp.call_err(
"books.rename_account",
json!({"account": "6150", "name": name}),
);
assert!(err.contains(says), "{name:?}: {err}");
}
assert_eq!(
mcp.call("books.verify", json!({"blobs": false}))["events"],
json!(events),
"refusals write nothing"
);
let out = mcp.call(
"books.rename_account",
json!({"account": "6150", "name": "Software subscriptions", "reason": "owner asked"}),
);
assert!(out["id"].as_u64().unwrap() > 0);
assert_eq!(out["code"], "6150");
assert_eq!(out["previous_name"], "Software");
assert_eq!(out["name"], "Software subscriptions");
let notes = out["notes"].to_string();
assert!(
notes.contains("QuickBooks matches accounts by name"),
"{notes}"
);
let after = mcp.call("books.verify", json!({"blobs": false}));
assert_eq!(after["status"], "ok");
// New books declare account_renamed (with the rest of this version's
// format) as they are created: the rename journals only itself.
assert_eq!(after["events"], json!(events + 1), "the rename");
let chart = mcp.call("books.chart", json!({}));
let acct = chart["accounts"]
.as_array()
.unwrap()
.iter()
.find(|a| a["code"] == "6150")
.unwrap()
.clone();
assert_eq!(acct["name"], "Software subscriptions");
assert_eq!(acct["balance"], "29.00");
assert_eq!(acct["tax_category"], "other_deductions");
// A past period prints the new name; the amounts are the same.
let out = mcp.call(
"books.export",
json!({"report": "profit_and_loss", "format": "json", "start": "2025-11-01", "end": "2025-11-30"}),
);
let pl: Value = serde_json::from_str(out["content"].as_str().unwrap()).unwrap();
assert_eq!(pl["expense_accounts"][0]["name"], "Software subscriptions");
assert_eq!(pl["expense_accounts"][0]["amount"], "29.00");
let iif = mcp.call(
"books.export",
json!({"report": "journal", "format": "iif"}),
);
let iif = iif["content"].as_str().unwrap();
assert!(iif.contains("Operating:Software subscriptions"), "{iif}");
// A renamed parent: its children's full names follow, and the note
// says so.
let out = mcp.call(
"books.rename_account",
json!({"account": "6000", "name": "Operating expenses"}),
);
assert!(out["notes"].to_string().contains("2 sub-accounts"), "{out}");
// Renaming the bank account to a name without "checking" or "bank"
// moves nothing: books.cash_accounts names it by code.
let out = mcp.call(
"books.rename_account",
json!({"account": "1000", "name": "Treasury"}),
);
assert!(!out["notes"].to_string().contains("cash account"), "{out}");
drop(mcp);
books.restart();
let mut mcp = Mcp::spawn(&books);
init(&mut mcp);
let chart = mcp.call("books.chart", json!({}));
let names: Vec<(String, String)> = chart["accounts"]
.as_array()
.unwrap()
.iter()
.map(|a| {
(
a["code"].as_str().unwrap().to_string(),
a["name"].as_str().unwrap().to_string(),
)
})
.collect();
for want in [
("6150", "Software subscriptions"),
("6000", "Operating expenses"),
("1000", "Treasury"),
] {
assert!(
names.contains(&(want.0.to_string(), want.1.to_string())),
"{names:?}"
);
}
assert_eq!(
mcp.call("books.verify", json!({"blobs": false}))["status"],
"ok"
);
}
/// Review R10 (rules.md B-31): on books that name no cash accounts
/// (books.cash_accounts unset, so they are picked by name), a rename that
/// moves an account into or out of the cash or card accounts says so; one
/// that moves nothing says nothing of them. A convention naming the old
/// name is listed, and one that doesn't isn't.
#[test]
fn rename_says_what_the_new_name_moves_defect_15() {
let books = Books::new("rename-sets");
let mut mcp = served(
&books,
"[company]\nname = \"Fernhill Labs LLC\"\n[books]\nreceipt_threshold = \"1000000.00\"\n",
);
mcp.call(
"books.import_chart_csv",
json!({"csv_text": "code,name,type,parent_code,tax_category\n1000,Business Checking,asset,,\n1010,Petty Fund,asset,,\n2100,Visa,liability,,\n2200,Credit Card,liability,,\n6000,Software,expense,,other_deductions\n"}),
);
mcp.call(
"books.set_convention",
json!({"id": "software-note", "kind": "note", "title": "Subscriptions",
"body_md": "Every SaaS bill goes to Software.", "decided_by": "A"}),
);
let notes = |out: &Value| out["notes"].to_string();
for (account, name, says) in [
(
"1000",
"Treasury",
"1000 Treasury is no longer a cash account",
),
(
"1010",
"Petty Cash",
"1010 Petty Cash is now a cash account",
),
("2100", "Visa Card", "2100 Visa Card is now a card account"),
("2200", "Amex", "2200 Amex is no longer a card account"),
] {
let out = mcp.call(
"books.rename_account",
json!({"account": account, "name": name}),
);
assert!(notes(&out).contains(says), "{name}: {out}");
}
let out = mcp.call(
"books.rename_account",
json!({"account": "1010", "name": "Petty Cash Box"}),
);
assert!(!notes(&out).contains("cash account"), "{out}");
let out = mcp.call(
"books.rename_account",
json!({"account": "6000", "name": "Software subscriptions"}),
);
assert!(
notes(&out).contains("Convention software-note names \u{201c}Software\u{201d}"),
"{out}"
);
let out = mcp.call(
"books.rename_account",
json!({"account": "6000", "name": "Tools"}),
);
assert!(!notes(&out).contains("Convention"), "{out}");
}
/// Defect 9 end to end (plan/served.md D-11, D-37): a schedule created
/// without a convention spreads by the day; books.list_schedules says how,
/// the first and last day covered, the next month to post, and what
/// remains; a run posts by the day; with books.schedule_convention set to
/// monthly_even, new schedules are equal months, and one that doesn't
/// start on the 1st is refused. Hand check: 1,200.00 from 2026-03-15 for
/// 12 months: March 100 × 17/31 = 54.84, then 100.00 a month, the last
/// March 45.16; through April 30, 154.84 posted, 1,045.16 left.
#[test]
fn mcp_daily_schedule() {
let books = Books::new("daily-schedule");
let mut mcp = Mcp::spawn(&books);
init(&mut mcp);
for (code, name, typ) in [
("1300", "Prepaid Expenses", "asset"),
("6300", "Insurance", "expense"),
] {
mcp.call(
"books.add_account",
json!({"code": code, "name": name, "account_type": typ}),
);
}
let create = |start: &str| {
json!({
"kind": "prepaid_expense",
"description": "Liability insurance",
"total": "1200.00",
"start": start,
"months": 12,
"balance_account": "1300",
"pl_account": "6300",
})
};
let s = mcp.call("books.create_schedule", create("2026-03-15"));
assert_eq!(s["convention"], "daily");
assert_eq!(s["start"], "2026-03-15");
assert_eq!(s["end"], "2027-03-14");
assert_eq!(s["next"], "2026-03-31");
assert_eq!(s["remaining"], "1200.00");
let months = s["months"].as_array().unwrap();
assert_eq!(months.len(), 13);
assert_eq!(months[0]["amount"], "54.84");
assert_eq!(months[1]["amount"], "100.00");
assert_eq!(months[12]["date"], "2027-03-31");
assert_eq!(months[12]["amount"], "45.16");
let preview = mcp.call("books.run_schedules", json!({"through": "2026-04-30"}));
assert_eq!(preview["status"], "preview");
let amounts: Vec<&str> = preview["postings"]
.as_array()
.unwrap()
.iter()
.map(|p| p["amount"].as_str().unwrap())
.collect();
assert_eq!(amounts, ["54.84", "100.00"]);
assert_eq!(preview["skipped"], json!([]));
let run = mcp.call(
"books.run_schedules",
json!({"through": "2026-04-30", "confirmed": true}),
);
assert_eq!(run["postings"].as_array().unwrap().len(), 2);
let list = mcp.call("books.list_schedules", json!({}));
let one = &list["schedules"][0];
assert_eq!(one["convention"], "daily");
assert_eq!(one["next"], "2026-05-31");
assert_eq!(one["remaining"], "1045.16");
// New schedules are set to equal months.
mcp.call(
"books.set_setting",
json!({"changes": [{"key": "books.schedule_convention", "value": "monthly_even"}],
"decided_by": "Owner", "decided_on": "2026-10-20"}),
);
let err = mcp.call_err("books.create_schedule", create("2026-03-15"));
assert!(err.contains("2026-03-01"), "{err}");
let even = mcp.call("books.create_schedule", create("2026-03-01"));
assert_eq!(even["convention"], "monthly_even");
assert_eq!(even["months"].as_array().unwrap().len(), 12);
assert_eq!(even["end"], "2027-02-28");
// A convention asked for wins over the setting; an unknown one is
// refused, naming the two.
let daily = mcp.call(
"books.create_schedule",
json!({"kind": "prepaid_expense", "description": "x", "total": "10.00",
"start": "2026-05-20", "months": 1, "balance_account": "1300",
"pl_account": "6300", "convention": "daily"}),
);
assert_eq!(daily["convention"], "daily");
let err = mcp.call_err(
"books.create_schedule",
json!({"kind": "prepaid_expense", "description": "x", "total": "10.00",
"start": "2026-05-01", "months": 1, "balance_account": "1300",
"pl_account": "6300", "convention": "weekly"}),
);
assert!(err.contains("daily or monthly_even"), "{err}");
}