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
@@ -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,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.
|
||||
@@ -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));
|
||||
}
|
||||
|
||||
|
||||
@@ -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),
|
||||
|
||||
+702
-143
File diff suppressed because it is too large.
Load diff
+149
-21
@@ -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>,
|
||||
}
|
||||
|
||||
|
||||
@@ -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