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

+1 -1
View File
@@ -2,4 +2,4 @@
- Invoices, bills, payroll, and closes post to the account filling each role (receivables, revenue, payables, general expense, retained earnings, payroll expense and withholdings); books.roles lists them.
- A role uses its default code (1200, 4000, 2000, 6999, 3999, 6100, 6110, 2110-2150) until it is assigned one of the chart's own accounts with books.assign_role (journaled). If the chart uses a default code for a different kind of account, the engine refuses and says which. It can't tell an account of the right type but another purpose (a hosting expense at payroll's 6100): check books.roles before running payroll, bills, or invoices.
- Never assign roles or renumber the human's accounts on your own: explain the conflict, and when they decide, assign the role to their account (best: their numbering stays) or books.renumber_account the account (its history stays).
- Never assign roles or renumber the human's accounts on your own: explain the conflict, and when they decide, assign the role to their account (best: their numbering stays) or books.renumber_account the account (its history stays); books.rename_account changes only its name, never a role.
@@ -8,6 +8,7 @@ The books keep their own record of how they are kept. Every agent, anywhere, rea
- Change settings with books.set_setting: one or more {key, value} together, with who decided (decided_by) and when (decided_on, default today). A value is checked and kept in one spelling; null clears a key back to its default. Change a setting when the human tells you a company fact or decides a policy, when a tool refuses naming a setting, when books.settings or books.digest lists a problem, and after books.renumber_account lists config_mentions.
- The company's EIN is the one identity number the settings keep; it is shown masked. No other setting, note or convention ever holds a full TIN: those go only into the vault (the parties-and-tins section).
- company.timezone sets what "today" means for the books.
- policy.require_tax_category is warn (unset: a revenue or expense account without a tax category is created, and the result and Needs attention say so) or refuse (books.add_account, books.import_chart_csv and books.set_tax_category won't leave one without; the accounts invoices, bills and payroll create are never refused, and their results name them). Before setting refuse, categorize with the human every account books.digest lists in uncategorized_accounts.
- books.export_settings writes the settings out as a settings file (TOML), each with who decided it. It is a copy: editing it changes nothing.
## Books that still read a settings file
@@ -1,6 +1,7 @@
# Tracing figures
- Reports are pure functions of the posted entries. When the human questions a figure, trace it to its entries with the tools: the report (books.trial_balance, books.profit_and_loss, books.balance_sheet, books.cash_flow), then books.search_entries and books.get_entry for the entries behind it, and books.show to put them on the human's screen.
- A report without dates covers the fiscal year to date, everywhere (the report tools, the export, books.show and the viewer); one with only an end date covers that date's fiscal year to it. {period_rule} The IIF is never a period: every posted entry from the first through its as_of. Every result names the dates it covers: say them when you quote a figure, and pass dates whenever the human means another period.
- A cash flow line names its entry (books.cash_flow: each line's entry, kind and accounts). An expense a member paid personally (or equipment a lender paid for) is cash borrowed and spent on its date: a financing line and an operating (or investing) line, each with on_behalf, the part no bank account moved; the reimbursement is a repayment. A noncash disclosure is an entry that moved no cash at all (property contributed, debt converted to capital). A warning names an entry whose later payment or collection the books can't classify, with the fix. A role refusal names the setting to change.
- Never guess, and never look for the books' files: the tools are the evidence.
- books.verify checks the books end to end when a number looks wrong or a human asks whether the books are intact.
+1
View File
@@ -1,4 +1,5 @@
# The viewer
- The human usually has the read-only viewer open beside you. When they ask to see something, and after you post, call books.show so it's on their screen: an entry, an account register, a report for any dates (by month, quarter or year, against the prior period or year), a receipt or document, an asset, a party, a reconciliation, or a search.
- books.show says which dates it showed (without dates, the fiscal year to date; a register opens on it too, with "Show all dates" one click away for the human). It refuses a start after the end, and options its target ignores (dates for an entry, a split for the trial balance, a start for a report as of a day, a split into more than 240 columns): pass only what the target takes.
- The viewer never changes the books; every change goes through these tools.
+25 -2
View File
@@ -43,7 +43,8 @@ pub const CONNECT_LEAD: usize = 512;
/// The handbook's sections, in reading order: (id, markdown). Each starts
/// with a `# Title` line; `{evidence_rule}` stands for the receipt rule in
/// force.
/// force, and `{period_rule}` for what omitted report dates mean
/// ([`period_rule`]).
pub const SECTIONS: &[(&str, &str)] = &[
("contract", include_str!("../handbook/contract.md")),
("entries", include_str!("../handbook/entries.md")),
@@ -116,7 +117,10 @@ pub const ALL: &str = "all";
/// The section `id` (or [`ALL`]), rendered for these settings and the
/// books' base currency (None: no such section).
pub fn section(id: &str, settings: &Settings, currency: &str) -> Option<String> {
let render = |md: &str| md.replace("{evidence_rule}", &evidence_rule(settings, currency));
let render = |md: &str| {
md.replace("{evidence_rule}", &evidence_rule(settings, currency))
.replace("{period_rule}", &period_rule())
};
if id == ALL {
let parts: Vec<String> = SECTIONS.iter().map(|(_, md)| render(md)).collect();
return Some(parts.join("\n"));
@@ -127,6 +131,17 @@ pub fn section(id: &str, settings: &Settings, currency: &str) -> Option<String>
.map(|(_, md)| render(md))
}
/// What omitted report dates mean (rules.md B-30), in the words every
/// report tool's description uses (`ledger_core::calendar::PeriodKind`).
pub fn period_rule() -> String {
use ledger_core::calendar::PeriodKind;
format!(
"{} For a report as of a day (balance sheet, trial balance, aging): {}",
PeriodKind::Statement.describe(),
PeriodKind::AsOf.describe()
)
}
/// The receipt rule in force (INVARIANT 2).
pub fn evidence_rule(settings: &Settings, currency: &str) -> String {
format!(
@@ -460,7 +475,14 @@ mod tests {
assert!(!title(md).is_empty());
let text = section(id, &s, "USD").unwrap();
assert!(!text.contains("{evidence_rule}"), "{id}");
assert!(!text.contains("{period_rule}"), "{id}");
}
// The default period, in the descriptions' own words (B-30).
assert!(
section("tracing-figures", &s, "USD")
.unwrap()
.contains(ledger_core::calendar::PeriodKind::Statement.describe())
);
assert!(
section("evidence-and-receipts", &s, "USD")
.unwrap()
@@ -497,6 +519,7 @@ mod tests {
at += found + one.len();
}
assert!(!all.contains("{evidence_rule}"));
assert!(!all.contains("{period_rule}"));
assert!(SECTIONS.iter().all(|(id, _)| *id != ALL));
}
+2 -1
View File
@@ -62,7 +62,7 @@ const C: Needs = PostWhenConfirmed;
const K: Needs = PostForApprovalRules;
/// Every tool and what it needs (INVARIANT 1).
pub const TABLE: [(&str, Needs); 111] = [
pub const TABLE: [(&str, Needs); 112] = [
("books.handbook", R),
("books.start", R),
// Settings are committed at once: the settings commit of the plan.
@@ -123,6 +123,7 @@ pub const TABLE: [(&str, Needs); 111] = [
("books.chart_template", R),
("books.tax_categories", R),
("books.renumber_account", P),
("books.rename_account", P),
("books.label_asset", W),
("books.correct_asset", P),
("books.set_asset_tax_basis", W),
File diff suppressed because it is too large. Load diff
+149 -21
View File
@@ -1,6 +1,7 @@
//! Tool parameter and output types. Amounts cross MCP as decimal strings,
//! never JSON floats.
use ledger_core::calendar::{AS_OF_DOC, END_DOC, START_DOC, YEAR_DOC};
use schemars::JsonSchema;
use serde::{Deserialize, Serialize};
@@ -13,7 +14,10 @@ use serde::{Deserialize, Serialize};
pub struct AddAccountParams {
/// Account code, e.g. "1000". Must be unique.
pub code: String,
/// Human name, e.g. "Business Checking".
/// Human name, e.g. "Business Checking": one line of at most 200
/// characters, kept trimmed; no other account under the same parent
/// has it (ignoring capitals), and it holds no run of nine digits
/// (write an account number's last four).
pub name: String,
/// One of: asset, liability, equity, revenue, expense.
pub account_type: String,
@@ -21,6 +25,14 @@ pub struct AddAccountParams {
pub parent: Option<u64>,
/// True for header accounts that never take postings.
pub placeholder: Option<bool>,
/// The tax category of a revenue or expense account (a key from
/// `books.tax_categories`): the line of the return it maps to.
#[serde(default)]
pub tax_category: Option<String>,
/// Percent deductible, overriding the category's default (0 to 100),
/// as a decimal string. Needs tax_category.
#[serde(default)]
pub deductible_percent: Option<String>,
}
/// Result of `books.add_account`.
@@ -28,6 +40,45 @@ pub struct AddAccountParams {
pub struct AddAccountOut {
/// The new account's id.
pub account_id: u64,
/// What to tell the human: a revenue or expense account created
/// without a tax category (the tax summary lists it as uncategorized
/// until it has one).
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub warnings: Vec<String>,
}
/// Parameters for `books.rename_account`.
#[derive(Debug, Deserialize, JsonSchema)]
pub struct RenameAccountParams {
/// The account's code (or id).
pub account: String,
/// Its new name, kept trimmed: one line of at most 200 characters,
/// not the one it has, not one another account under the same parent
/// has (ignoring capitals), and no run of nine digits.
pub name: String,
/// Why, in a line or two, for the journal (the human's words, or what
/// they asked). Optional.
#[serde(default)]
pub reason: Option<String>,
}
/// Result of `books.rename_account`.
#[derive(Debug, Serialize, JsonSchema)]
pub struct RenameAccountOut {
/// The account's id (unchanged).
pub id: u64,
/// Its code (unchanged).
pub code: String,
/// The name it had: the journal keeps it.
pub previous_name: String,
/// The name it has now.
pub name: String,
/// What else the new name changes: a set of accounts picked by name
/// it joins or leaves (cash and card accounts, when the settings name
/// none), conventions whose text names the old name, and the
/// QuickBooks import (IIF matches accounts by name).
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub notes: Vec<String>,
}
/// One account in the chart.
@@ -474,17 +525,38 @@ pub struct ShowParams {
/// A page: overview, cash, profit, transactions, reports, accounts,
/// assets, parties, documents, company, journal.
pub page: Option<String>,
/// For account and report: first date, YYYY-MM-DD.
/// For an account or a report over a period: its first day (see
/// [`show_start_doc`]).
#[schemars(description = show_start_doc())]
pub start: Option<String>,
/// For account and report: last date, YYYY-MM-DD.
/// For an account or a report: its last day, or the day a report as
/// of a day is as of (see [`show_end_doc`]).
#[schemars(description = show_end_doc())]
pub end: Option<String>,
/// For a report: split into columns, "total" (default), "month",
/// "quarter", or "year" (P&L, balance sheet, cash flow).
pub columns: Option<String>,
/// For a report: compare with "prior_period" or "prior_year".
/// For a report: compare with "prior_period" or "prior_year" (P&L,
/// balance sheet, cash flow, tax summary; not with a split).
pub compare: Option<String>,
}
/// `books.show`'s `start`, in the one default's words
/// ([`ledger_core::calendar::START_DOC`]).
pub fn show_start_doc() -> String {
format!(
"For an account, or a report over a period (a balance sheet only when split or compared with the prior period). {START_DOC}"
)
}
/// `books.show`'s `end`, in the one default's words
/// ([`ledger_core::calendar::END_DOC`]).
pub fn show_end_doc() -> String {
format!(
"For an account or a report: the last day, or the day a report as of a day is as of (vendor_1099: a day in its year). {END_DOC}"
)
}
/// Result of `books.show`.
#[derive(Debug, Serialize, JsonSchema)]
pub struct ShowOut {
@@ -738,8 +810,9 @@ pub struct TrialBalanceLineOut {
/// Parameters for `books.trial_balance`.
#[derive(Debug, Default, Deserialize, JsonSchema)]
pub struct TrialBalanceParams {
/// As of this date, YYYY-MM-DD. Defaults to today.
/// As of this day ([`AS_OF_DOC`]).
#[serde(default)]
#[schemars(description = AS_OF_DOC)]
pub as_of: Option<String>,
}
@@ -761,9 +834,11 @@ pub struct TrialBalanceOut {
/// Parameters for `books.profit_and_loss`.
#[derive(Debug, Deserialize, JsonSchema)]
pub struct ProfitAndLossParams {
/// Period start, YYYY-MM-DD. Defaults to the beginning of time.
/// Period start ([`START_DOC`]).
#[schemars(description = START_DOC)]
pub start: Option<String>,
/// Period end, YYYY-MM-DD. Defaults to today.
/// Period end ([`END_DOC`]).
#[schemars(description = END_DOC)]
pub end: Option<String>,
/// Optional class name or id to filter to one division.
#[serde(default, skip_serializing_if = "Option::is_none")]
@@ -803,9 +878,11 @@ pub struct StatementLineOut {
/// Parameters for `books.cash_flow`.
#[derive(Debug, Deserialize, JsonSchema)]
pub struct CashFlowParams {
/// Period start, YYYY-MM-DD. Defaults to the beginning of time.
/// Period start ([`START_DOC`]).
#[schemars(description = START_DOC)]
pub start: Option<String>,
/// Period end, YYYY-MM-DD. Defaults to today.
/// Period end ([`END_DOC`]).
#[schemars(description = END_DOC)]
pub end: Option<String>,
/// Asset account codes treated as cash (e.g. `["1000", "1010"]`): the
/// statement explains the change in these accounts. Defaults to the
@@ -1081,12 +1158,17 @@ pub struct ImportChartCsvOut {
pub created: Vec<String>,
/// Number of accounts created.
pub count: usize,
/// What to tell the human: each revenue or expense account created
/// without a tax category.
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub warnings: Vec<String>,
}
/// Parameters for `books.balance_sheet`.
#[derive(Debug, Deserialize, JsonSchema)]
pub struct BalanceSheetParams {
/// As of this date, YYYY-MM-DD. Defaults to today.
/// As of this day ([`AS_OF_DOC`]).
#[schemars(description = AS_OF_DOC)]
pub as_of: Option<String>,
}
@@ -1773,6 +1855,11 @@ pub struct DigestOut {
/// with a recorded statement) with activity not reconciled through the
/// end of last month: record each statement and reconcile it.
pub unreconciled_accounts: Vec<UnreconciledAccountOut>,
/// Revenue and expense accounts (not headers) with no tax category,
/// active or not, "CODE Name": the list the viewer's Needs attention
/// shows. Propose a category for each from books.tax_categories and
/// set it with books.set_tax_category once the human agrees.
pub uncategorized_accounts: Vec<String>,
}
/// An account due for reconciliation (`books.digest`).
@@ -1827,13 +1914,14 @@ pub struct ExportParams {
/// "journal", "trial_balance", "profit_and_loss", "balance_sheet", or
/// "cash_flow".
pub report: String,
/// Period start for profit_and_loss and cash_flow, YYYY-MM-DD.
/// Period start for the period reports (see [`export_start_doc`]).
#[schemars(description = export_start_doc())]
pub start: Option<String>,
/// Period end for profit_and_loss and cash_flow, YYYY-MM-DD.
/// Period end for the period reports (see [`export_end_doc`]).
#[schemars(description = export_end_doc())]
pub end: Option<String>,
/// As-of date for balance_sheet, trial_balance, fixed_asset_register,
/// and iif (every posted entry dated on or before it), YYYY-MM-DD.
/// Defaults to today.
/// As-of day for the reports as of a day (see [`export_as_of_doc`]).
#[schemars(description = export_as_of_doc())]
pub as_of: Option<String>,
/// Cash account codes for cash_flow, and for iif its BANK accounts.
/// Defaults to `[books] cash_accounts`; one or the other is required
@@ -1851,6 +1939,28 @@ pub struct ExportParams {
pub investing_accounts: Vec<String>,
}
/// `books.export`'s `start`, in the one default's words
/// ([`ledger_core::calendar::START_DOC`]).
pub fn export_start_doc() -> String {
format!(
"For the period reports: profit_and_loss, cash_flow, members_equity, tax_summary, general_ledger, general_journal (csv, json, xlsx; iif takes as_of instead). {START_DOC}"
)
}
/// `books.export`'s `end`, in the one default's words
/// ([`ledger_core::calendar::END_DOC`]).
pub fn export_end_doc() -> String {
format!("For the period reports, as for start. {END_DOC}")
}
/// `books.export`'s `as_of`, in the one default's words
/// ([`ledger_core::calendar::AS_OF_DOC`]).
pub fn export_as_of_doc() -> String {
format!(
"For balance_sheet, trial_balance, fixed_asset_register and iif (every posted entry dated on or before it). {AS_OF_DOC}"
)
}
/// Result of `books.export`.
#[derive(Debug, Serialize, JsonSchema)]
pub struct ExportOut {
@@ -2534,6 +2644,10 @@ pub struct CreateDocumentOut {
pub total: String,
/// The auto-posted journal entry's id.
pub entry_id: u64,
/// What to tell the human: a revenue or expense account this created
/// (Revenue 4000, General Expenses 6999) has no tax category yet.
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub warnings: Vec<String>,
}
/// Parameters for `books.receive_payment` / `books.pay_bill`.
@@ -2563,8 +2677,10 @@ pub struct PaymentOut {
/// Parameters for `books.ar_aging` / `books.ap_aging`.
#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
pub struct AgingParams {
/// As-of date, YYYY-MM-DD.
pub as_of: String,
/// As of this day ([`AS_OF_DOC`]).
#[serde(default)]
#[schemars(description = AS_OF_DOC)]
pub as_of: Option<String>,
}
/// One row of an aging report.
@@ -2589,6 +2705,8 @@ pub struct AgingRowOut {
/// Output of `books.ar_aging` / `books.ap_aging`.
#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
pub struct AgingOut {
/// The day it is as of, YYYY-MM-DD.
pub as_of: String,
/// Rows, one per party with an outstanding balance.
pub rows: Vec<AgingRowOut>,
}
@@ -2669,6 +2787,11 @@ pub struct RunPayrollOut {
pub total_gross: String,
/// Total net pay, decimal string.
pub total_net: String,
/// What to tell the human: an expense account this created (Salary &
/// Wage Expense 6100, Payroll Tax Expense 6110) has no tax category
/// yet.
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub warnings: Vec<String>,
}
/// Parameters for `books.remit_payroll_taxes`.
@@ -2780,7 +2903,9 @@ pub struct ProfitAndLossByClassOut {
/// Parameters for `books.export_cpa_package`.
#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
pub struct ExportCpaPackageParams {
/// As-of date, YYYY-MM-DD. Defaults to today.
/// As of this day ([`AS_OF_DOC`]); the period reports cover the
/// fiscal year that holds it, through it.
#[schemars(description = AS_OF_DOC)]
pub as_of: Option<String>,
/// Cash account codes the cash flow statement reconciles to, e.g.
/// `["1000"]`. Defaults to `[books] cash_accounts`; one or the other
@@ -3114,8 +3239,9 @@ pub struct ListPartiesParams {
/// Include archived parties (default false).
#[serde(default)]
pub include_archived: bool,
/// Calendar year for `paid` and `received` (default: this year, so
/// year to date).
/// Calendar year for `paid` and `received` ([`YEAR_DOC`]: this year,
/// so year to date).
#[schemars(description = YEAR_DOC)]
pub year: Option<i32>,
}
@@ -3124,7 +3250,9 @@ pub struct ListPartiesParams {
pub struct PartyParams {
/// The party: id or name.
pub party: String,
/// Calendar year for the totals (default: this year, so year to date).
/// Calendar year for the totals ([`YEAR_DOC`]: this year, so year to
/// date).
#[schemars(description = YEAR_DOC)]
pub year: Option<i32>,
}
+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}"
);
}