P3: rename accounts, one set of period defaults, tax categories warn or refuse (defects 15, 16, 8)
check / check (push) Canceled after 0s

books.rename_account; account names trimmed, bounded, refused when a
sibling's or TIN-shaped; books.show refuses what its target ignores. A
report asked for without dates covers this fiscal year so far, everywhere
(one rule for tools, descriptions and the viewer; the IIF and CPA package
keep their own dates). policy.require_tax_category warns or refuses; a
Form 4797 gain is shown apart on every return.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01T3gXMVt7XJPi6ens1or9Dj
This commit is contained in:
swaitsandClaude Opus 5.5 committed 2026-10-21 10:41:24 -06:00
1 parent 8255ac2103
commit 9ab003d0d1
69 files changed
+8063 -570

No files matched your search

+159
View File
@@ -1553,3 +1553,162 @@ fn old_book_answers_every_tool(dir: &Path, per_tool: usize) -> Vec<String> {
let _ = std::fs::remove_dir_all(&p.base);
failures
}
/// P3 (defects 8, 15, 16): renames, refused report ranges and tax
/// categories at creation, at their edges, are each answered (a result or
/// a refusal, never a stop), and the books reopen after them. A new name
/// is one line of at most 200 characters (ledger_core::account::
/// MAX_ACCOUNT_NAME): 200 is taken and read back whole; 201, a
/// 100,000-character one, a line break or a tab is refused.
#[test]
fn hostile_renames_ranges_and_categories_are_answered() {
let p = places("p3");
let mut m = Mcp::start(&p);
fixture(&mut m, &p);
let refused = |r: &Value| r["isError"] == json!(true);
for (tool, args, refuse) in [
(
"books.rename_account",
json!({"account": "6300", "name": ""}),
true,
),
(
"books.rename_account",
json!({"account": "6300", "name": " "}),
true,
),
(
"books.rename_account",
json!({"account": "9999", "name": "X"}),
true,
),
(
"books.rename_account",
json!({"account": "6300", "name": "insurance"}),
false,
),
(
"books.rename_account",
json!({"account": "6300", "name": "Contract labor"}),
true,
),
(
"books.rename_account",
json!({"account": "6300", "name": "Policy 123456789"}),
true,
),
(
"books.rename_account",
json!({"account": "6300", "name": "Policy ٠١٢٣٤٥٦٧٨"}),
true,
),
(
"books.rename_account",
json!({"account": "6300", "name": "Risk", "reason": "x".repeat(2_001)}),
true,
),
(
"books.rename_account",
json!({"account": "6300", "name": "Risk", "reason": "\u{0}"}),
true,
),
(
"books.show",
json!({"account": "1000", "start": "9999-12-31"}),
true,
),
(
"books.show",
json!({"report": "profit_and_loss", "start": "9999-12-31"}),
true,
),
(
"books.show",
json!({"report": "profit_and_loss", "end": "0001-01-01"}),
false,
),
(
"books.show",
json!({"report": "trial_balance", "end": "9999-12-31"}),
false,
),
(
"books.profit_and_loss",
json!({"start": "9999-12-31"}),
true,
),
("books.profit_and_loss", json!({"end": "0001-01-01"}), false),
(
"books.profit_and_loss_by_class",
json!({"start": "2026-12-31", "end": "2026-01-01"}),
true,
),
(
"books.cash_flow",
json!({"start": "9999-12-31", "cash_accounts": ["1000"]}),
true,
),
("books.ar_aging", json!({}), false),
("books.ap_aging", json!({"as_of": "9999-12-31"}), false),
(
"books.add_account",
json!({"code": "4100", "name": "Fees", "account_type": "revenue", "tax_category": "meals"}),
true,
),
(
"books.add_account",
json!({"code": "4101", "name": "Fees", "account_type": "revenue", "deductible_percent": "1e9"}),
true,
),
(
"books.rename_account",
json!({"account": "6300", "name": "N".repeat(201)}),
true,
),
(
"books.rename_account",
json!({"account": "6300", "name": "N".repeat(100_000)}),
true,
),
(
"books.rename_account",
json!({"account": "6300", "name": "Risk\nPolicy"}),
true,
),
(
"books.rename_account",
json!({"account": "6300", "name": "Risk\tPolicy"}),
true,
),
(
"books.add_account",
json!({"code": "6310", "name": "X".repeat(201), "account_type": "expense"}),
true,
),
(
"books.add_account",
json!({"code": "6311", "name": "Two\nlines", "account_type": "expense"}),
true,
),
] {
let r = pinned(&mut m, tool, args.clone());
assert_eq!(refused(&r), refuse, "{tool} {}: {r}", truncated(&args));
}
// A name of 200 characters: taken, and read back whole.
let long = "N".repeat(200);
let r = pinned(
&mut m,
"books.rename_account",
json!({"account": "1000", "name": long}),
);
assert!(!refused(&r), "{}", truncated(&r));
let chart = m.ok("books.chart", json!({}));
assert!(
chart["accounts"]
.as_array()
.unwrap()
.iter()
.any(|a| a["code"] == "1000" && a["name"].as_str().unwrap() == long)
);
reopens(m, &p, "P3's hostile inputs");
}
+467 -6
View File
@@ -178,7 +178,7 @@ fn mcp_end_to_end_over_stdio() {
// Tool listing: the narrow tools; chart is read-only.
let list = mcp.request("tools/list", json!({}));
let tools = list["result"]["tools"].as_array().unwrap();
assert_eq!(tools.len(), 108, "expected 108 tools, got {}", tools.len());
assert_eq!(tools.len(), 109, "expected 109 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"));
@@ -309,7 +309,7 @@ fn mcp_end_to_end_over_stdio() {
// Export works.
let export = mcp.call(
"books.export",
json!({"format": "csv", "report": "journal"}),
json!({"format": "csv", "report": "journal", "start": "2026-01-01", "end": "2026-12-31"}),
);
assert!(export["content"].as_str().unwrap().contains("January rent"));
@@ -1445,10 +1445,15 @@ fn mcp_period_statements_over_stdio() {
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: the statement begins at the first entry, not year 1.
let all = mcp.call("books.profit_and_loss", json!({"end": "2026-02-28"}));
assert_eq!(all["start"], "2026-01-02");
assert_eq!(all["revenue"], "6.50");
// 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"}));
@@ -2775,3 +2780,459 @@ fn renumbering_an_account_keeps_its_history() {
"{codes:?}"
);
}
/// A server on books whose settings come from `toml`, initialized.
fn served(dir: &std::path::Path, toml: &str) -> Mcp {
let path = dir.join("wanderbooks.toml");
std::fs::write(&path, toml).unwrap();
let mut mcp = Mcp::spawn_with(
&dir.join("books-data"),
&[std::ffi::OsStr::new("--books-toml"), path.as_os_str()],
);
init(&mut mcp);
mcp
}
/// Post one balanced two-line entry over stdio.
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 dir = test_dir("one-default");
let mut mcp = served(
&dir,
"[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(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 dir = test_dir("default-vs-iif");
let mut mcp = served(
&dir,
"[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(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 dir = test_dir("rename-e2e");
let mut mcp = served(
&dir,
"[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");
assert_eq!(
after["events"],
json!(events + 2),
"the feature and 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);
let mut mcp = Mcp::spawn(&dir.join("books-data"));
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 dir = test_dir("rename-sets");
let mut mcp = served(
&dir,
"[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}");
}
+289 -17
View File
@@ -1092,8 +1092,8 @@ fn cash_flow_exports_equal_the_core_statement() {
assert_eq!(tool, j);
}
/// The journal export, CSV and JSON, is every posted entry line by line
/// (no drafts), as the ledger holds them. Kills: export_journal's
/// The journal export, CSV and JSON, over every date, is every posted
/// entry line by line (no drafts), as the ledger holds them. Kills: export_journal's
/// json/csv switch.
#[test]
fn journal_exports_hold_every_posted_line() {
@@ -1106,7 +1106,10 @@ fn journal_exports_hold_every_posted_line() {
let mut m = Mcp::start(&dir, "1000000.00");
for report in ["journal", "general_journal"] {
let csv = m.export_text(json!({"format": "csv", "report": report}));
// Every date: the journal covers a period (rules.md B-30).
let csv = m.export_text(
json!({"format": "csv", "report": report, "start": "0001-01-01", "end": "9999-12-31"}),
);
let rows = csv_rows(
&csv,
&[
@@ -1148,9 +1151,10 @@ fn journal_exports_hold_every_posted_line() {
}
}
let j: Value =
serde_json::from_str(&m.export_text(json!({"format": "json", "report": "journal"})))
.unwrap();
let j: Value = serde_json::from_str(&m.export_text(
json!({"format": "json", "report": "journal", "start": "0001-01-01", "end": "9999-12-31"}),
))
.unwrap();
let got = j.as_array().unwrap();
assert_eq!(got.len(), posted.len(), "a JSON entry per posted entry");
for (g, e) in got.iter().zip(&posted) {
@@ -1292,6 +1296,17 @@ fn ledger_tax_and_equity_exports_equal_the_core_reports() {
]);
}
}
// Book amounts only: Form 4797 figures the tax gain.
for l in &ts.form_4797 {
want.push(vec![
"form_4797".into(),
l.category.clone(),
l.label.clone(),
l.accounts.join("; "),
l.book.to_string(),
String::new(),
]);
}
for u in &ts.uncategorized {
want.push(vec![
"uncategorized".into(),
@@ -2287,11 +2302,15 @@ fn show_writes_the_viewer_command_for_each_target() {
}
);
// A split, or one total column compared (the viewer compares only a
// report in one total column: defect 15, options a target ignores are
// refused).
for (columns, compare) in [
("month", "prior_year"),
("quarter", "prior_period"),
("year", "prior_year"),
("total", "prior_period"),
(Some("month"), None),
(Some("quarter"), None),
(Some("year"), None),
(Some("total"), Some("prior_period")),
(None, Some("prior_year")),
] {
let out = m.ok(
"books.show",
@@ -2304,16 +2323,25 @@ fn show_writes_the_viewer_command_for_each_target() {
report: "profit_and_loss".into(),
start: Some("2025-01-01".into()),
end: Some("2025-12-31".into()),
columns: Some(columns.into()),
compare: Some(compare.into()),
columns: columns.map(Into::into),
compare: compare.map(Into::into),
}
);
if columns != "total" {
assert!(
out["shown"].as_str().unwrap().contains(columns),
"shown names the {columns} columns: {out}"
);
let shown = out["shown"].as_str().unwrap();
assert!(shown.contains("2025-01-01 to 2025-12-31"), "{out}");
if let Some(c) = columns.filter(|c| *c != "total") {
assert!(shown.contains(&format!("by {c}")), "{out}");
}
if let Some(c) = compare {
assert!(shown.contains(&c.replace('_', " ")), "{out}");
}
}
for (columns, compare) in [("month", "prior_year"), ("quarter", "prior_period")] {
let e = m.refuse(
"books.show",
json!({"report": "profit_and_loss", "columns": columns, "compare": compare}),
);
assert!(e.contains("one total column"), "{e}");
}
m.refuse(
"books.show",
@@ -2370,6 +2398,250 @@ fn show_writes_the_viewer_command_for_each_target() {
m.refuse("books.show", json!({}));
}
/// The command `books.show` last wrote, or None before any.
fn last_show(m: &Mcp) -> Option<ledger_store::ops::ViewerCommand> {
let books = m.run.parent().unwrap().to_path_buf();
ledger_store::ops::read(&data_dir(&books))
}
/// Defect 15, repros 1.1.1 and 1.1.2: a start after the end, given, is
/// refused for a report and for an account, and nothing reaches the
/// viewer (the last command it was sent stays the last). A start equal to
/// the end is a one-day period.
#[test]
fn show_refuses_start_after_end_defect_15() {
let dir = test_dir("show-inverted");
drop(new_store(&dir));
let mut m = Mcp::start(&dir, "1000000.00");
m.ok("books.show", json!({"page": "overview"}));
let before = last_show(&m).unwrap();
for args in [
json!({"report": "profit_and_loss", "start": "2026-12-31", "end": "2026-01-01"}),
json!({"account": "1000", "start": "2026-12-31", "end": "2026-01-01"}),
json!({"report": "cash_flow", "start": "2026-01-02", "end": "2026-01-01"}),
json!({"report": "balance_sheet", "columns": "month", "start": "2026-03-01", "end": "2026-02-28"}),
] {
let e = m.refuse("books.show", args.clone());
assert!(e.contains("is before start"), "{args}: {e}");
assert_eq!(last_show(&m), Some(before.clone()), "{args}");
}
let out = m.ok(
"books.show",
json!({"report": "profit_and_loss", "start": "2026-03-01", "end": "2026-03-01"}),
);
assert!(
out["shown"]
.as_str()
.unwrap()
.contains("2026-03-01 to 2026-03-01"),
"{out}"
);
}
/// Defect 15, repro 1.1.3: a start after today with no end (the end an
/// omitted end means) is refused, saying the end was defaulted; nothing
/// reaches the viewer.
#[test]
fn show_refuses_a_derived_inverted_range_defect_15() {
let dir = test_dir("show-derived");
drop(new_store(&dir));
let mut m = Mcp::start(&dir, "1000000.00");
m.ok("books.show", json!({"page": "journal"}));
let before = last_show(&m).unwrap();
for args in [
json!({"account": "1000", "start": "9999-12-31"}),
json!({"report": "general_ledger", "start": "9999-01-01"}),
] {
let e = m.refuse("books.show", args.clone());
assert!(
e.contains("defaults to today") && e.contains("pass end"),
"{args}: {e}"
);
assert_eq!(last_show(&m), Some(before.clone()), "{args}");
}
}
/// Defect 16, repro 1.1.4: books.show sends the dates it names, resolved
/// by the one default (rules.md B-30): only an end covers that end's
/// fiscal year to it (2025-01-01 to 2025-06-30, never today's year's
/// start); no dates cover today's fiscal year to date (checked against
/// the clock read before and after, R14); a report as of a day says "as
/// of", vendor_1099 "for" its year; and the viewer gets the same pair.
#[test]
fn show_sends_the_dates_it_names_defect_16() {
let dir = test_dir("show-dates");
drop(new_store(&dir));
let mut m = Mcp::start(&dir, "1000000.00");
let target = |m: &Mcp| match last_show(m).unwrap().target {
ViewerTarget::Report { start, end, .. } | ViewerTarget::Account { start, end, .. } => {
(start.unwrap(), end.unwrap())
}
other => panic!("not dated: {other:?}"),
};
for (args, shown, dates) in [
(
json!({"report": "profit_and_loss", "end": "2025-06-30"}),
"the profit and loss report, 2025-01-01 to 2025-06-30",
("2025-01-01", "2025-06-30"),
),
(
json!({"account": "1000", "end": "2025-06-30"}),
"the register of 1000 Bank, 2025-01-01 to 2025-06-30",
("2025-01-01", "2025-06-30"),
),
(
json!({"report": "profit_and_loss", "start": "2025-03-01", "end": "2025-03-31"}),
"the profit and loss report, 2025-03-01 to 2025-03-31",
("2025-03-01", "2025-03-31"),
),
(
json!({"report": "trial_balance", "end": "2025-06-30"}),
"the trial balance report, as of 2025-06-30",
("2025-01-01", "2025-06-30"),
),
(
json!({"report": "balance_sheet", "end": "2025-06-30"}),
"the balance sheet report, as of 2025-06-30",
("2025-01-01", "2025-06-30"),
),
(
json!({"report": "balance_sheet", "end": "2025-06-30", "columns": "quarter"}),
"the balance sheet report, 2025-01-01 to 2025-06-30, by quarter",
("2025-01-01", "2025-06-30"),
),
(
json!({"report": "fixed_asset_register", "end": "2024-12-31"}),
"the fixed asset register report, as of 2024-12-31",
("2024-01-01", "2024-12-31"),
),
(
json!({"report": "ar_aging", "end": "2025-02-15"}),
"the ar aging report, as of 2025-02-15",
("2025-01-01", "2025-02-15"),
),
(
json!({"report": "vendor_1099", "end": "2025-12-31"}),
"the vendor 1099 report, for 2025",
("2025-01-01", "2025-12-31"),
),
] {
let out = m.ok("books.show", args.clone());
assert_eq!(out["shown"], shown, "{args}");
assert_eq!(
target(&m),
(dates.0.to_string(), dates.1.to_string()),
"{args}"
);
}
// No dates: today's fiscal year to date, whichever day the call saw.
let before = ledger_core::settings::today_in(None);
let out = m.ok("books.show", json!({"report": "profit_and_loss"}));
let after = ledger_core::settings::today_in(None);
let (s, e) = target(&m);
let ok = [before, after].iter().any(|t| {
let (fy, _) = ledger_core::settings::Company::default().fiscal_year_containing(*t);
(s.clone(), e.clone()) == (fy.to_string(), t.to_string())
&& out["shown"] == format!("the profit and loss report, {fy} to {t}")
});
assert!(ok, "{s} to {e}: {out}");
}
/// Defect 15 (settled: books.show refuses options its target ignores):
/// dates, columns or a comparison for an entry, a page or any other
/// non-dated target; columns or a comparison for an account; columns for
/// a report that doesn't split; a comparison for one that doesn't compare;
/// a start for a report as of a day, or for the 1099 worksheet. Each names
/// what takes the option, and nothing reaches the viewer.
#[test]
fn show_refuses_options_its_target_ignores_defect_15() {
let dir = test_dir("show-options");
let mut s = new_store(&dir);
let e = post(
&mut s,
"2025-01-02",
"Seed",
&[("1000", Dr, "1.00"), ("4000", Cr, "1.00")],
);
drop(s);
let mut m = Mcp::start(&dir, "1000000.00");
m.ok("books.show", json!({"page": "overview"}));
let before = last_show(&m).unwrap();
for (args, says) in [
(
json!({"entry_id": e, "start": "2025-01-01"}),
"for an account or a report",
),
(
json!({"page": "reports", "end": "2025-01-31"}),
"for an account or a report",
),
(
json!({"search": "x", "columns": "month"}),
"for an account or a report",
),
(
json!({"entry_id": e, "start": "2025-01-01", "compare": "prior_year"}),
"start and compare are",
),
(
json!({"account": "1000", "columns": "month"}),
"takes only start and end",
),
(
json!({"account": "1000", "compare": "prior_year"}),
"takes only start and end",
),
(
json!({"report": "trial_balance", "columns": "total"}),
"columns is for profit_and_loss, balance_sheet and cash_flow",
),
(
json!({"report": "tax_summary", "columns": "month"}),
"columns is for",
),
(
json!({"report": "general_ledger", "compare": "prior_year"}),
"compare is for profit_and_loss, balance_sheet, cash_flow and tax_summary",
),
(
json!({"report": "trial_balance", "start": "2025-01-01", "end": "2025-03-31"}),
"is as of a day: pass end",
),
(
json!({"report": "balance_sheet", "start": "2025-01-01", "end": "2025-03-31"}),
"in one total column is as of a day",
),
(
json!({"report": "vendor_1099", "start": "2025-01-01"}),
"is for a calendar year",
),
// A split past the viewer's 240 columns, which it shows as the
// total: 312 months.
(
json!({"report": "profit_and_loss", "start": "2000-01-01", "end": "2025-12-31", "columns": "month"}),
"is 312 columns, and the viewer splits a report into at most 240",
),
(
json!({"report": "balance_sheet", "start": "0001-01-01", "end": "2025-12-31", "columns": "year"}),
"at most 240",
),
] {
let err = m.refuse("books.show", args.clone());
assert!(err.contains(says), "{args}: {err}");
assert_eq!(last_show(&m), Some(before.clone()), "{args}");
}
// What a balance sheet takes a start for: a split, or the prior period;
// and 240 months is a split the viewer shows.
for args in [
json!({"report": "balance_sheet", "start": "2025-01-01", "end": "2025-03-31", "columns": "month"}),
json!({"report": "balance_sheet", "start": "2025-01-01", "end": "2025-03-31", "compare": "prior_period"}),
json!({"report": "profit_and_loss", "start": "2006-01-01", "end": "2025-12-31", "columns": "month"}),
json!({"report": "profit_and_loss", "start": "0001-01-01", "end": "2025-12-31", "columns": "total"}),
] {
m.ok("books.show", args);
}
}
// ------------------------------------------------- resources
/// Each advertised resource reads back as the matching tool's result, and
+4
View File
@@ -746,6 +746,10 @@ fn happy_path(name: &str) -> Mcp {
"books.renumber_account",
json!({"account": "7000", "code": "7100"}),
);
m.ok(
"books.rename_account",
json!({"account": "7100", "name": "Gain or Loss on Sale", "reason": "clearer"}),
);
m.ok("books.settings", json!({}));
m.ok("books.roles", json!({}));
m.ok(
+451
View File
@@ -0,0 +1,451 @@
//! Tax categories at account creation, and `policy.require_tax_category`
//! (P3, defect 8; rules.md B-32), through the real server over stdio.
//!
//! ```text
//! GOAL: every revenue and expense account maps to a line of the return,
//! or the human hears that it doesn't; a company that refuses
//! uncategorized accounts gets exactly that, never a refused
//! invoice.
//! SOURCE: rules.md B-32; plan/served.md P3 defect 8 and "Settled in
//! design" (warn by default, headers exempt).
//! ```
//!
//! Fixtures are fictional (Fernhill Labs LLC).
use serde_json::{Value, json};
use std::io::{BufRead, BufReader, Write};
use std::process::{Child, ChildStdin, Command, Stdio};
struct Mcp {
stdin: ChildStdin,
stdout: BufReader<std::process::ChildStdout>,
next_id: u64,
child: Child,
}
impl Drop for Mcp {
fn drop(&mut self) {
let _ = self.child.kill();
let _ = self.child.wait();
}
}
impl Mcp {
/// A server on books that keep their own settings, initialized.
fn spawn(data_dir: &std::path::Path) -> Self {
let mut child = Command::new(env!("CARGO_BIN_EXE_ledger-mcp"))
.arg("--data-dir")
.arg(data_dir)
.stdin(Stdio::piped())
.stdout(Stdio::piped())
.stderr(Stdio::null())
.spawn()
.expect("spawn ledger-mcp");
let stdin = child.stdin.take().unwrap();
let stdout = BufReader::new(child.stdout.take().unwrap());
let mut m = Self {
stdin,
stdout,
next_id: 1,
child,
};
m.request(
"initialize",
json!({"protocolVersion": "2025-06-18", "capabilities": {},
"clientInfo": {"name": "spec-tax-policy", "version": "0"}}),
);
let msg = json!({"jsonrpc": "2.0", "method": "notifications/initialized", "params": {}});
writeln!(m.stdin, "{msg}").unwrap();
m.stdin.flush().unwrap();
m
}
fn request(&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});
writeln!(self.stdin, "{msg}").unwrap();
self.stdin.flush().unwrap();
let mut line = String::new();
self.stdout
.read_line(&mut line)
.expect("read response line");
let resp: Value = serde_json::from_str(&line).expect("parse response");
assert_eq!(resp["id"], id, "id mismatch in {resp}");
assert!(resp.get("error").is_none(), "{method}: {}", resp["error"]);
resp
}
fn result(&mut self, tool: &str, args: Value) -> Value {
let resp = self.request("tools/call", json!({"name": tool, "arguments": args}));
resp["result"].clone()
}
/// Call a tool; panic on a refusal. Returns structuredContent.
fn call(&mut self, tool: &str, args: Value) -> Value {
let result = self.result(tool, args);
assert_ne!(
result.get("isError"),
Some(&json!(true)),
"{tool}: {}",
result["content"]
);
result.get("structuredContent").cloned().unwrap_or_else(|| {
serde_json::from_str(result["content"][0]["text"].as_str().unwrap()).unwrap()
})
}
/// Call a tool expecting a refusal; returns its text.
fn call_err(&mut self, tool: &str, args: Value) -> String {
let result = self.result(tool, args);
assert_eq!(
result.get("isError"),
Some(&json!(true)),
"{tool}: {result}"
);
result["content"][0]["text"].as_str().unwrap().to_string()
}
fn events(&mut self) -> u64 {
self.call("books.verify", json!({"blobs": false}))["events"]
.as_u64()
.unwrap()
}
fn set_policy(&mut self, value: Value) {
self.call(
"books.set_setting",
json!({"changes": [{"key": "policy.require_tax_category", "value": value}],
"decided_by": "Tobin Harlow", "decided_on": "2026-10-20"}),
);
}
}
/// A test's dir, removed when the test ends, pass or panic.
struct TestDir(std::path::PathBuf);
impl Drop for TestDir {
fn drop(&mut self) {
let _ = std::fs::remove_dir_all(&self.0);
}
}
fn test_dir(name: &str) -> TestDir {
let dir = std::env::temp_dir().join(format!("ledger-mcp-tax-{}-{name}", std::process::id()));
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(&dir).unwrap();
TestDir(dir)
}
fn add(m: &mut Mcp, args: Value) -> Value {
m.call("books.add_account", args)
}
fn chart_codes(m: &mut Mcp) -> Vec<String> {
m.call("books.chart", json!({}))["accounts"]
.as_array()
.unwrap()
.iter()
.map(|a| a["code"].as_str().unwrap().to_string())
.collect()
}
/// Defect 8, repro 1.4.1: books.add_account of a revenue or expense
/// account without a tax category creates it and warns, naming it and
/// the tools to call; an asset, liability, equity or header account, or
/// one given its category, has no warning. A category that doesn't fit
/// the account or the return is refused, and a deductible percent needs
/// a category.
#[test]
fn add_account_warns_without_tax_category_defect_8() {
let dir = test_dir("warn");
let mut m = Mcp::spawn(&dir.0.join("books-data"));
let out = add(
&mut m,
json!({"code": "6150", "name": "Software", "account_type": "expense"}),
);
assert!(out["account_id"].as_u64().is_some());
assert_eq!(
out["warnings"],
json!([
"6150 Software has no tax category: the tax summary will list it as uncategorized. Propose one from books.tax_categories and set it with books.set_tax_category once the human agrees."
])
);
let out = add(
&mut m,
json!({"code": "4000", "name": "Sales", "account_type": "revenue"}),
);
assert_eq!(out["warnings"].as_array().unwrap().len(), 1, "{out}");
for args in [
json!({"code": "1000", "name": "Business Checking", "account_type": "asset"}),
json!({"code": "2000", "name": "Card", "account_type": "liability"}),
json!({"code": "3000", "name": "Capital", "account_type": "equity"}),
json!({"code": "6000", "name": "Operating", "account_type": "expense", "placeholder": true}),
json!({"code": "6160", "name": "Hosting", "account_type": "expense", "tax_category": "other_deductions"}),
json!({"code": "6170", "name": "Meals", "account_type": "expense", "tax_category": "meals", "deductible_percent": "50"}),
] {
let out = add(&mut m, args.clone());
assert!(out.get("warnings").is_none(), "{args}: {out}");
}
let chart = m.call("books.chart", json!({}));
let meals = chart["accounts"]
.as_array()
.unwrap()
.iter()
.find(|a| a["code"] == "6170")
.unwrap()
.clone();
assert_eq!(meals["tax_category"], "meals");
assert_eq!(meals["deductible_percent"], "50");
let before = m.events();
for (args, says) in [
(
json!({"code": "4100", "name": "Fees", "account_type": "revenue", "tax_category": "meals"}),
"expense",
),
(
json!({"code": "6180", "name": "X", "account_type": "expense", "tax_category": "no_such"}),
"unknown tax category",
),
(
json!({"code": "6190", "name": "Y", "account_type": "expense", "deductible_percent": "50"}),
"needs a tax_category",
),
(
json!({"code": "6195", "name": "Z", "account_type": "expense", "tax_category": "other_deductions", "deductible_percent": "150"}),
"between 0 and 100",
),
] {
let err = m.call_err("books.add_account", args.clone());
assert!(err.contains(says), "{args}: {err}");
}
assert_eq!(m.events(), before, "refusals write nothing");
// Needs attention's list, for the agent too.
let digest = m.call("books.digest", json!({"since": "2026-01-01"}));
assert_eq!(
digest["uncategorized_accounts"],
json!(["4000 Sales", "6150 Software"])
);
}
/// Defect 8 under `policy.require_tax_category = refuse`: books.add_account
/// without a category is refused and the chart is unchanged; with one it
/// is created (account.added and account.tax_set); import_chart_csv with
/// one uncategorized expense row creates nothing and says why; clearing a
/// needed category is refused; a bill on books without 6999 still posts,
/// its result naming the account it created, which Needs attention (the
/// digest) lists. Back to warn, the same account is created with a
/// warning.
#[test]
fn require_tax_category_refuses_defect_8() {
let dir = test_dir("refuse");
let mut m = Mcp::spawn(&dir.0.join("books-data"));
add(
&mut m,
json!({"code": "1000", "name": "Business Checking", "account_type": "asset"}),
);
add(
&mut m,
json!({"code": "6150", "name": "Software", "account_type": "expense", "tax_category": "other_deductions"}),
);
m.set_policy(json!("refuse"));
let settings = m.call("books.settings", json!({}));
assert!(
settings.to_string().contains("policy.require_tax_category"),
"{settings}"
);
let before = m.events();
let codes = chart_codes(&mut m);
let err = m.call_err(
"books.add_account",
json!({"code": "6160", "name": "Hosting", "account_type": "expense"}),
);
assert!(
err.contains("policy.require_tax_category is refuse")
&& err.contains("6160 Hosting")
&& err.contains("books.tax_categories"),
"{err}"
);
assert_eq!(chart_codes(&mut m), codes);
assert_eq!(m.events(), before);
let out = add(
&mut m,
json!({"code": "6160", "name": "Hosting", "account_type": "expense", "tax_category": "other_deductions"}),
);
assert!(out.get("warnings").is_none(), "{out}");
assert_eq!(m.events(), before + 2, "account.added and account.tax_set");
// A header is exempt.
add(
&mut m,
json!({"code": "6000", "name": "Operating", "account_type": "expense", "placeholder": true}),
);
// The import: refused whole, saying why.
let before = m.events();
let err = m.call_err(
"books.import_chart_csv",
json!({"csv_text": "code,name,type,parent_code,tax_category\n6200,Rent,expense,,rent_property\n6210,Misc,expense,,\n"}),
);
assert!(
err.contains("6210") && err.contains("fifth column") && err.contains("never a header"),
"{err}"
);
assert!(!chart_codes(&mut m).contains(&"6200".to_string()));
assert_eq!(m.events(), before);
// Clearing a needed category.
let err = m.call_err(
"books.set_tax_category",
json!({"account": "6150", "category": null}),
);
assert!(err.contains("keeps a tax category"), "{err}");
m.call(
"books.set_tax_category",
json!({"account": "6150", "category": "supplies"}),
);
// A bill creates General Expenses 6999 without a category: never
// refused, named in the result's warnings and the digest.
let vendor = m.call("books.add_vendor", json!({"name": "Coastline Hosting"}));
let bill = m.call(
"books.create_bill",
json!({"party_id": vendor["id"], "date": "2026-02-01", "due_date": "2026-03-01",
"lines": [{"description": "hosting", "amount": "42.00"}]}),
);
assert_eq!(
bill["warnings"],
json!([
"6999 General Expenses has no tax category: the tax summary will list it as uncategorized. Propose one from books.tax_categories and set it with books.set_tax_category once the human agrees."
])
);
let digest = m.call("books.digest", json!({"since": "2026-01-01"}));
assert_eq!(
digest["uncategorized_accounts"],
json!(["6999 General Expenses"])
);
// A second bill creates nothing, and warns of nothing.
let again = m.call(
"books.create_bill",
json!({"party_id": vendor["id"], "date": "2026-02-02", "due_date": "2026-03-02",
"lines": [{"description": "hosting", "amount": "1.00"}]}),
);
assert!(again.get("warnings").is_none(), "{again}");
// Back to warn (cleared): created, with the warning.
m.set_policy(Value::Null);
let out = add(
&mut m,
json!({"code": "6210", "name": "Misc", "account_type": "expense"}),
);
assert_eq!(out["warnings"].as_array().unwrap().len(), 1, "{out}");
let out = m.call(
"books.import_chart_csv",
json!({"csv_text": "code,name,type,parent_code\n6220,Postage,expense,\n6230,Fees,revenue,\n1100,Petty Cash,asset,\n"}),
);
assert_eq!(out["count"], 3);
assert_eq!(out["warnings"].as_array().unwrap().len(), 2, "{out}");
}
/// rules.md B-31 for new accounts: a name another account under the same
/// parent has, ignoring capitals (top-level accounts are siblings too),
/// or one holding nine digits in a row, is refused, for books.add_account
/// and books.import_chart_csv alike; under another parent the name is
/// free.
#[test]
fn a_new_account_named_like_a_sibling_is_refused() {
let dir = test_dir("names");
let mut m = Mcp::spawn(&dir.0.join("books-data"));
add(
&mut m,
json!({"code": "6000", "name": "Operating", "account_type": "expense", "placeholder": true}),
);
let ops = m.call("books.chart", json!({}))["accounts"][0]["id"].clone();
add(
&mut m,
json!({"code": "6150", "name": "Software", "account_type": "expense", "parent": ops, "tax_category": "other_deductions"}),
);
let before = m.events();
for (args, says) in [
(
json!({"code": "6151", "name": "SOFTWARE", "account_type": "expense", "parent": ops}),
"6150 Software already has the name",
),
(
json!({"code": "6001", "name": " operating ", "account_type": "expense"}),
"6000 Operating already has the name",
),
(
json!({"code": "1000", "name": "Checking 123456789", "account_type": "asset"}),
"nine digits",
),
] {
let err = m.call_err("books.add_account", args.clone());
assert!(err.contains(says), "{args}: {err}");
}
let err = m.call_err(
"books.import_chart_csv",
json!({"csv_text": "code,name,type,parent_code\n1000,Cash,asset,\n1010,cash,asset,\n"}),
);
assert!(err.contains("1010") && err.contains("1000 Cash"), "{err}");
assert_eq!(m.events(), before);
// Top level: no sibling of the child.
add(
&mut m,
json!({"code": "6200", "name": "Software", "account_type": "expense", "tax_category": "other_deductions"}),
);
}
/// rules.md B-32: a gain on business property (`form_4797_gain_loss`, the
/// disposal account a refusing company must categorize) is exported apart
/// in the tax summary, JSON and CSV alike: at its book amount, with no tax
/// amount, and outside income and both nets (Form 4797 figures it from
/// the tax basis; never Schedule C income, never ordinary income the
/// summary could place).
#[test]
fn a_gain_on_business_property_is_exported_apart() {
let dir = test_dir("form4797");
let mut m = Mcp::spawn(&dir.0.join("books-data"));
for args in [
json!({"code": "1000", "name": "Business Checking", "account_type": "asset"}),
json!({"code": "4000", "name": "Sales", "account_type": "revenue", "tax_category": "gross_receipts"}),
json!({"code": "4700", "name": "Gain on disposal", "account_type": "revenue", "tax_category": "form_4797_gain_loss"}),
] {
add(&mut m, args);
}
for (cr, amount) in [("4000", "10.00"), ("4700", "7.50")] {
let e = m.call(
"books.draft_entry",
json!({"date": "2026-03-02", "description": "x", "no_party_reason": "test"}),
);
let id = e["entry_id"].clone();
for (account, side) in [("1000", "debit"), (cr, "credit")] {
m.call(
"books.add_line",
json!({"entry_id": id, "account": account, "side": side, "amount": amount}),
);
}
m.call(
"books.post_entry",
json!({"entry_id": id, "confirmed": true}),
);
}
let range = |format: &str| json!({"report": "tax_summary", "format": format, "start": "2026-01-01", "end": "2026-12-31"});
let out = m.call("books.export", range("json"));
let j: Value = serde_json::from_str(out["content"].as_str().unwrap()).unwrap();
assert_eq!(
j["form_4797"],
json!([{"category": "form_4797_gain_loss", "label": "Gain or loss on business property (Form 4797)",
"accounts": ["4700 Gain on disposal"], "book": "7.50"}])
);
assert_eq!(j["income"].as_array().unwrap().len(), 1, "{j}");
assert_eq!(j["income"][0]["category"], "gross_receipts");
assert_eq!(j["book_net"], "10.00");
assert_eq!(j["tax_net"], "10.00");
let out = m.call("books.export", range("csv"));
let csv = out["content"].as_str().unwrap();
assert!(
csv.lines().any(|l| l
== "form_4797,form_4797_gain_loss,Gain or loss on business property (Form 4797),4700 Gain on disposal,7.50,"),
"{csv}"
);
assert!(
!csv.lines().any(|l| l.starts_with("income,form_4797")),
"{csv}"
);
}