P3: rename accounts, one set of period defaults, tax categories warn or refuse (defects 15, 16, 8)
check / check (push) Canceled after 0s
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:
69 files changed
+8063
-570
No files matched your search
@@ -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");
|
||||
}
|
||||
@@ -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}");
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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}"
|
||||
);
|
||||
}
|
||||
Reference in new issue
Block a user