From 3cab75c494d06d2a9b9c6fc9876f57fb00db3576 Mon Sep 17 00:00:00 2001 From: Stephen Waits Date: Mon, 26 Oct 2026 16:57:20 -0600 Subject: [PATCH] refactor(mcp): split server.rs into a file per tool group (U0a) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit server.rs had grown to 12,566 lines: every tool handler, every helper and the request machinery in one file, so every unit that adds a tool touched it and parallel worktrees conflicted on every merge (plan/served.md 8.2 U0a, 8.0.3). The 98 handlers still in it now live one file per group under tools/, following the pattern tools/kept.rs and tools/projects.rs already use (`#[path]` declaration, `use super::*`, one `#[tool_router(vis = "pub(super)")] impl` per file): documents (invoices, bills, payments, voids, books.documents, books.document, the control check, the agings), entries (draft, line, post, reverse, get, search, the bank CSV import), reports (the statements, books.show, verify, digest, export, the CPA package, the P&L by class, with the export builders), assets, schedules, periods, reconciliation, parties (parties, vendors, customers, W-9s, the TIN vault, 1099s, entry parties), attachments (receipts, filed documents, links), settings (the handbook, books.settings, FX rates), chart, payroll, payouts, tax; add_class and list_classes join the existing projects file, since a project is a class with its facts. The helpers only one group uses moved into that group's file; the ones several groups share moved to tools/helpers.rs, re-exported by server.rs so every group reaches them through `use super::*` and no group file reaches into another's (resolve_instrument and the attachment/statement shapers moved there too, since the payment, entries and attachment tools all use them). server.rs keeps BooksServer, the store and its lock, the request machinery, call_tool/get_info, tool_router (which now composes the group routers; core_router is gone), and the shared helpers the plan names (as_of, hand_over, resolve_vendor, parse_doc_ref, preview_lines_for). This is a move, not a change: no accounting behaviour, tool name, description, annotation or schema changed. A dump of tool_router().list_all() — every tool's name, title, description, input and output schema and annotations, in order — is identical to main's (122 tools; the plan's "118" is stale). server.rs is 1,909 lines and has no #[tool] handler. The server's cross-group tests moved to server_tests.rs beside it (a #[path] module; fixtures in server::test_support) and each group's own tests into its group file, so server.rs stays under the 3,000-line bar. cargo test -p ledger-mcp: 99 passed; cargo test -p ledger-cli: 25 suites, 0 failed. --- crates/ledger-mcp/src/server.rs | 10870 +--------------- crates/ledger-mcp/src/server_tests.rs | 1510 +++ crates/ledger-mcp/src/tools/assets.rs | 1009 ++ crates/ledger-mcp/src/tools/attachments.rs | 541 + crates/ledger-mcp/src/tools/chart.rs | 543 + crates/ledger-mcp/src/tools/documents.rs | 633 + crates/ledger-mcp/src/tools/entries.rs | 902 ++ crates/ledger-mcp/src/tools/helpers.rs | 710 + crates/ledger-mcp/src/tools/instruments.rs | 22 +- crates/ledger-mcp/src/tools/parties.rs | 1520 +++ crates/ledger-mcp/src/tools/payouts.rs | 99 + crates/ledger-mcp/src/tools/payroll.rs | 164 + crates/ledger-mcp/src/tools/periods.rs | 159 + crates/ledger-mcp/src/tools/projects.rs | 39 + crates/ledger-mcp/src/tools/reconciliation.rs | 456 + crates/ledger-mcp/src/tools/reports.rs | 1997 +++ crates/ledger-mcp/src/tools/schedules.rs | 346 + crates/ledger-mcp/src/tools/settings.rs | 177 + crates/ledger-mcp/src/tools/tax.rs | 75 + 19 files changed, 10986 insertions(+), 10786 deletions(-) create mode 100644 crates/ledger-mcp/src/server_tests.rs create mode 100644 crates/ledger-mcp/src/tools/assets.rs create mode 100644 crates/ledger-mcp/src/tools/attachments.rs create mode 100644 crates/ledger-mcp/src/tools/chart.rs create mode 100644 crates/ledger-mcp/src/tools/documents.rs create mode 100644 crates/ledger-mcp/src/tools/entries.rs create mode 100644 crates/ledger-mcp/src/tools/helpers.rs create mode 100644 crates/ledger-mcp/src/tools/parties.rs create mode 100644 crates/ledger-mcp/src/tools/payouts.rs create mode 100644 crates/ledger-mcp/src/tools/payroll.rs create mode 100644 crates/ledger-mcp/src/tools/periods.rs create mode 100644 crates/ledger-mcp/src/tools/reconciliation.rs create mode 100644 crates/ledger-mcp/src/tools/reports.rs create mode 100644 crates/ledger-mcp/src/tools/schedules.rs create mode 100644 crates/ledger-mcp/src/tools/settings.rs create mode 100644 crates/ledger-mcp/src/tools/tax.rs diff --git a/crates/ledger-mcp/src/server.rs b/crates/ledger-mcp/src/server.rs index be1013b..a1068e3 100644 --- a/crates/ledger-mcp/src/server.rs +++ b/crates/ledger-mcp/src/server.rs @@ -301,32 +301,6 @@ impl SharedStore { } } -/// The book's id, as the commands given to agents name it: its folder's -/// name in the data root. -fn book_name(store: &Store) -> String { - store - .data_dir() - .file_name() - .map(|n| n.to_string_lossy().to_string()) - .unwrap_or_default() -} - -/// A filed file's name and type: those given, else the upload's own. -fn named( - file_name: Option, - mime_type: Option, - file: &files::TakenFile, -) -> Result<(String, String), String> { - let name = file_name.unwrap_or_else(|| file.file_name.clone()); - let mime = mime_type.unwrap_or_else(|| file.mime.clone()); - if let Some(why) = ledger_store::uploads::file_name_problem(&name) - .or_else(|| ledger_store::uploads::mime_problem(&mime)) - { - return Err(why); - } - Ok((name, mime)) -} - /// What every request gets after an internal error (SharedStore /// INVARIANT 2). pub(crate) const STOPPED: &str = "the books server stopped taking requests after an internal error, to protect the books: ask the human to restart the books server (the books are checked again when it starts)"; @@ -476,116 +450,6 @@ fn not_run_closing() -> rmcp::ErrorData { ) } -/// Likely counterparties for an entry, best first (at most three): parties -/// named in its description, then in its receipts' names and -/// descriptions, then the party of an earlier entry with the same -/// description. Names match as whole words, ignoring case; a party also -/// matches by the first word of its name when that word has 4+ letters -/// ("Apple" for "Apple Inc."). -fn suggest_parties(store: &Store, entry: &JournalEntry) -> Result, String> { - let ledger = store.ledger(); - let words = |t: &str| -> Vec { - t.to_lowercase() - .split(|c: char| !c.is_alphanumeric()) - .filter(|w| !w.is_empty()) - .map(str::to_string) - .collect() - }; - let contains = |hay: &[String], needle: &[String]| { - !needle.is_empty() && hay.windows(needle.len()).any(|w| w == needle) - }; - let names_of = |p: &ledger_core::Party| -> Vec> { - let mut out = vec![words(&p.name)]; - if let Some(d) = &p.display_name { - out.push(words(d)); - } - if let Some(first) = words(&p.name).first() - && first.len() >= 4 - { - out.push(vec![first.clone()]); - } - out - }; - let receipts: String = store - .attachments_for(ledger_store::Subject::Entry(entry.id())) - .map_err(|e| e.to_string())? - .iter() - .map(|a| { - format!( - "{} {} ", - a.file_name, - a.description.clone().unwrap_or_default() - ) - }) - .collect(); - let desc = words(entry.description()); - let rec = words(&receipts); - let mut out: Vec = Vec::new(); - let push = |out: &mut Vec, p: &ledger_core::Party, why: String| { - if !out.iter().any(|s| s.party_id == p.id) { - out.push(PartySuggestionOut { - party_id: p.id, - name: p.name.clone(), - why, - }); - } - }; - let live: Vec<&ledger_core::Party> = ledger.parties().filter(|p| !p.archived).collect(); - for p in &live { - if names_of(p).iter().any(|n| contains(&desc, n)) { - push(&mut out, p, "named in the description".into()); - } - } - for p in &live { - if names_of(p).iter().any(|n| contains(&rec, n)) { - push(&mut out, p, "named in a receipt".into()); - } - } - let key = entry.description().trim().to_lowercase(); - for e in ledger.posted_entries() { - if e.id() != entry.id() - && e.description().trim().to_lowercase() == key - && let Some(p) = ledger - .entry_parties(e.id()) - .first() - .and_then(|s| ledger.get_party(s.party)) - && !p.archived - { - push( - &mut out, - p, - format!("tagged on entry {} with the same description", e.id()), - ); - } - } - out.truncate(3); - Ok(out) -} - -/// The settings as ledger_package reads them: the company, and the -/// `[books]` cash, financing, and investing roles. -fn package_settings(settings: &Settings) -> ledger_package::Settings<'_> { - ledger_package::Settings { - company: &settings.company, - cash_accounts: &settings.roles.cash, - financing_accounts: &settings.roles.financing, - investing_accounts: &settings.roles.investing, - } -} - -/// The 1099 worksheet for `year`, the one every tool and the CPA package -/// use (ledger_package::vendor_1099_report): payments out of the cash and -/// financing roles, an entry's shares resolved with the settings' members -/// (as [`share_context`] checks them). -fn vendor_1099_report( - ledger: &ledger_core::Ledger, - settings: &Settings, - year: i32, - threshold: Option, -) -> Result { - ledger_package::vendor_1099_report(ledger, &package_settings(settings), year, threshold, None) -} - impl BooksServer { /// The settings that name account `code`, by key. fn config_mentions(&self, code: &str) -> Vec { @@ -645,13 +509,26 @@ impl BooksServer { /// Every tool the server serves. pub(crate) fn tool_router() -> rmcp::handler::server::router::tool::ToolRouter { - Self::core_router() - + Self::kept_router() + Self::kept_router() + Self::access_router() + Self::files_router() + Self::backup_router() + Self::instruments_router() + Self::projects_router() + + Self::documents_router() + + Self::entries_router() + + Self::reports_router() + + Self::assets_router() + + Self::schedules_router() + + Self::periods_router() + + Self::reconciliation_router() + + Self::parties_router() + + Self::attachments_router() + + Self::settings_router() + + Self::chart_router() + + Self::payroll_router() + + Self::payouts_router() + + Self::tax_router() } /// The settings in force for this request: the books' own, taken from @@ -1112,488 +989,47 @@ impl BooksServer { // Helpers // --------------------------------------------------------------------------- -/// A decimal string, exactly `-?digits[.digits]` (INVARIANT 3). -fn parse_amount(s: &str) -> Result { - ledger_config::decimal(s) - .ok_or_else(|| format!("bad amount {s:?}: expected a decimal string like \"123.45\"")) -} - -fn parse_side(s: &str) -> Result { - match s.to_lowercase().as_str() { - "debit" => Ok(Side::Debit), - "credit" => Ok(Side::Credit), - _ => Err(format!("bad side {s:?}: expected \"debit\" or \"credit\"")), +/// How the viewer reads `report`'s dates with these options, or why it +/// would ignore one of them (`books.show` refuses what its target +/// ignores). Splits are the P&L's, the balance sheet's and the cash +/// flow's; comparisons theirs and the tax summary's, in one total column; +/// a balance sheet in one total column is as of its end, unless compared +/// with the period before (which starts the day before start). +fn show_report_dates( + report: &str, + columns: Option<&str>, + compare: Option<&str>, +) -> Result { + let split = columns.is_some_and(|c| c != "total"); + if columns.is_some() && !matches!(report, "profit_and_loss" | "balance_sheet" | "cash_flow") { + return Err(format!( + "columns is for profit_and_loss, balance_sheet and cash_flow, not {report}" + )); } -} - -/// The settings that disagree with the books: each names the setting and -/// what to change. A code must name an account in the chart, of the kind -/// the setting needs (cash and investing: assets; financing: liabilities; -/// capital, distributions, close_into, close_sweep: equity). -/// The live conventions that name account `code` (a posting template's -/// line, an approval rule's accounts): after a renumber, each still names -/// the old code until revised. -fn convention_mentions(ledger: &Ledger, code: &str) -> Vec { - convention_codes(ledger) - .into_iter() - .filter(|(_, c)| c == code) - .map(|(id, _)| format!("convention {id} (revise it with books.set_convention)")) - .collect() -} - -/// Every account code a live convention names: (convention id, code). -fn convention_codes(ledger: &Ledger) -> Vec<(String, String)> { - let mut out = Vec::new(); - for c in ledger - .conventions() - .conventions() - .filter(|c| c.retired.is_none()) - { - if let Some(t) = c.template() { - for l in t.lines { - if ledger_core::conventions::slot(&l.account).is_none() { - out.push((c.id.clone(), l.account)); - } - } + if compare.is_some() { + if !matches!( + report, + "profit_and_loss" | "balance_sheet" | "cash_flow" | "tax_summary" + ) { + return Err(format!( + "compare is for profit_and_loss, balance_sheet, cash_flow and tax_summary, not {report}" + )); } - if let Some(r) = c.approval_rule() { - for a in r.accounts { - out.push((c.id.clone(), a)); - } - } - } - out.dedup(); - out -} - -fn settings_problems(ledger: &Ledger, cfg: &Settings) -> Vec { - let mut out = Vec::new(); - // Named as the books keep them. - let how = " with books.set_setting"; - // The cash flow roles: codes the chart lacks here; the rest is the - // engine's own check, the one the statement refuses on (rules.md B-14 - // rule 2), so the two never disagree. - let lists = [ - ( - ledger_core::RoleList::Cash, - "books.cash_accounts", - &cfg.roles.cash, - ), - ( - ledger_core::RoleList::Financing, - "books.financing_accounts", - &cfg.roles.financing, - ), - ( - ledger_core::RoleList::Investing, - "books.investing_accounts", - &cfg.roles.investing, - ), - ]; - let mut ids: [Vec; 3] = Default::default(); - for ((_, setting, codes), ids) in lists.iter().zip(&mut ids) { - for code in codes.iter() { - match ledger.chart().find_by_code(code) { - Some(a) => ids.push(a.id), - None => out.push(format!( - "{setting} names {code}, which isn't in the chart: add that account, or change {setting}{how}" - )), - } - } - } - let named = |l: ledger_core::RoleList| { - lists - .iter() - .find(|(list, _, _)| *list == l) - .map_or(String::new(), |(_, setting, _)| setting.to_string()) - }; - for p in ledger_core::role_problems(ledger, &ids[0], &ids[1], &ids[2]) { - out.push(format!("{}{how}", p.message(named))); - } - let mut check = |setting: &str, code: &str, want: &[AccountType]| { - match ledger.chart().find_by_code(code) { - None => out.push(format!( - "{setting} names {code}, which isn't in the chart: add that account, or change {setting}{how}" - )), - Some(a) if !want.contains(&a.account_type) => out.push(format!( - "{setting} names {code} \"{}\", a {:?} account, but needs {}: change {setting}{how}", - a.name, - a.account_type, - want.iter().map(|t| format!("{t:?}")).collect::>().join(" or "), - )), - Some(_) => {} - } - }; - use AccountType::*; - - let c = &cfg.company; - for m in &c.members { - check( - &format!("{} {} capital_account", "company.members", m.name), - &m.capital_account, - &[Equity], - ); - if let Some(d) = &m.distribution_account { - check( - &format!("{} {} distribution_account", "company.members", m.name), - d, - &[Equity], + if split { + return Err( + "compare is for a report in one total column: a report split into columns shows no comparison" + .into(), ); } } - if let Some(code) = &c.close_into { - check("books.close_into", code, &[Equity]); - } - for code in &c.close_sweep { - check("books.close_sweep", code, &[Equity]); - } - // Conventions naming an account no longer in the chart (renumbered or - // never there): a template can't draft, a rule covers nothing. - for (id, code) in convention_codes(ledger) { - if ledger.chart().find_by_code(&code).is_none() { - out.push(format!( - "convention {id} names account {code}, which isn't in the chart: revise it with books.set_convention" - )); - } - } - out -} - -/// A parameter and its deprecated alias (one release): the value given, -/// noting when it came by the alias; both given and meaning different -/// values (by `same`: an amount, a day or a rule however written) is -/// refused. -fn aliased<'a>( - name: &str, - value: Option<&'a str>, - alias: &str, - old: Option<&'a str>, - notes: &mut Vec, - same: fn(&str, &str) -> bool, -) -> Result, String> { - match (value, old) { - (Some(v), Some(o)) if !same(v, o) => Err(format!( - "{name} {v:?} and its old name {alias} {o:?} disagree: give {name} only" - )), - (Some(v), _) => Ok(Some(v)), - (None, Some(o)) => { - notes.push(format!("{alias} is now {name}")); - Ok(Some(o)) - } - (None, None) => Ok(None), - } -} - -/// Two amounts given for one figure mean the same (`"100"` and -/// `"100.00"`); text that isn't an amount compares as text. -fn same_amount(a: &str, b: &str) -> bool { - match (a.trim().parse::(), b.trim().parse::()) { - (Ok(x), Ok(y)) => x == y, - _ => a == b, - } -} - -/// Two days given for one date mean the same. -fn same_day(a: &str, b: &str) -> bool { - match (parse_date(a), parse_date(b)) { - (Ok(x), Ok(y)) => x == y, - _ => a == b, - } -} - -/// Two basis rules mean the same, by wire or pre-P3 name -/// (`"carryover"` and `"carryover_723"`). -fn same_rule(a: &str, b: &str) -> bool { - let rule = |r: &str| { - ledger_core::BasisRule::parse(r).or_else(|| ledger_core::BasisRule::parse_legacy(r)) - }; - match (rule(a), rule(b)) { - (Some(x), Some(y)) => x == y, - _ => a == b, - } -} - -/// A basis rule from a tool: its wire name, or its pre-P3 name (an alias -/// for one release, noted). -fn parse_basis_rule(rule: &str, notes: &mut Vec) -> Result { - if let Some(r) = ledger_core::BasisRule::parse(rule) { - return Ok(r); - } - match ledger_core::BasisRule::parse_legacy(rule) { - Some(r) => { - notes.push(format!("the rule {rule:?} is now {:?}", r.key())); - Ok(r) - } - None => Err(format!( - "basis rule {rule:?}: expected purchase, carryover_723, or personal_use_conversion" - )), - } -} - -/// A basis record from a tool's facts, checked (AssetBasis::check). A -/// contribution's value at contribution is required: it is a fact the -/// human gives, never assumed from the cost. -fn parse_asset_basis( - rule: &str, - adjusted: Option<&str>, - fmv: Option<&str>, - method: Option<&str>, - source: Option<&BasisSourceIn>, - notes: &mut Vec, -) -> Result { - let rule = parse_basis_rule(rule, notes)?; - if rule.is_contribution() && fmv.is_none() { - return Err(format!( - "a contribution ({}) needs fmv_at_contribution: its fair market value when contributed (ask the human; the books' amount in the contribution entry, if that is what it was worth)", - rule.key() - )); - } - let basis = ledger_core::AssetBasis { - rule, - contributor_adjusted_basis: adjusted.map(parse_amount).transpose()?, - fmv_at_contribution: fmv.map(parse_amount).transpose()?, - sec704c_method: method - .map(|m| { - ledger_core::Sec704cMethod::parse(m).ok_or_else(|| { - format!( - "sec704c_method {m:?}: expected traditional, traditional_with_curative, or remedial" - ) - }) - }) - .transpose()?, - source: source - .map(|s| -> Result<_, String> { - Ok(ledger_core::settings::Source { - decided_by: s.decided_by.clone(), - decided_on: match &s.decided_on { - Some(d) => parse_date(d)?, - None => today(), - }, - citation: s.citation.clone(), - note: s.note.clone(), - }) - }) - .transpose()?, - }; - basis - .check() - .map_err(|why| format!("bad tax basis: {why}"))?; - Ok(basis) -} - -/// An asset's basis facts and figures for a tool's output. -fn basis_out(a: &ledger_core::Asset) -> Result, String> { - let Some(b) = &a.basis else { - return Ok(None); - }; - let Some(f) = a.basis_figures().map_err(|e| e.to_string())? else { - return Ok(None); - }; - use ledger_core::BasisRule; - let loss = f - .built_in_gain_704c - .filter(|g| g.is_sign_negative() && !g.is_zero()); - Ok(Some(AssetBasisOut { - rule: b.rule.key().to_string(), - contributor_adjusted_basis: b.contributor_adjusted_basis.map(money), - fmv_at_contribution: a.fmv_at_contribution().map(money), - fmv_recorded: a.fmv_is_recorded(), - tax_basis_723: f.tax_basis_723.map(money), - depreciable_basis: money(f.depreciable_basis), - built_in_gain_704c: f.built_in_gain_704c.map(money), - sec704c_1c_note: loss.map(|l| { - format!( - "a built-in loss of {}: it counts only for the contributing member(s); for the others the basis is treated as the value at contribution (IRC §704(c)(1)(C))", - money(-l) - ) - }), - sec704c_method: b.sec704c_method.map(|m| m.key().to_string()), - source: b.source.as_ref().map(|s| BasisSourceOut { - decided_by: s.decided_by.clone(), - decided_on: s.decided_on.to_string(), - citation: s.citation.clone(), - note: s.note.clone(), - }), - sources: BasisSourcesOut { - tax_basis_723: b.rule.is_contribution().then(|| "IRC §723".to_string()), - depreciable_basis: match b.rule { - BasisRule::Purchase => "IRC §1012", - BasisRule::Carryover723 => "IRC §723", - BasisRule::PersonalUseConversion => "Treas. Reg. §1.168(i)-4(b)", - } - .to_string(), - built_in_gain_704c: b - .rule - .is_contribution() - .then(|| "Treas. Reg. §1.704-3(a)(3)(ii)".to_string()), - }, - })) -} - -/// An asset named by id or by tag (exactly one). -fn resolve_asset(store: &Store, id: Option, tag: Option<&str>) -> Result { - match (id, tag) { - (Some(id), None) => store - .ledger() - .get_asset(id) - .map(|a| a.id) - .ok_or_else(|| format!("unknown asset {id}")), - (None, Some(tag)) => store - .ledger() - .asset_by_tag(tag) - .map(|a| a.id) - .ok_or_else(|| format!("no asset is tagged {tag:?}")), - _ => Err("name the asset by asset_id or by tag (one of them)".into()), - } -} - -/// An asset named by a string that is its current tag or its id. A tag -/// wins: tags like "1001" are allowed, and the label is what the human -/// reads. -fn resolve_asset_ref(store: &Store, r: &str) -> Result { - if let Some(a) = store.ledger().asset_by_tag(r) { - return Ok(a.id); - } - match r.parse::() { - Ok(id) if store.ledger().get_asset(id).is_some() => Ok(id), - _ => Err(format!("no asset has the tag or id {r:?}")), - } -} - -/// Every control role and the account filling it, for books.roles. -fn roles_out(ledger: &Ledger) -> Vec { - ledger_core::control_accounts() - .map(|c| { - let account = ledger.role_account(c).and_then(|id| ledger.chart().get(id)); - RoleOut { - role: c.key.to_string(), - purpose: c.role.to_string(), - account_type: format!("{:?}", c.account_type), - default_code: c.code.to_string(), - assigned: ledger.role_assignment(c).is_some(), - account_code: account.map(|a| a.code.clone()), - account_name: account.map(|a| a.name.clone()), - } - }) - .collect() -} - -/// Parse a calendar-month period "YYYY-MM". -fn parse_period_mcp(period: &str) -> Result<(i32, u32), String> { - let err = || format!("bad period {period:?}: expected YYYY-MM"); - let mut parts = period.split('-'); - let year: i32 = parts.next().ok_or_else(err)?.parse().map_err(|_| err())?; - let month: u32 = parts.next().ok_or_else(err)?.parse().map_err(|_| err())?; - // A month of the calendar the books keep (ledger_core::calendar). - if parts.next().is_some() || !(1..=9999).contains(&year) || !(1..=12).contains(&month) { - return Err(err()); - } - Ok((year, month)) -} - -/// Last calendar day of a month: refused, never a panic, for one chrono -/// can't reach (P0 review round 3, S1). -fn last_day_of_month(year: i32, month: u32) -> Result { - NaiveDate::from_ymd_opt(year, month, 1) - .and_then(|first| first.checked_add_months(chrono::Months::new(1))) - .and_then(|next| next.pred_opt()) - .ok_or_else(|| format!("bad period {year:04}-{month:02}: no such month")) -} - -fn parse_account_type(s: &str) -> Result { - match s.to_lowercase().as_str() { - "asset" => Ok(AccountType::Asset), - "liability" => Ok(AccountType::Liability), - "equity" => Ok(AccountType::Equity), - "revenue" => Ok(AccountType::Revenue), - "expense" => Ok(AccountType::Expense), - _ => Err(format!( - "bad account_type {s:?}: expected asset, liability, equity, revenue, or expense" - )), - } -} - -/// Resolve an account reference: an account code, or a numeric id. A -/// number that is one account's code and another's id is refused -/// (INVARIANT 6). -fn resolve_account(store: &Store, account: &str) -> Result { - let chart = store.ledger().chart(); - let by_code = chart.find_by_code(account).map(|a| a.id); - let by_id = account - .parse::() - .ok() - .filter(|id| chart.get(*id).is_some()); - match (by_code, by_id) { - (Some(c), Some(i)) if c != i => Err(format!( - "account {account:?} is ambiguous: it is the code of account {c} and the id of account {i}; pass the code of the one you mean" - )), - (Some(c), _) => Ok(c), - (None, Some(i)) => Ok(i), - (None, None) => Err(format!( - "unknown account {account:?}: use an id or code from books.chart" - )), - } -} - -/// A payment's method and reference from its parameters (the method by -/// id or label, books.instruments). -fn payment_details( - store: &Store, - p: &PaymentParams, -) -> Result { - Ok(ledger_core::PaymentDetails { - instrument: p - .instrument - .as_deref() - .map(|r| instruments::resolve_instrument(store, r)) - .transpose()?, - reference: p.reference.clone(), + Ok(match report { + "trial_balance" | "ar_aging" | "ap_aging" | "fixed_asset_register" => ShownDates::AsOf, + "vendor_1099" => ShownDates::Year, + "balance_sheet" if !split && compare != Some("prior_period") => ShownDates::AsOf, + _ => ShownDates::Period, }) } -/// What a payment tool answers: the document's figures after it, derived -/// from its lines that stand (rules.md B-38), as of the end of the books. -fn payment_out( - ledger: &Ledger, - doc: ledger_core::DocRef, - entry_id: u64, -) -> Result, String> { - let st = ledger - .doc_state(doc, chrono::NaiveDate::MAX) - .map_err(|e| e.to_string())? - .ok_or_else(|| format!("{} not found", doc_label(doc)))?; - Ok(Json(PaymentOut { - entry_id, - remaining: money(st.outstanding), - status: st.status.key().to_string(), - })) -} - -/// "invoice" or "bill" and an id, as a document. -fn doc_ref(kind: &str, id: u64) -> Result { - match kind.trim().to_ascii_lowercase().as_str() { - "invoice" => Ok(ledger_core::DocRef::Invoice(id)), - "bill" => Ok(ledger_core::DocRef::Bill(id)), - other => Err(format!("bad kind {other:?}: expected invoice or bill")), - } -} - -/// A document's id. -fn doc_id(doc: ledger_core::DocRef) -> u64 { - match doc { - ledger_core::DocRef::Invoice(id) | ledger_core::DocRef::Bill(id) => id, - } -} - -/// A document as books.documents and books.document name it -/// ("invoice:7", "bill:12"). -fn doc_ref_text(doc: ledger_core::DocRef) -> String { - match doc { - ledger_core::DocRef::Invoice(id) => format!("invoice:{id}"), - ledger_core::DocRef::Bill(id) => format!("bill:{id}"), - } -} - /// "invoice:7" or "bill:12" as a document. fn parse_doc_ref(r: &str) -> Result { let bad = || format!("bad ref {r:?}: expected invoice:N or bill:N (from books.documents)"); @@ -1602,104 +1038,6 @@ fn parse_doc_ref(r: &str) -> Result { doc_ref(kind, id).map_err(|_| bad()) } -/// A tagged line's movement on the wire. -fn movement_key(m: ledger_core::Movement) -> &'static str { - match m { - ledger_core::Movement::Origin => "origin", - ledger_core::Movement::Payment => "payment", - ledger_core::Movement::Discount => "discount", - ledger_core::Movement::WriteOff => "write_off", - ledger_core::Movement::Adopted => "adopted", - ledger_core::Movement::RoleMove => "role_move", - } -} - -/// A document's kind and id for people ("invoice 7"). -fn doc_label(doc: ledger_core::DocRef) -> String { - match doc { - ledger_core::DocRef::Invoice(id) => format!("invoice {id}"), - ledger_core::DocRef::Bill(id) => format!("bill {id}"), - } -} - -/// What the digest lists from the control check: each line on a control -/// account that is no document's, and each document needing attention. -fn control_problems(out: &ControlCheckOut) -> Vec { - let mut v = Vec::new(); - for a in &out.accounts { - for u in &a.unattached { - v.push(format!( - "{} {}: entry {} line {} ({}, {}) is no invoice's or bill's", - a.code, a.name, u.entry_id, u.line, u.date, u.amount - )); - } - for d in &a.attention { - v.push(format!( - "{} {}: {} {} is {} (total {}, owes {})", - a.code, a.name, d.kind, d.id, d.status, d.total, d.outstanding - )); - } - } - v -} - -/// The control check as `books.control_check` answers it. -fn control_check_out( - ledger: &Ledger, - as_of: chrono::NaiveDate, - checks: &[ledger_core::ControlCheck], -) -> ControlCheckOut { - let accounts: Vec = checks - .iter() - .map(|c| { - let acct = ledger.chart().get(c.account); - ControlCheckRowOut { - role: c.role.clone(), - code: acct.map(|a| a.code.clone()).unwrap_or_default(), - name: acct.map(|a| a.name.clone()).unwrap_or_default(), - balance: money(c.balance), - documents_total: money(c.documents_total), - unattached: c - .unattached - .iter() - .map(|u| UnattachedLineOut { - entry_id: u.entry, - line: u.line, - date: u.date.to_string(), - amount: money(u.amount), - }) - .collect(), - difference: money(c.difference), - attention: c - .attention - .iter() - .map(|a| { - let (kind, id) = match a.doc { - ledger_core::DocRef::Invoice(id) => ("invoice", id), - ledger_core::DocRef::Bill(id) => ("bill", id), - }; - DocAttentionOut { - kind: kind.to_string(), - id, - status: a.status.key().to_string(), - total: money(a.total), - outstanding: money(a.outstanding), - } - }) - .collect(), - } - }) - .collect(); - let agrees = checks - .iter() - .all(|c| c.difference.is_zero() && c.unattached.is_empty() && c.attention.is_empty()); - ControlCheckOut { - as_of: as_of.to_string(), - agrees, - accounts, - } -} - /// Resolve a vendor by id or name (case-insensitive); a number that is /// one vendor's id and another's name is refused (INVARIANT 6). fn resolve_vendor(store: &Store, vendor: &str) -> Result { @@ -1732,29 +1070,6 @@ fn resolve_vendor(store: &Store, vendor: &str) -> Result { } } -/// Resolve a party by id or by name: the name ignoring case and -/// surrounding space, among parties not archived (an archived party goes -/// by id). A number that is one party's id and another's name is refused -/// (INVARIANT 6). -fn resolve_party(store: &Store, party: &str) -> Result { - let ledger = store.ledger(); - let by_id = party - .trim() - .parse::() - .ok() - .filter(|id| ledger.get_party(*id).is_some()); - let by_name = ledger.party_by_name(party).map(|p| p.id); - match (by_id, by_name) { - (Some(i), Some(n)) if i != n => Err(format!( - "party {party:?} is ambiguous: it is party {i}'s id and party {n}'s name; pass the id" - )), - (Some(i), _) | (None, Some(i)) => Ok(i), - (None, None) => Err(format!( - "unknown party {party:?}: use an id or name from books.parties (an archived party goes by id), or add it with books.add_party" - )), - } -} - /// Mask any full TIN in a refusal (`ledger_core::parties::redact_full_tins`): /// a refusal that quotes its input (an unknown party, a bad amount, a /// parameter of the wrong type) never echoes a TIN the agent put in the @@ -1892,774 +1207,6 @@ fn resource_list() -> Vec { /// The subject kinds, for messages. const SUBJECT_KINDS: &str = "entry, account, asset, party, project, invoice, bill, period, payroll_run, schedule, reconciliation, company"; -/// Resolve a tool's subject: its kind, and its id resolved the way the -/// other tools resolve references (an account by code or id, an asset by -/// tag or id, a party by id or name), checked to exist. -fn resolve_subject(store: &Store, s: &SubjectParam) -> Result { - use ledger_store::Subject; - let kind = s.kind.trim(); - let text = match &s.id { - None => None, - Some(RefId::Number(n)) => Some(n.to_string()), - Some(RefId::Text(t)) => Some(t.trim().to_string()), - }; - if Subject::from_parts(kind, 0).is_none() { - return Err(format!( - "unknown subject kind {kind:?}: expected one of {SUBJECT_KINDS}" - )); - } - if kind == "company" { - return match text { - None => Ok(Subject::Company), - Some(_) => Err("the company subject takes no id".into()), - }; - } - let t = text.ok_or_else(|| format!("a {kind} subject needs its id"))?; - let sub = match kind { - "account" => Subject::Account(resolve_account(store, &t)?), - "asset" => Subject::Asset(resolve_asset_ref(store, &t)?), - "party" => Subject::Party(resolve_party(store, &t)?), - // A JSON number is an id, never a name. - "project" => match &s.id { - Some(RefId::Number(n)) => Subject::Project(*n), - _ => Subject::Project(u64::from(resolve_class(store, &t)?)), - }, - k => { - let id = t - .parse::() - .map_err(|_| format!("a {k} subject's id is a number, got {t:?}"))?; - Subject::from_parts(k, id).expect("kind checked") - } - }; - if !store.subject_exists(sub).map_err(|e| e.to_string())? { - return Err(format!("{} doesn't exist", sub.describe())); - } - Ok(sub) -} - -/// A subject for a tool's output, with human words for it. -fn subject_out(store: &Store, sub: ledger_store::Subject) -> SubjectOut { - use ledger_store::Subject as S; - let l = store.ledger(); - let label = match sub { - S::Entry(i) => l - .get_entry(i) - .map(|e| format!("entry #{i} {}", e.description())), - S::Account(i) => l - .chart() - .get(i) - .map(|a| format!("account {} {}", a.code, a.name)), - S::Asset(i) => l.get_asset(i).map(|a| match &a.tag { - Some(t) => format!("asset {t} {}", a.description), - None => format!("asset {i} {}", a.description), - }), - S::Party(i) => l.get_party(i).map(|p| format!("party {i} {}", p.label())), - S::Project(i) => u32::try_from(i) - .ok() - .and_then(|c| l.get_class(c)) - .map(|c| format!("project {i} {}", c.name)), - S::Period(i) => l - .list_periods() - .iter() - .find(|p| p.id == i) - .map(|p| format!("period {}", p.name)), - S::PayrollRun(i) => l - .get_payroll_run(i) - .map(|r| format!("payroll run {i} of {}", r.date)), - S::Schedule(i) => l - .get_schedule(i) - .map(|s| format!("schedule {i} {}", s.description)), - S::Reconciliation(i) => store.reconciliations(None).ok().and_then(|rs| { - rs.into_iter().find(|r| r.id == i).map(|r| { - let code = l - .chart() - .get(r.account_id) - .map(|a| a.code.clone()) - .unwrap_or_default(); - format!("reconciliation {i} of {code} through {}", r.statement_date) - }) - }), - S::Invoice(_) | S::Bill(_) | S::Company => None, - } - .unwrap_or_else(|| sub.describe()); - SubjectOut { - kind: sub.kind().to_string(), - id: (sub != S::Company).then(|| sub.id()), - label, - } -} - -/// Everything an attachment is linked to, for a tool's output. -fn links_out(store: &Store, attachment_id: u64) -> Result, String> { - Ok(store - .attachment_links(attachment_id) - .map_err(|e| e.to_string())? - .into_iter() - .map(|s| subject_out(store, s)) - .collect()) -} - -/// A stored attachment's metadata, by id. -fn find_attachment(store: &Store, id: u64) -> Result { - store - .list_attachments(None) - .map_err(|e| e.to_string())? - .into_iter() - .find(|a| a.id == id) - .ok_or_else(|| format!("unknown attachment {id}")) -} - -/// An attachment for a listing, with its links. -fn receipt_out(store: &Store, a: ledger_store::Attachment) -> Result { - Ok(ReceiptOut { - links: links_out(store, a.id)?, - attachment_id: a.id, - sha256: a.blob_sha, - entry_id: a.entry_id, - category: a.category, - file_name: a.file_name, - mime_type: a.mime_type, - size_bytes: a.size_bytes, - description: a.description, - withdrawn: a.withdrawn_at.is_some(), - withdrawn_reason: a.withdrawn_reason, - }) -} - -/// Check every `link_to` subject before anything is filed: each resolves, -/// its journal feature is declared, none twice, none the entry the file is -/// filed on (all or nothing). -fn check_link_to( - store: &Store, - filed_on: Option, - link_to: &[SubjectParam], -) -> Result, String> { - let mut out: Vec = Vec::new(); - for s in link_to { - let sub = resolve_subject(store, s)?; - // A link to a subject kind an older version doesn't know needs its - // journal feature, asked here, before the file is filed: a refusal - // leaves nothing half-filed (rules.md B-27). - if let Some(f) = Store::subject_feature(sub) { - store.ensure_feature(f).map_err(|e| e.to_string())?; - } - if out.contains(&sub) { - return Err(format!("link_to names {} twice", sub.describe())); - } - if filed_on.map(ledger_store::Subject::Entry) == Some(sub) { - return Err(format!( - "link_to names {}, which it is filed on already", - sub.describe() - )); - } - out.push(sub); - } - Ok(out) -} - -/// An account's code and name. -fn code_name(store: &Store, id: AccountId) -> (String, String) { - store - .ledger() - .chart() - .get(id) - .map(|a| (a.code.clone(), a.name.clone())) - .unwrap_or_default() -} - -/// A recorded statement for a tool's output. -fn statement_out(store: &Store, st: &ledger_store::Statement) -> Result { - let (account_code, account_name) = code_name(store, st.account_id); - let file_name = find_attachment(store, st.attachment_id)?.file_name; - let reconciliation_id = store - .reconciliations(Some(st.account_id)) - .map_err(|e| e.to_string())? - .into_iter() - .find(|r| r.attachment_id == st.attachment_id && r.voided.is_none()) - .map(|r| r.id); - Ok(StatementOut { - attachment_id: st.attachment_id, - file_name, - account_code, - account_name, - period_start: st.period_start.to_string(), - period_end: st.period_end.to_string(), - opening_balance: money(st.opening_balance), - closing_balance: money(st.closing_balance), - reconciliation_id, - }) -} - -/// A recorded reconciliation for a tool's output. -fn reconciliation_out(store: &Store, r: &ledger_store::Reconciliation) -> ReconciliationOut { - let (account_code, account_name) = code_name(store, r.account_id); - ReconciliationOut { - reconciliation_id: r.id, - account_code, - account_name, - statement_date: r.statement_date.to_string(), - statement_balance: money(r.statement_balance), - statement_attachment: r.attachment_id, - cleared: r - .cleared - .iter() - .map(|&(entry_id, line_index)| ClearedRefOut { - entry_id, - line_index, - }) - .collect(), - voided: r.voided.is_some(), - voided_reason: r.voided.clone(), - } -} - -/// The latest statement date an account is reconciled through -/// (reconciliations not voided), if any. -fn reconciled_through(store: &Store, account: AccountId) -> Result, String> { - Ok(store - .reconciliations(Some(account)) - .map_err(|e| e.to_string())? - .into_iter() - .filter(|r| r.voided.is_none()) - .map(|r| r.statement_date) - .max()) -} - -/// A line of a reconciliation report. -fn reconcile_line( - entry_id: u64, - line_index: usize, - date: NaiveDate, - description: &str, - side: Side, - amount: Decimal, -) -> ReconcileLineOut { - ReconcileLineOut { - entry_id, - line_index, - date: date.to_string(), - description: description.to_string(), - side: match side { - Side::Debit => "debit", - Side::Credit => "credit", - } - .to_string(), - amount: money(amount), - } -} - -/// The last four digits of every TIN the books know of, from the journal -/// (each party's TIN and W-9 facts), never the vault: the filing scan's -/// probe bound (tin_files.rs INVARIANT 4). -fn known_tin_last4(store: &Store) -> Vec { - store - .ledger() - .parties() - .flat_map(|p| { - [ - p.tin.as_ref().map(|t| t.last4.as_str()), - p.w9.as_ref().map(|w| w.tin_last4.as_str()), - ] - }) - .flatten() - .filter_map(|l| l.parse().ok()) - .collect() -} - -/// A TIN type from a tool: ssn, ein, or itin. -fn parse_tin_type(s: &str) -> Result { - match s.trim().to_lowercase().as_str() { - "ssn" => Ok(ledger_vault::TinType::Ssn), - "ein" => Ok(ledger_vault::TinType::Ein), - "itin" => Ok(ledger_vault::TinType::Itin), - _ => Err("tin_type is ssn, ein, or itin".into()), - } -} - -/// Parse role names (`Role::key`), refusing any unknown one. -fn parse_roles(roles: &[String]) -> Result, String> { - roles - .iter() - .map(|r| { - ledger_core::Role::parse(r.trim()).ok_or_else(|| { - format!( - "unknown role {r:?}: expected one of {}", - ledger_core::Role::ALL.map(|r| r.key()).join(", ") - ) - }) - }) - .collect() -} - -/// A party's roles (vendor and customer included), in display order. -fn party_roles(p: &ledger_core::Party) -> Vec { - let all = p.all_roles(); - ledger_core::Role::ALL - .into_iter() - .filter(|r| all.contains(r)) - .map(|r| r.key().to_string()) - .collect() -} - -/// A party's facts for a tool's output. -fn party_out(ledger: &Ledger, p: &ledger_core::Party) -> PartyOut { - PartyOut { - party_id: p.id, - name: p.name.clone(), - label: p.label().to_string(), - display_name: p.display_name.clone(), - roles: party_roles(p), - website: p.website.clone(), - address: p.address.clone(), - notes: p.notes.clone(), - archived: p.archived, - vendor_id: p.vendor, - customer_id: p.customer, - w9: p.w9.as_ref().map(|w| W9Out { - received_on: w.received.to_string(), - entity_type: w.entity_type.clone(), - exempt_payee_code: w.exempt_payee_code.clone(), - fatca_code: w.fatca_code.clone(), - tin_last4: w.tin_last4.clone(), - kept_at: w.kept_at.clone(), - in_vault: w.vault_record.is_some(), - }), - w4: p.w4.as_ref().map(|w| W4Out { - received_on: w.received.to_string(), - state: w.state.clone(), - kept_at: w.kept_at.clone(), - }), - tin: p.tin.as_ref().map(|t| TinOut { - tin_type: t.tin_type.key().to_string(), - last4: t.last4.clone(), - in_vault: t.vault_record.is_some(), - purged: t.purged.clone(), - }), - creditor_of: p - .creditor_of - .iter() - .filter_map(|a| ledger.chart().get(*a).map(|a| a.code.clone())) - .collect(), - } -} - -/// An optional party text from a tool: trimmed, and "" clears it. -fn optional_text(s: &str) -> Option { - let t = s.trim(); - (!t.is_empty()).then(|| t.to_string()) -} - -/// A document's status for a tool's output. -fn status_key(s: DocumentStatus) -> String { - match s { - DocumentStatus::Open => "open", - DocumentStatus::Partial => "partial", - DocumentStatus::Paid => "paid", - } - .to_string() -} - -/// An invoice or bill for `books.party`: its (date, due date), (total, -/// paid so far), status, and entry. -fn party_document( - id: u64, - (date, due): (NaiveDate, NaiveDate), - (total, balance, paid): ( - Result, - Result, - Decimal, - ), - status: DocumentStatus, - entry_id: u64, -) -> Result { - Ok(PartyDocumentOut { - id, - date: date.to_string(), - due_date: due.to_string(), - total: money(total.map_err(|e| e.to_string())?), - paid: money(paid), - balance: money(balance.map_err(|e| e.to_string())?), - status: status_key(status), - entry_id, - }) -} - -/// The sum of `values`, each computed or refused: refused (as text) when -/// one is, or when the sum is past exact arithmetic. Figures an earlier -/// version journaled are as journaled (rules.md B-28). -fn checked_sum( - values: impl IntoIterator>, - what: &str, -) -> Result { - values.into_iter().try_fold(Decimal::ZERO, |sum, v| { - let v = v.map_err(|e| e.to_string())?; - sum.checked_add(v).ok_or_else(|| { - ledger_core::LedgerError::TooLargeToCompute(what.to_string()).to_string() - }) - }) -} - -/// Everything the books did with one party (`PartyTotalsOut` defines it). -struct PartyActivity { - /// The party's entries, drafts included. - entries: std::collections::BTreeSet, - /// Paid to it in the year, on the basis asked. - paid: Decimal, - /// Received from it in the year, on the basis asked. - received: Decimal, -} - -/// A party-totals `basis` from a tool: "cash" (the default) or "all" -/// (rules.md B-35 item 4); any other word is refused. -fn parse_basis(basis: Option<&str>) -> Result { - match basis { - None => Ok(ledger_core::parties::Basis::default()), - Some(b) => ledger_core::parties::Basis::parse(b) - .ok_or_else(|| format!("basis is \"cash\" or \"all\", not {b:?}")), - } -} - -/// The party's entries and the year's paid and received on `basis` -/// (`PartyTotalsOut`): ledger_core::parties::activity_with over the -/// calendar year, with the money accounts of [`share_context`] and the -/// members (so an entry naming several parties counts this one's part). -fn party_activity( - store: &Store, - party: &ledger_core::Party, - settings: &Settings, - year: i32, - basis: ledger_core::parties::Basis, -) -> Result { - let ledger = store.ledger(); - let span = ( - NaiveDate::from_ymd_opt(year, 1, 1).ok_or("year out of range")?, - NaiveDate::from_ymd_opt(year, 12, 31).ok_or("year out of range")?, - ); - let (money, members) = share_context(ledger, settings); - let a = ledger_core::parties::activity_with(ledger, party.id, span, &money, &members); - let (paid, received) = a.totals(basis); - Ok(PartyActivity { - entries: a.entries, - paid, - received, - }) -} - -/// The `party_required` refusal for an engine-posted entry (payroll, a -/// remittance, a disposal) that names no counterparty under `[books] -/// parties = "require"`: `what` says what to pass. -fn party_required(what: &str) -> String { - serde_json::json!({ - "code": "party_required", - "message": format!( - "these books require a counterparty on every entry (their parties setting is \"require\"): {what} (add the party with books.add_party first if it's new)" - ), - }) - .to_string() -} - -/// The chart's ids for these account codes (codes it lacks are skipped). -fn cash_ids(ledger: &Ledger, codes: &[String]) -> Vec { - codes - .iter() - .filter_map(|c| ledger.chart().find_by_code(c).map(|a| a.id)) - .collect() -} - -/// What party shares resolve with ([`ledger_core::shares::resolve`]): the -/// money accounts over the `[books] cash_accounts` and -/// `financing_accounts` (with the cards), and the members. -fn share_context( - ledger: &Ledger, - settings: &Settings, -) -> ( - ledger_core::parties::MoneyAccounts, - Vec, -) { - ( - ledger_core::parties::money_accounts( - ledger, - &cash_ids(ledger, &settings.roles.cash), - &cash_ids(ledger, &settings.roles.financing), - ), - settings.company.party_members(ledger), - ) -} - -/// `parties` params as shares: each party resolved, `amount` or `percent` -/// parsed (not both), neither meaning derived. -fn parse_shares( - store: &Store, - params: &[PartyShareParam], -) -> Result, String> { - params - .iter() - .map(|p| { - let party = resolve_party(store, &p.party)?; - let share = match (&p.amount, &p.percent) { - (Some(_), Some(_)) => { - return Err(format!( - "party {}: give an amount or a percent, not both", - p.party - )); - } - (Some(a), None) => ledger_core::Share::Amount(parse_amount(a)?), - (None, Some(pc)) => ledger_core::Share::Percent(parse_amount(pc)?), - (None, None) => ledger_core::Share::Derived, - }; - Ok(ledger_core::PartyShare { party, share }) - }) - .collect() -} - -/// Whether an entry's lines are final enough to check shares against: -/// posted, or a draft with lines whose debits equal its credits. -fn lines_settled(entry: &JournalEntry) -> bool { - if entry.status() == ledger_core::EntryStatus::Posted { - return true; - } - // A draft's lines are as journaled, of any size (rules.md B-28): sides - // past exact arithmetic are not settled lines. - let side = |s: Side| -> Option { - entry - .lines() - .iter() - .filter(|l| l.side == s) - .try_fold(Decimal::ZERO, |sum, l| sum.checked_add(l.amount.amount)) - }; - match (side(Side::Debit), side(Side::Credit)) { - (Some(debits), Some(credits)) => !entry.lines().is_empty() && debits == credits, - _ => false, - } -} - -/// Refuse shares that don't resolve against the entry's lines, once the -/// lines are settled ([`lines_settled`]). -fn check_shares_resolve( - store: &Store, - settings: &Settings, - entry_id: u64, - shares: &[ledger_core::PartyShare], -) -> Result<(), String> { - let ledger = store.ledger(); - let entry = ledger - .get_entry(entry_id) - .ok_or_else(|| format!("unknown entry {entry_id}"))?; - if !lines_settled(entry) { - return Ok(()); - } - let (money, members) = share_context(ledger, settings); - ledger_core::shares::resolve(ledger, entry_id, shares, &money, &members).map(|_| ()) -} - -/// Fix a posted entry's derived split among several parties: journal the -/// exact amounts they resolve to now, so a later change to -/// `[[company.members]]` (a new income split, a member archived) never -/// re-splits posted history. -/// -/// ```text -/// GOAL: posted is forever, the split between its parties included. -/// SOURCE: AGENTS.md ("Posted is forever"); docs/parties-and-attachments.md -/// "Shares"; ledger_core::shares INVARIANT 10 (derived several: -/// the members' capital lines, else their income shares, both read -/// from the settings). -/// INVARIANTS: -/// 1. Only a posted entry naming two or more parties with derived shares -/// changes: its shares become Amount(|part|) for each party, in -/// order, journaled as entry.parties_set (the reversal chain too), so -/// each party's resolved part is exactly what it was. -/// 2. Nothing changes when the shares don't resolve, or a part is zero -/// (an amount must be positive): the entry keeps its derived shares. -/// ``` -fn fix_derived_shares(store: &mut Store, settings: &Settings, entry_id: u64) -> Result<(), String> { - let ledger = store.ledger(); - let Some(entry) = ledger.get_entry(entry_id) else { - return Ok(()); - }; - let shares = ledger.entry_parties(entry_id); - if entry.status() != ledger_core::EntryStatus::Posted - || shares.len() < 2 - || !shares - .iter() - .any(|s| matches!(s.share, ledger_core::Share::Derived)) - { - return Ok(()); - } - let (money, members) = share_context(ledger, settings); - let Ok(resolved) = ledger_core::shares::resolve(ledger, entry_id, shares, &money, &members) - else { - return Ok(()); - }; - if resolved.parts.iter().any(|p| p.amount.is_zero()) { - return Ok(()); - } - let fixed: Vec = resolved - .parts - .iter() - .map(|p| ledger_core::PartyShare { - party: p.party, - share: ledger_core::Share::Amount(p.amount.abs()), - }) - .collect(); - store - .set_entry_parties(entry_id, &fixed) - .map_err(|e| e.to_string()) -} - -/// An entry's parties for output, each share resolved, and why they don't -/// resolve when they don't. -fn entry_parties_out( - store: &Store, - settings: &Settings, - entry: &JournalEntry, -) -> (Vec, Option) { - let ledger = store.ledger(); - let shares = ledger.entry_parties(entry.id()); - let (accounts, members) = share_context(ledger, settings); - let (resolved, error) = - match ledger_core::shares::resolve(ledger, entry.id(), shares, &accounts, &members) { - Ok(r) => (Some(r), None), - Err(e) => (None, Some(e)), - }; - let out = shares - .iter() - .enumerate() - .map(|(i, s)| { - let (kind, amount, percent) = match s.share { - ledger_core::Share::Amount(a) => ("amount", Some(money(a)), None), - ledger_core::Share::Percent(p) => { - ("percent", None, Some(p.normalize().to_string())) - } - ledger_core::Share::Derived => ("derived", None, None), - }; - EntryPartyOut { - party_id: s.party, - party: ledger - .get_party(s.party) - .map(|p| p.label().to_string()) - .unwrap_or_else(|| format!("party {}", s.party)), - share: kind.to_string(), - amount, - percent, - resolved: resolved.as_ref().map(|r| money(r.parts[i].amount)), - direction: resolved - .as_ref() - .map(|r| r.target.direction.key().to_string()), - cash: resolved.as_ref().map(|r| money(r.parts[i].cash)), - in_kind: resolved.as_ref().map(|r| money(r.parts[i].in_kind)), - } - }) - .collect(); - (out, error) -} - -/// The party's customer record's invoices, by id. -fn party_invoices<'a>( - ledger: &'a Ledger, - party: &ledger_core::Party, -) -> Vec<&'a ledger_core::Invoice> { - let Some(c) = party.customer else { - return Vec::new(); - }; - ledger - .invoices() - .into_iter() - .filter(|i| i.customer_id == c) - .collect() -} - -/// The party's vendor record's bills, by id. -fn party_bills<'a>(ledger: &'a Ledger, party: &ledger_core::Party) -> Vec<&'a ledger_core::Bill> { - let Some(v) = party.vendor else { - return Vec::new(); - }; - ledger - .bills() - .into_iter() - .filter(|b| b.vendor_id == v) - .collect() -} - -/// Resolve a class (a project) by id or name, trimmed, the name ignoring -/// capitals as the duplicate check does; a number that is one class's id -/// and another's name is refused (INVARIANT 6). New names can't be digits -/// only, so only old books can hold such a pair. -fn resolve_class(store: &Store, class: &str) -> Result { - let class = class.trim(); - let by_id = class - .parse::() - .ok() - .filter(|id| store.ledger().get_class(*id).is_some()); - let by_name = store.ledger().find_class_by_name(class).map(|c| c.id); - match (by_id, by_name) { - (Some(i), Some(n)) if i != n => { - let name = store - .ledger() - .get_class(i) - .map(|c| c.name.clone()) - .unwrap_or_default(); - Err(format!( - "project {class:?} is ambiguous: it is project {i}'s id and project {n}'s name; pass {n} for project {n}, or {name:?} (project {i}'s name) for project {i}" - )) - } - (Some(i), _) | (None, Some(i)) => Ok(i), - (None, None) => Err(format!( - "unknown project {class:?}: use an id or name from books.projects" - )), - } -} - -/// An invoice's or bill's lines as the tool takes them: each amount -/// parsed and each project resolved ([`resolve_class`]) before anything is -/// written. -fn document_lines( - store: &Store, - lines: Vec, -) -> Result, String> { - lines - .into_iter() - .map(|l| { - Ok(DocumentLine { - description: l.description, - amount: parse_amount(&l.amount)?, - class_id: l - .class - .as_deref() - .map(|c| resolve_class(store, c)) - .transpose()?, - account: None, - quantity: None, - unit_price: None, - }) - }) - .collect() -} - -/// A document's notes, each project once, in the order first named: its -/// late-tag note ([`projects::late_tag_note`]) for a finished or abandoned -/// project, and on an invoice ([`projects::on_spec_invoice_note`]) one for -/// an on-spec project (rules.md B-37 items 4, 5 and 8). -fn document_notes( - ledger: &ledger_core::Ledger, - lines: &[DocumentLine], - invoice: bool, -) -> Vec { - let mut named: Vec = Vec::new(); - for c in lines.iter().filter_map(|l| l.class_id) { - if !named.contains(&c) { - named.push(c); - } - } - let mut notes = Vec::new(); - for c in named { - notes.extend(projects::late_tag_note(ledger, c)); - if invoice { - notes.extend(projects::on_spec_invoice_note(ledger, c)); - } - } - notes -} - /// Preview lines with resulting balances for raw (account, side, amount) lines. fn preview_lines_for( store: &Store, @@ -2706,48 +1253,6 @@ fn preview_lines_for( Ok(out) } -/// Preview lines with resulting balances for an entry. -fn preview_lines(store: &Store, entry: &JournalEntry) -> Result, String> { - let lines: Vec<(AccountId, Side, Money)> = entry - .lines() - .iter() - .map(|l| (l.account, l.side, l.amount)) - .collect(); - preview_lines_for(store, &lines) -} - -fn preview_receipts(store: &Store, entry_id: u64) -> Result, String> { - Ok(store - .list_attachments(Some(entry_id)) - .map_err(|e| e.to_string())? - .into_iter() - .map(|a| PreviewReceipt { - attachment_id: a.id, - file_name: a.file_name, - sha256: a.blob_sha, - }) - .collect()) -} - -/// Net movement of one account over entries in `[from, to]`, normal side. -fn movement_in(ledger: &Ledger, account: AccountId, from: NaiveDate, to: NaiveDate) -> Decimal { - let movement: Decimal = ledger - .posted_entries() - .filter(|e| e.date() >= from && e.date() <= to) - .flat_map(|e| e.lines()) - .filter(|l| l.account == account) - .map(|l| match l.side { - Side::Debit => l.amount.amount, - Side::Credit => -l.amount.amount, - }) - .sum(); - let account = ledger.chart().get(account).expect("account exists"); - match account.account_type.normal_side() { - Side::Debit => movement, - Side::Credit => -movement, - } -} - thread_local! { /// The token of the request running on this thread (books served from /// the data root), its id and name, for what is bound to it (its @@ -2806,38 +1311,6 @@ fn digest_since(query: &str, today: NaiveDate) -> String { }) } -/// A tool description that ends with the one sentence for its kind of -/// dates (rules.md B-30), so no tool says other than the resolver does. -fn described(what: &str, kind: ledger_core::calendar::PeriodKind) -> String { - format!("{what} {}", kind.describe()) -} - -/// The warning for a revenue or expense account created without a tax -/// category (rules.md B-32). -fn uncategorized_warning(code: &str, name: &str) -> String { - format!( - "{} {} 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.", - code.trim(), - name.trim() - ) -} - -/// The warnings for the revenue and expense accounts an operation created -/// (control accounts: Revenue, General Expenses, payroll's expenses) -/// without a tax category: every account from `first_new` on. -fn created_uncategorized(ledger: &Ledger, first_new: ledger_core::AccountId) -> Vec { - let mut new: Vec<&ledger_core::Account> = ledger - .chart() - .all() - .filter(|a| a.id >= first_new && a.tax_category.is_none()) - .filter(|a| ledger_core::tax::needs_category(a.account_type, a.placeholder)) - .collect(); - new.sort_by_key(|a| a.id); - new.into_iter() - .map(|a| uncategorized_warning(&a.code, &a.name)) - .collect() -} - /// How the viewer reads a report's dates (ledger-gui pages/reports.rs): /// over a period, as of its end, or for its end's calendar year. #[derive(Clone, Copy, Debug, PartialEq, Eq)] @@ -2850,116 +1323,6 @@ enum ShownDates { Year, } -/// How the viewer reads `report`'s dates with these options, or why it -/// would ignore one of them (`books.show` refuses what its target -/// ignores). Splits are the P&L's, the balance sheet's and the cash -/// flow's; comparisons theirs and the tax summary's, in one total column; -/// a balance sheet in one total column is as of its end, unless compared -/// with the period before (which starts the day before start). -fn show_report_dates( - report: &str, - columns: Option<&str>, - compare: Option<&str>, -) -> Result { - let split = columns.is_some_and(|c| c != "total"); - if columns.is_some() && !matches!(report, "profit_and_loss" | "balance_sheet" | "cash_flow") { - return Err(format!( - "columns is for profit_and_loss, balance_sheet and cash_flow, not {report}" - )); - } - if compare.is_some() { - if !matches!( - report, - "profit_and_loss" | "balance_sheet" | "cash_flow" | "tax_summary" - ) { - return Err(format!( - "compare is for profit_and_loss, balance_sheet, cash_flow and tax_summary, not {report}" - )); - } - if split { - return Err( - "compare is for a report in one total column: a report split into columns shows no comparison" - .into(), - ); - } - } - Ok(match report { - "trial_balance" | "ar_aging" | "ap_aging" | "fixed_asset_register" => ShownDates::AsOf, - "vendor_1099" => ShownDates::Year, - "balance_sheet" if !split && compare != Some("prior_period") => ShownDates::AsOf, - _ => ShownDates::Period, - }) -} - -fn schedule_out(ledger: &Ledger, s: &ledger_core::AmortizationSchedule) -> ScheduleOut { - // A schedule an earlier version journaled may run past the last date - // chrono holds (rules.md B-28): shown, with why its plan can't be. - let plan = s.plan(); - let code = |id| { - ledger - .chart() - .get(id) - .map(|a| a.code.clone()) - .unwrap_or_default() - }; - ScheduleOut { - schedule_id: s.id, - description: s.description.clone(), - kind: match s.kind { - ledger_core::ScheduleKind::PrepaidExpense => "prepaid_expense", - ledger_core::ScheduleKind::DeferredRevenue => "deferred_revenue", - } - .to_string(), - balance_account: code(s.balance_account), - pl_account: code(s.pl_account), - total: money(s.total.amount), - convention: s.convention.key().to_string(), - start: s.first_day().to_string(), - end: s.last_day().map(|d| d.to_string()), - next: s.next().ok().flatten().map(|d| d.to_string()), - remaining: plan.as_ref().ok().and(s.remaining().ok()).map(money), - months: plan - .as_ref() - .map(|plan| { - plan.iter() - .map(|(d, a)| ScheduleMonthOut { - date: d.to_string(), - amount: money(*a), - posted: s.posted.contains(d), - }) - .collect() - }) - .unwrap_or_default(), - // A null remaining is always explained (ScheduleOut::plan_error). - plan_error: plan - .err() - .or_else(|| s.remaining().err()) - .map(|e| e.to_string()), - } -} - -fn recurring_out(r: &ledger_core::RecurringEntry) -> RecurringOut { - RecurringOut { - recurring_id: r.id, - description: r.description.clone(), - frequency: format!("{:?}", r.frequency).to_lowercase(), - start: r.start.to_string(), - end: r.end.map(|e| e.to_string()), - generated: r.generated.iter().map(|d| d.to_string()).collect(), - } -} - -fn lines_out(lines: &[ledger_core::StatementLine]) -> Vec { - lines - .iter() - .map(|l| StatementLineOut { - code: l.code.clone(), - name: l.name.clone(), - amount: money(l.amount), - }) - .collect() -} - // --------------------------------------------------------------------------- // Tools // --------------------------------------------------------------------------- @@ -2978,7188 +1341,43 @@ mod kept; mod projects; pub use kept::SettingKeyOut; -#[tool_router(router = core_router)] -impl BooksServer { - #[tool( - name = "books.handbook", - title = "Bookkeeping handbook", - description = "The guide to keeping these books: every procedure and rule, by section. Read the contract section first, then each section you need, before bookkeeping. Without section: the index; with section: that section's text (also at books://handbook/{id}); section \"all\": the whole handbook.", - annotations(read_only_hint = true, open_world_hint = false) - )] - async fn handbook( - &self, - params: Parameters, - ) -> Result, String> { - let settings = self.settings(); - let declared = self.document_line_projects(); - let sections = handbook::SECTIONS - .iter() - .map(|(id, _)| { - let text = - handbook::section(id, &settings, self.currency(), declared).unwrap_or_default(); - HandbookSectionOut { - id: id.to_string(), - title: handbook::title(&text).to_string(), - uri: format!("{}/{id}", handbook::URI), - chars: text.chars().count(), - } - }) - .collect(); - let section = match params.0.section.as_deref().map(str::trim) { - None => None, - Some(id) => { - let text = handbook::section(id, &settings, self.currency(), declared).ok_or_else(|| { - let ids: Vec<&str> = handbook::SECTIONS.iter().map(|(id, _)| *id).collect(); - format!( - "no handbook section {id:?}: the sections are {}, or \"{}\" for all of them", - ids.join(", "), - handbook::ALL - ) - })?; - let title = if id == handbook::ALL { - "The whole handbook".to_string() - } else { - handbook::title(&text).to_string() - }; - Some(HandbookTextOut { - id: id.to_string(), - title, - text, - }) - } - }; - Ok(Json(HandbookOut { - read_first: "Read the contract section first: the rules every entry follows. Then each section you need, or section \"all\" for the whole handbook.".to_string(), - sections, - section, - })) - } - - #[tool( - name = "books.chart", - title = "Chart of accounts", - description = "List the chart of accounts with current balances. Start here: every line you post must use a real account from this chart.", - annotations(read_only_hint = true, open_world_hint = false) - )] - async fn chart(&self) -> Result, String> { - let store = self.lock()?; - let ledger = store.ledger(); - let mut accounts = Vec::new(); - let mut all: Vec<_> = ledger.chart().all().collect(); - all.sort_by(|a, b| a.code.cmp(&b.code)); - for a in all { - let balance = ledger.account_balance(a.id).map_err(|e| e.to_string())?; - accounts.push(ChartAccount { - id: a.id, - code: a.code.clone(), - name: a.name.clone(), - account_type: format!("{:?}", a.account_type).to_lowercase(), - parent: a.parent, - placeholder: a.placeholder, - balance: money(if balance.is_zero() { - Decimal::ZERO - } else { - balance - }), - tax_category: a.tax_category.clone(), - deductible_percent: a.deductible_percent.map(|p| p.to_string()), - }); - } - Ok(Json(ChartOut { - accounts, - base_currency: store.base_currency().code().to_string(), - })) - } - - #[tool( - name = "books.add_account", - title = "Add an account", - description = "Add an account to the chart. Codes must be unique; check books.chart first. Give a revenue or expense account its tax_category (books.tax_categories): without one the result warns, or, under policy.require_tax_category = refuse, the account is refused. A name another account under the same parent has (ignoring capitals), one holding nine digits in a row, or one that isn't a single line of at most 200 characters, is refused.", - annotations( - read_only_hint = false, - destructive_hint = false, - open_world_hint = false - ) - )] - async fn add_account( - &self, - params: Parameters, - ) -> Result, String> { - let p = params.0; - let account_type = parse_account_type(&p.account_type)?; - let placeholder = p.placeholder.unwrap_or(false); - let pct = match &p.deductible_percent { - Some(s) => Some(parse_amount(s)?), - None => None, - }; - let category = p.tax_category.as_deref(); - if pct.is_some() && category.is_none() { - return Err("deductible_percent needs a tax_category".into()); - } - if let Some(cat) = category { - ledger_core::tax::check_category(cat, account_type, self.settings().company.tax_form) - .map_err(|e| e.to_string())?; - } - let mut store = self.lock()?; - let id = store - .add_account_with_tax( - &p.code, - &p.name, - account_type, - p.parent, - placeholder, - category.map(|c| (c, pct)), - ) - .map_err(|e| e.to_string())?; - let warnings = - if category.is_none() && ledger_core::tax::needs_category(account_type, placeholder) { - vec![uncategorized_warning(&p.code, &p.name)] - } else { - Vec::new() - }; - Ok(Json(AddAccountOut { - account_id: id, - warnings, - })) - } - - #[tool( - name = "books.trial_balance", - title = "Trial balance", - description = described( - "The trial balance as of a date: every account with a non-zero balance, debit and credit columns and totals. Debits must equal credits.", - ledger_core::calendar::PeriodKind::AsOf - ), - annotations(read_only_hint = true, open_world_hint = false) - )] - async fn trial_balance( - &self, - params: Parameters, - ) -> Result, String> { - let store = self.lock()?; - let as_of = self.as_of(params.0.as_of.as_deref())?; - let tb = reports::trial_balance_as_of(store.ledger(), as_of); - Ok(Json(TrialBalanceOut { - as_of: as_of.to_string(), - lines: tb - .lines - .iter() - .map(|l| TrialBalanceLineOut { - code: l.code.clone(), - name: l.name.clone(), - debit: money(l.debit), - credit: money(l.credit), - }) - .collect(), - total_debits: money(tb.total_debits), - total_credits: money(tb.total_credits), - balanced: tb.balanced(), - })) - } - - #[tool( - name = "books.profit_and_loss", - title = "Profit and loss", - description = described( - "Profit and loss for a period: revenue, expenses, net income. Pass class (a project: name or id; see books.projects) to filter to one project.", - ledger_core::calendar::PeriodKind::Statement - ), - annotations(read_only_hint = true, open_world_hint = false) - )] - async fn profit_and_loss( - &self, - params: Parameters, - ) -> Result, String> { - let p = params.0; - let store = self.lock()?; - let class_id = match &p.class { - Some(c) => Some(resolve_class(&store, c)?), - None => None, - }; - let pl = self.pnl_statement( - store.ledger(), - p.start.as_deref(), - p.end.as_deref(), - class_id, - )?; - Ok(Json(ProfitAndLossOut { - start: pl.start.to_string(), - end: pl.end.to_string(), - revenue: money(pl.total_revenue), - expenses: money(pl.total_expenses), - net_income: money(pl.net_income()), - revenue_accounts: lines_out(&pl.revenue), - expense_accounts: lines_out(&pl.expenses), - })) - } - - #[tool( - name = "books.balance_sheet", - title = "Balance sheet", - description = described( - "The balance sheet as of a date: assets, liabilities, equity (including current earnings).", - ledger_core::calendar::PeriodKind::AsOf - ), - annotations(read_only_hint = true, open_world_hint = false) - )] - async fn balance_sheet( - &self, - params: Parameters, - ) -> Result, String> { - let p = params.0; - let store = self.lock()?; - let bs = self.bs_statement(store.ledger(), p.as_of.as_deref())?; - Ok(Json(BalanceSheetOut { - as_of: bs.as_of.to_string(), - assets: money(bs.total_assets), - liabilities: money(bs.total_liabilities), - equity: money(bs.total_equity), - current_earnings: money(bs.current_earnings), - balanced: bs.balanced(), - asset_accounts: lines_out(&bs.assets), - liability_accounts: lines_out(&bs.liabilities), - equity_accounts: lines_out(&bs.equity), - })) - } - - #[tool( - name = "books.cash_flow", - title = "Cash flow statement", - description = described( - "Cash flows, direct method (ASC 230): each entry that moved cash, gross, by section and kind (operating by ASC 230-10-45-25 class; investing and financing by kind, such as a member's contributions); an entry and its reversal in one period drop out. What a member or lender paid for the company is borrowed and spent that day (on_behalf). Also noncash activities, net income reconciled to operating cash, and warnings. Sections sum to the change in cash. Roles default to the settings.", - ledger_core::calendar::PeriodKind::Statement - ), - annotations(read_only_hint = true, open_world_hint = false) - )] - async fn cash_flow( - &self, - params: Parameters, - ) -> Result, String> { - let p = params.0; - let store = self.lock()?; - let ledger = store.ledger(); - let stmt = self.cash_flow_for( - ledger, - p.start.as_deref(), - p.end.as_deref(), - &p.cash_accounts, - &p.financing_accounts, - &p.investing_accounts, - )?; - Ok(Json(cash_flow_out(&stmt))) - } - - #[tool( - name = "books.draft_entry", - title = "Draft an entry", - description = "Start a draft journal entry. Drafts are journaled and survive restarts, but don't affect reports until posted; add lines with books.add_line. Pass party (id or name) for the counterparty: every entry with a vendor, platform, agency, bank, member, or contractor names its party (a party with a vendor record tags the vendor too, for 1099s). An entry with several counterparties passes parties instead, each with an amount, a percent, or neither (derived): name both members on a joint member transaction and let their shares derive.", - annotations( - read_only_hint = false, - destructive_hint = false, - open_world_hint = false - ) - )] - async fn draft_entry( - &self, - params: Parameters, - ) -> Result, String> { - let p = params.0; - let mut store = self.lock()?; - // Resolve every reference before writing anything (INVARIANT 2). - let date = parse_date(&p.date)?; - let vendor = p - .vendor - .as_deref() - .map(|v| resolve_vendor(&store, v)) - .transpose()?; - let instrument = p - .instrument - .as_deref() - .map(|r| instruments::resolve_instrument(&store, r)) - .transpose()?; - let party = p - .party - .as_deref() - .map(|r| resolve_party(&store, r)) - .transpose()?; - let no_party = p - .no_party_reason - .as_deref() - .map(str::trim) - .filter(|r| !r.is_empty()); - let shares = p - .parties - .as_deref() - .map(|ps| parse_shares(&store, ps)) - .transpose()?; - if no_party.is_some() && (party.is_some() || vendor.is_some() || shares.is_some()) { - return Err( - "no_party_reason is for an entry with no counterparty: pass it without party, parties, or vendor".into(), - ); - } - if shares.is_some() && (party.is_some() || vendor.is_some()) { - return Err( - "pass party (one) or parties (several, with shares), not both; a vendor party in parties tags its vendor record".into(), - ); - } - if let Some(sh) = &shares { - store - .ledger() - .check_party_shares(sh) - .map_err(|e| e.to_string())?; - } - if let (Some(v), Some(pid)) = (vendor, party) { - let party_vendor = store.ledger().get_party(pid).and_then(|p| p.vendor); - if party_vendor != Some(v) { - return Err(format!( - "vendor {v} isn't party {pid}'s vendor record: pass only the party (its vendor record is tagged with it), or the vendor alone" - )); - } - } - // A payment method exists only in books that declare the - // payment_instruments feature (books.add_instrument needs it), so - // tagging the draft with one below can't be refused for the format - // after the draft's first line (B-27). - let id = store - .create_entry(date, &p.description) - .map_err(|e| e.to_string())?; - if let Some(sh) = shares.filter(|sh| !sh.is_empty()) { - store - .set_entry_parties(id, &sh) - .map_err(|e| e.to_string())?; - } else if party.is_some() { - store - .set_entry_party(id, party) - .map_err(|e| e.to_string())?; - } else if vendor.is_some() { - store - .set_entry_vendor(id, vendor) - .map_err(|e| e.to_string())?; - } else if let Some(r) = no_party { - store.set_entry_no_party(id, r).map_err(|e| e.to_string())?; - } - if instrument.is_some() { - store - .set_entry_instrument(id, instrument) - .map_err(|e| e.to_string())?; - } - Ok(Json(DraftEntryOut { - entry_id: id, - status: "draft".to_string(), - })) - } - - #[tool( - name = "books.add_line", - title = "Add a line to a draft", - description = "Add one line to a draft entry. Amount is a decimal string, never a float. The entry must balance before posting. For foreign-currency lines, pass foreign_currency, foreign_amount, and fx_rate (foreign -> base); the base amount is computed as foreign_amount * fx_rate. Pass class (a project: name or id) to tag the line; see books.projects.", - annotations( - read_only_hint = false, - destructive_hint = false, - open_world_hint = false - ) - )] - async fn add_line( - &self, - params: Parameters, - ) -> Result, String> { - let p = params.0; - let mut store = self.lock()?; - let account = resolve_account(&store, &p.account)?; - let side = parse_side(&p.side)?; - // Resolve the class before writing anything (INVARIANT 2). - let class_id = match &p.class { - Some(c) => Some(resolve_class(&store, c)?), - None => None, - }; - // Foreign-currency line? - if p.foreign_currency.is_some() || p.foreign_amount.is_some() || p.fx_rate.is_some() { - let fc = p - .foreign_currency - .ok_or("foreign_currency is required for fx lines")?; - let fa = p - .foreign_amount - .ok_or("foreign_amount is required for fx lines")?; - let rate = p.fx_rate.ok_or("fx_rate is required for fx lines")?; - let currency = Currency::new(&fc).map_err(|e| e.to_string())?; - let foreign_amount = parse_amount(&fa)?; - let fx_rate = parse_amount(&rate)?; - store - .add_line_fx(p.entry_id, account, side, currency, foreign_amount, fx_rate) - .map_err(|e| e.to_string())?; - } else { - let amount = parse_amount(&p.amount)?; - if amount <= Decimal::ZERO { - return Err("amount must be positive".to_string()); - } - let currency = store.base_currency(); - store - .add_line(p.entry_id, account, side, Money::new(amount, currency)) - .map_err(|e| e.to_string())?; - } - // Class tag, if requested. The line just added is the last one. - if let Some(class_id) = class_id { - let line_index = store - .ledger() - .get_entry(p.entry_id) - .ok_or_else(|| format!("unknown entry {}", p.entry_id))? - .lines() - .len() - .checked_sub(1) - .ok_or_else(|| "entry has no lines".to_string())?; - store - .set_line_class(p.entry_id, line_index, Some(class_id)) - .map_err(|e| e.to_string())?; - } - let note = class_id.and_then(|c| projects::late_tag_note(store.ledger(), c)); - Ok(Json(AddLineOut { ok: true, note })) - } - - #[tool( - name = "books.post_entry", - title = "Post an entry", - description = "Post a draft entry. Without confirmed=true this returns a preview (lines, resulting balances, receipt checklist) and posts nothing. With confirmed=true it posts after enforcing the receipt policy.", - annotations( - read_only_hint = false, - destructive_hint = true, - open_world_hint = false - ) - )] - async fn post_entry( - &self, - params: Parameters, - ) -> Result, String> { - let p = params.0; - let mut store = self.lock()?; - let entry = store - .ledger() - .get_entry(p.entry_id) - .ok_or_else(|| format!("unknown entry {}", p.entry_id))? - .clone(); - if entry.status() != ledger_core::EntryStatus::Draft { - return Err(format!("entry {} is not a draft", p.entry_id)); - } - // Pure validation before anything is journaled. - entry - .validate(store.ledger().chart()) - .map_err(|e| e.to_string())?; - // Validated: its lines are in a line's range, so their sum is exact. - let total = ReceiptPolicy::entry_debits(&entry).map_err(|e| e.to_string())?; - if let Some(expected) = p.expected_total { - let want = parse_amount(&expected)?; - if total != want { - return Err(format!( - "expected_total mismatch: entry totals {total}, expected {want}" - )); - } - } - let mut receipt_required = self - .settings() - .policy - .receipt_required(store.ledger(), &entry); - let receipts = preview_receipts(&store, p.entry_id)?; - // Anti-splitting: if this entry looks like part of a split group, - // the receipt policy applies to the group total. - let split_group = self - .settings() - .policy - .split_group(store.ledger(), p.entry_id, &entry); - if !split_group.is_empty() { - receipt_required = true; - } - let receipt_missing = receipt_required && receipts.is_empty(); - let party_policy = self.settings().parties; - let party_id = store.ledger().entry_party(p.entry_id); - let (parties, parties_error) = entry_parties_out(&store, &self.settings(), &entry); - let no_party_reason = store - .ledger() - .entry_no_party(p.entry_id) - .map(str::to_string); - // Any party, or a no-party mark, satisfies the policy. - let party_missing = party_policy != ledger_config::PartyPolicy::Off - && parties.is_empty() - && no_party_reason.is_none(); - let party_suggestions = if party_missing { - suggest_parties(&store, &entry)? - } else { - Vec::new() - }; - let out = PostEntryOut { - status: "preview".to_string(), - entry_id: p.entry_id, - date: entry.date().to_string(), - description: entry.description().to_string(), - total: money(total), - lines: preview_lines(&store, &entry)?, - receipts, - receipt_required, - receipt_missing, - split_detected: split_group.clone(), - party_policy: party_policy.key().to_string(), - party_id, - party: party_id - .and_then(|i| store.ledger().get_party(i)) - .map(|x| x.name.clone()), - parties, - parties_error, - no_party_reason, - party_missing, - party_suggestions, - approvals_missing: store - .ledger() - .approvals_missing(p.entry_id) - .map_err(|e| e.to_string())? - .into_iter() - .map(|(rule, approver)| ApprovalNeededOut { rule, approver }) - .collect(), - }; - if !p.confirmed.unwrap_or(false) { - return Ok(Json(out)); - } - if !out.approvals_missing.is_empty() { - let missing: Vec = out - .approvals_missing - .iter() - .map(|a| format!("{} (rule {})", a.approver, a.rule)) - .collect(); - return Err(serde_json::json!({ - "code": "approval_required", - "message": format!( - "entry {} needs approval before it posts (policy.approval_rules is enforce): {}. Ask each approver; when they approve, record it with books.approve_entry, then post.", - p.entry_id, - missing.join("; ") - ), - "entry_id": p.entry_id, - "remedy_tool": "books.approve_entry", - }) - .to_string()); - } - if let Some(why) = &out.parties_error { - return Err(serde_json::json!({ - "code": "party_shares", - "message": format!( - "entry {}'s party shares don't add up: {why}. Fix them with books.set_entry_party parties (amounts that sum to what it moved, or percents summing to 100).", - p.entry_id - ), - "entry_id": p.entry_id, - "remedy_tool": "books.set_entry_party", - }) - .to_string()); - } - if party_missing && party_policy == ledger_config::PartyPolicy::Require { - let names: Vec = out - .party_suggestions - .iter() - .map(|s| format!("{} (party {}, {})", s.name, s.party_id, s.why)) - .collect(); - return Err(serde_json::json!({ - "code": "party_required", - "message": format!( - "entry {} names no counterparty, and these books require one (policy.parties = \"require\"). Tag it with books.set_entry_party (add the party with books.add_party first if it's new), or, if it truly has none, call books.set_entry_party with party null and no_party_reason.{}", - p.entry_id, - if names.is_empty() { String::new() } else { format!(" Likely: {}.", names.join("; ")) } - ), - "entry_id": p.entry_id, - "remedy_tool": "books.set_entry_party", - "party_suggestions": out.party_suggestions, - }) - .to_string()); - } - if receipt_missing { - let split_note = if split_group.is_empty() { - String::new() - } else { - format!( - " Anti-splitting: this looks like part of a split group with entries {:?}; the policy applies to the group total.", - split_group - ) - }; - return Err(ReceiptRequiredError { - code: "receipt_required".to_string(), - message: format!( - "entry {} moves {} in debits ({}); attach a receipt with books.attach_receipt before posting.{}", - p.entry_id, - money(total), - self.settings().policy.describe(), - split_note - ), - entry_id: Some(p.entry_id), - asset_id: None, - debits: total.to_string(), - policy: self.settings().policy.describe(), - remedy_tool: "books.attach_receipt".to_string(), - remedy_hint: format!( - "attach a receipt to entry {} with books.attach_receipt, then retry books.post_entry with confirmed=true", - p.entry_id - ), - split_group, - } - .to_tool_error()); - } - store.post_entry(p.entry_id).map_err(|e| e.to_string())?; - fix_derived_shares(&mut store, &self.settings(), p.entry_id)?; - let mut posted = out; - posted.status = "posted".to_string(); - Ok(Json(posted)) - } - - #[tool( - name = "books.reverse_entry", - title = "Reverse an entry", - description = "Reverse a posted entry with an offsetting entry. Without confirmed=true returns a preview; reversals are exempt from the receipt policy.", - annotations( - read_only_hint = false, - destructive_hint = true, - open_world_hint = false - ) - )] - async fn reverse_entry( - &self, - params: Parameters, - ) -> Result, String> { - let p = params.0; - let mut store = self.lock()?; - let orig = store - .ledger() - .get_entry(p.entry_id) - .ok_or_else(|| format!("unknown entry {}", p.entry_id))? - .clone(); - if orig.status() != ledger_core::EntryStatus::Posted { - return Err(format!("entry {} is not posted", p.entry_id)); - } - let date = parse_date(&p.date)?; - // Refused before the preview, as the post would be (an asset's - // disposal, a run its disposal took as final, a month posted twice). - store - .check_reverse_entry(p.entry_id, date) - .map_err(|e| e.to_string())?; - // Build the preview from the flipped lines without mutating. - let flipped: Vec<(AccountId, Side, Money)> = orig - .lines() - .iter() - .map(|l| { - let side = match l.side { - Side::Debit => Side::Credit, - Side::Credit => Side::Debit, - }; - (l.account, side, l.amount) - }) - .collect(); - let out = ReverseEntryOut { - status: "preview".to_string(), - reversal_id: 0, // filled in on post - reverses: p.entry_id, - lines: preview_lines_for(&store, &flipped)?, - }; - if !p.confirmed.unwrap_or(false) { - return Ok(Json(out)); - } - let reversal_id = store - .reverse_entry(p.entry_id, date, &p.description) - .map_err(|e| e.to_string())?; - let mut posted = out; - posted.status = "posted".to_string(); - posted.reversal_id = reversal_id; - Ok(Json(posted)) - } - - #[tool( - name = "books.attach_receipt", - title = "Attach a receipt", - description = "Attach a receipt to an entry, or file it unlinked and link later with books.link_receipt. The bytes: upload_id, from books.begin_upload and `wanderbooks upload` (a file never passes through you). Files are content-addressed and deduplicated. link_to links it to more subjects in the same call (another entry it also supports, an asset, a party, a project, ...: see books.link_attachment); every subject is checked first, so one bad subject files nothing. A file holding a full TIN is refused: one the server read or wrote with TINs, or text with a TIN the vault keeps or an SSN or EIN printed 3-2-4 or 2-7 with hyphens.", - annotations( - read_only_hint = false, - destructive_hint = false, - open_world_hint = false - ) - )] - async fn attach_receipt( - &self, - params: Parameters, - ) -> Result, String> { - let p = params.0; - let mut store = self.lock()?; - if let Some(e) = p.entry_id - && store.ledger().get_entry(e).is_none() - { - return Err(format!("unknown entry {e}")); - } - let link_to = check_link_to(&store, p.entry_id, &p.link_to)?; - // Taken once everything else is known good. - let file = self.take_file(files::Given { - upload_id: &p.upload_id, - purposes: files::FILED, - })?; - let (file_name, mime_type) = named(p.file_name, p.mime_type, &file)?; - let (bytes, claim) = (file.bytes, file.claim); - self.refuse_tin_file(&store, &bytes, &mime_type, &file_name)?; - let (attachment_id, sha256, deduplicated) = store - .attach_receipt( - p.entry_id, - &file_name, - &mime_type, - &bytes, - p.description.as_deref(), - ) - .map_err(|e| e.to_string())?; - files::filed(claim); - for sub in link_to { - store - .link_attachment(attachment_id, sub) - .map_err(|e| e.to_string())?; - } - Ok(Json(AttachReceiptOut { - links: links_out(&store, attachment_id)?, - attachment_id, - sha256, - deduplicated, - })) - } - - #[tool( - name = "books.file_document", - title = "File a document", - description = "File a document: category formation, legal, tax, accounting, statement (a bank or card statement: then books.record_statement), contract, photo, warranty, import_source (a bank export), or other. link_to links it to subjects in the same call (an account, an asset, a party, a project, the company: see books.link_attachment), all checked first. The bytes: upload_id, from books.begin_upload and `wanderbooks upload` (a file never passes through you). Never a W-9 (books.set_w9 w9_upload_id puts it in the vault). A file holding a full TIN is refused: one the server read or wrote with TINs, or text with a TIN the vault keeps or an SSN or EIN printed 3-2-4 or 2-7 with hyphens.", - annotations( - read_only_hint = false, - destructive_hint = false, - open_world_hint = false - ) - )] - async fn file_document( - &self, - params: Parameters, - ) -> Result, String> { - let p = params.0; - match p.category.as_str() { - "formation" | "legal" | "tax" | "accounting" | "statement" | "contract" | "photo" - | "warranty" | "import_source" | "other" => {} - _ => { - return Err(format!( - "bad category {:?}: expected formation, legal, tax, accounting, statement, contract, photo, warranty, import_source, or other", - p.category - )); - } - } - let mut store = self.lock()?; - let link_to = check_link_to(&store, None, &p.link_to)?; - let file = self.take_file(files::Given { - upload_id: &p.upload_id, - purposes: files::FILED, - })?; - let (file_name, mime_type) = named(p.file_name, p.mime_type, &file)?; - let (bytes, claim) = (file.bytes, file.claim); - self.refuse_tin_file(&store, &bytes, &mime_type, &file_name)?; - let (attachment_id, sha256, deduplicated) = store - .file_document( - &p.category, - &file_name, - &mime_type, - &bytes, - p.description.as_deref(), - ) - .map_err(|e| e.to_string())?; - files::filed(claim); - for sub in link_to { - store - .link_attachment(attachment_id, sub) - .map_err(|e| e.to_string())?; - } - Ok(Json(AttachReceiptOut { - links: links_out(&store, attachment_id)?, - attachment_id, - sha256, - deduplicated, - })) - } - - #[tool( - name = "books.link_receipt", - title = "Link a receipt to an entry", - description = "Link a previously filed (unlinked) receipt to an entry.", - annotations( - read_only_hint = false, - destructive_hint = false, - open_world_hint = false - ) - )] - async fn link_receipt( - &self, - params: Parameters, - ) -> Result, String> { - let p = params.0; - let mut store = self.lock()?; - store - .link_receipt(p.attachment_id, p.entry_id) - .map_err(|e| e.to_string())?; - Ok(Json(LinkReceiptOut { ok: true })) - } - - #[tool( - name = "books.unlink_receipt", - title = "Unlink a receipt", - description = "Detach a receipt from its entry, with a reason recorded in the journal. The receipt stays filed (unlinked); link it elsewhere or withdraw it with books.withdraw_attachment. The receipt policy still holds: an entry that requires a receipt cannot be left with none, so to replace a receipt attach the new one first, then unlink the old.", - annotations( - read_only_hint = false, - destructive_hint = true, - open_world_hint = false - ) - )] - async fn unlink_receipt( - &self, - params: Parameters, - ) -> Result, String> { - let p = params.0; - let mut store = self.lock()?; - let attachment = store - .list_attachments(None) - .map_err(|e| e.to_string())? - .into_iter() - .find(|a| a.id == p.attachment_id) - .ok_or_else(|| format!("unknown attachment {}", p.attachment_id))?; - if let Some(entry_id) = attachment.entry_id { - let remaining = store - .list_attachments(Some(entry_id)) - .map_err(|e| e.to_string())? - .len() - - 1; - if remaining == 0 { - self.ensure_receipt_not_required(&store, entry_id)?; - } - } - let entry_id = store - .unlink_receipt(p.attachment_id, &p.reason) - .map_err(|e| e.to_string())?; - let remaining_receipts = store - .list_attachments(Some(entry_id)) - .map_err(|e| e.to_string())? - .len() as u64; - Ok(Json(UnlinkReceiptOut { - ok: true, - entry_id, - remaining_receipts, - })) - } - - #[tool( - name = "books.withdraw_attachment", - title = "Withdraw a receipt or document", - description = "Withdraw an unlinked receipt or a filed document, with a reason recorded in the journal. It drops out of books.list_receipts and can never be linked again. Nothing is deleted: the bytes stay in the blob store and the journal keeps the filing and the withdrawal. Unlink it first (books.unlink_receipt from its entry, books.unlink_attachment from anything else).", - annotations( - read_only_hint = false, - destructive_hint = true, - open_world_hint = false - ) - )] - async fn withdraw_attachment( - &self, - params: Parameters, - ) -> Result, String> { - let p = params.0; - let mut store = self.lock()?; - store - .withdraw_attachment(p.attachment_id, &p.reason) - .map_err(|e| e.to_string())?; - Ok(Json(WithdrawAttachmentOut { ok: true })) - } - - /// ```text - /// GOAL: one file, linked wherever it is evidence, without refiling. - /// SOURCE: docs/parties-and-attachments.md "Attachments"; - /// Store::link_attachment. - /// INVARIANTS: - /// 1. Journaled as `attachment.linked`; a refusal journals nothing. - /// 2. A file linked to an entry counts as that entry's receipt. - /// SCENARIOS: - /// 1. IF the attachment is unknown or withdrawn, the subject kind is - /// unknown, or the subject doesn't exist: SHOULD refuse. - /// 2. IF it is already linked there (or filed on that entry): SHOULD - /// refuse. - /// ``` - #[tool( - name = "books.link_attachment", - title = "Link a file to a subject", - description = "Link a stored receipt or document to a subject, so one file is evidence wherever it applies without filing it again. subject: {kind, id}: entry (id), account (code or id: its statements, the agreement that opened it), asset (tag or id: photos, warranty, manual), party (id or name: contracts, correspondence), project (id or name: its contract, spec, pitch), invoice, bill, period (its close package), payroll_run, schedule (the prepaid contract), reconciliation, or company (no id: formation documents). A file linked to an entry counts as one of its receipts. Journaled as attachment.linked. Returns the attachment's links.", - annotations( - read_only_hint = false, - destructive_hint = false, - open_world_hint = false - ) - )] - async fn link_attachment( - &self, - params: Parameters, - ) -> Result, String> { - let p = params.0; - let mut store = self.lock()?; - find_attachment(&store, p.attachment_id)?; - let sub = resolve_subject(&store, &p.subject)?; - store - .link_attachment(p.attachment_id, sub) - .map_err(|e| e.to_string())?; - Ok(Json(AttachmentLinksOut { - attachment_id: p.attachment_id, - links: links_out(&store, p.attachment_id)?, - })) - } - - /// ```text - /// GOAL: take a link off, on the record, without leaving evidence - /// missing where a rule needs it. - /// SOURCE: R-13a (receipt corrections are append-only events); - /// Store::unlink_attachment. - /// INVARIANTS: - /// 1. Journaled as `attachment.unlinked` with the reason; the file - /// stays filed. A refusal journals nothing. - /// SCENARIOS: - /// 1. IF the reason is blank, or it isn't linked to the subject: - /// SHOULD refuse. - /// 2. IF it is filed on that entry (rather than linked): SHOULD refuse, - /// naming books.unlink_receipt. - /// 3. IF it is a posted entry's last receipt and the receipt policy - /// covers the entry: SHOULD refuse (receipt_required). - /// 4. IF it is the statement of a reconciliation not voided: SHOULD - /// refuse (void the reconciliation first). - /// ``` - #[tool( - name = "books.unlink_attachment", - title = "Unlink a file from a subject", - description = "Take a link (books.link_attachment) off a subject, with the reason recorded in the journal. The file stays filed and keeps its other links. A receipt filed on an entry comes off with books.unlink_receipt instead. Refused when it would leave a posted entry the receipt policy covers without a receipt (attach the replacement first), or take the statement off a reconciliation that isn't voided. Journaled as attachment.unlinked. Returns the attachment's links.", - annotations( - read_only_hint = false, - destructive_hint = true, - open_world_hint = false - ) - )] - async fn unlink_attachment( - &self, - params: Parameters, - ) -> Result, String> { - use ledger_store::Subject; - let p = params.0; - let mut store = self.lock()?; - let a = find_attachment(&store, p.attachment_id)?; - let sub = resolve_subject(&store, &p.subject)?; - let linked = store - .attachment_links(a.id) - .map_err(|e| e.to_string())? - .contains(&sub); - match sub { - Subject::Entry(e) if a.entry_id == Some(e) => { - return Err(format!( - "attachment {} is filed on entry {e}, not linked to it: take it off with books.unlink_receipt", - a.id - )); - } - Subject::Entry(e) if linked => { - let others = store - .list_attachments(Some(e)) - .map_err(|e| e.to_string())? - .iter() - .filter(|x| x.id != a.id) - .count(); - if others == 0 { - self.ensure_receipt_not_required(&store, e)?; - } - } - Subject::Reconciliation(r) - if store - .reconciliations(None) - .map_err(|e| e.to_string())? - .iter() - .any(|x| x.id == r && x.attachment_id == a.id && x.voided.is_none()) => - { - return Err(format!( - "attachment {} is the statement reconciliation {r} rests on: void the reconciliation first (books.void_reconciliation)", - a.id - )); - } - _ => {} - } - store - .unlink_attachment(a.id, sub, &p.reason) - .map_err(|e| e.to_string())?; - Ok(Json(AttachmentLinksOut { - attachment_id: a.id, - links: links_out(&store, a.id)?, - })) - } - - #[tool( - name = "books.attachments", - title = "Files linked to a subject", - description = "The files on a subject ({kind, id} as in books.link_attachment): for an entry, the receipts filed on it and the files linked to it; for anything else, the files linked to it. Withdrawn files are left out. Each comes with all its links.", - annotations(read_only_hint = true, open_world_hint = false) - )] - async fn attachments( - &self, - params: Parameters, - ) -> Result, String> { - let store = self.lock()?; - let sub = resolve_subject(&store, ¶ms.0.subject)?; - let attachments = store - .attachments_for(sub) - .map_err(|e| e.to_string())? - .into_iter() - .map(|a| receipt_out(&store, a)) - .collect::>()?; - Ok(Json(AttachmentsOut { - subject: subject_out(&store, sub), - attachments, - })) - } - - #[tool( - name = "books.list_receipts", - title = "List receipts and documents", - description = "List receipts and filed documents, optionally filtered to one entry (filed on it or linked to it), each with everything it is linked to (links). Withdrawn attachments are hidden unless include_withdrawn=true. For any other subject's files, books.attachments.", - annotations(read_only_hint = true, open_world_hint = false) - )] - async fn list_receipts( - &self, - params: Parameters, - ) -> Result, String> { - let p = params.0; - let store = self.lock()?; - let include_withdrawn = p.include_withdrawn.unwrap_or(false); - let receipts = store - .list_attachments(p.entry_id) - .map_err(|e| e.to_string())? - .into_iter() - .filter(|a| include_withdrawn || a.withdrawn_at.is_none()) - .map(|a| receipt_out(&store, a)) - .collect::>()?; - Ok(Json(ListReceiptsOut { receipts })) - } - - #[tool( - name = "books.get_receipt", - title = "Get a receipt", - description = "A receipt or document: what it is and everything it is linked to. view bytes adds the file, handed over: run its download (wanderbooks download) to save a copy in a folder you name; its bytes never come back here.", - annotations(read_only_hint = true, open_world_hint = false) - )] - async fn get_receipt( - &self, - params: Parameters, - ) -> Result, String> { - let p = params.0; - let bytes_wanted = match p.view.as_deref().unwrap_or("meta") { - "meta" => false, - "bytes" => true, - other => { - return Err(format!( - "view {:?}: meta (the default) or bytes", - other.chars().take(20).collect::() - )); - } - }; - let store = self.lock()?; - let meta = find_attachment(&store, p.attachment_id)?; - // Handed over, read hash-verified (D-46): never its bytes here. - let file = if bytes_wanted { - let bytes = store - .attachment_bytes(p.attachment_id) - .map_err(|e| e.to_string())?; - Some(self.hand_over(&book_name(&store), &meta.file_name, &meta.mime_type, bytes)?) - } else { - None - }; - Ok(Json(GetReceiptOut { - file, - links: links_out(&store, meta.id)?, - description: meta.description.clone(), - attachment_id: meta.id, - sha256: meta.blob_sha, - entry_id: meta.entry_id, - category: meta.category, - file_name: meta.file_name, - mime_type: meta.mime_type, - size_bytes: meta.size_bytes, - })) - } - - #[tool( - name = "books.get_entry", - title = "Get an entry", - description = "Full detail of one entry: lines, totals, status, and attached receipts.", - annotations(read_only_hint = true, open_world_hint = false) - )] - async fn get_entry( - &self, - params: Parameters, - ) -> Result, String> { - let p = params.0; - let store = self.lock()?; - let entry = store - .ledger() - .get_entry(p.entry_id) - .ok_or_else(|| format!("unknown entry {}", p.entry_id))? - .clone(); - let lines = entry - .lines() - .iter() - .map(|l| { - // A draft line an earlier version journaled may name an - // account the chart doesn't have (rules.md B-28). - let (code, name) = match store.ledger().chart().get(l.account) { - Some(a) => (a.code.clone(), a.name.clone()), - None => (String::new(), format!("(no account {})", l.account)), - }; - EntryLineOut { - account_id: l.account, - code, - name, - side: match l.side { - Side::Debit => "debit".to_string(), - Side::Credit => "credit".to_string(), - }, - amount: money(l.amount.amount), - class_id: l.class_id, - project: l - .class_id - .and_then(|c| store.ledger().get_class(c)) - .map(|c| c.name.clone()), - } - }) - .collect(); - let (parties, parties_error) = entry_parties_out(&store, &self.settings(), &entry); - let total = ReceiptPolicy::entry_debits(&entry); - Ok(Json(GetEntryOut { - entry_id: p.entry_id, - date: entry.date().to_string(), - description: entry.description().to_string(), - status: match entry.status() { - ledger_core::EntryStatus::Draft => "draft".to_string(), - ledger_core::EntryStatus::Posted => "posted".to_string(), - }, - reversal_of: entry.reversal_of(), - lines, - total: total.as_ref().ok().map(|t| money(*t)), - total_error: total.err().map(|e| e.to_string()), - receipts: preview_receipts(&store, p.entry_id)?, - vendor: store - .ledger() - .entry_vendor(p.entry_id) - .and_then(|v| store.ledger().get_vendor(v)) - .map(|v| v.name.clone()), - instrument: store - .ledger() - .entry_instrument(p.entry_id) - .and_then(|i| store.ledger().get_instrument(i)) - .map(|i| i.label.clone()), - instrument_id: store.ledger().entry_instrument(p.entry_id), - party_id: store.ledger().entry_party(p.entry_id), - party: store - .ledger() - .entry_party(p.entry_id) - .and_then(|id| store.ledger().get_party(id)) - .map(|p| p.label().to_string()), - parties, - parties_error, - no_party_reason: store - .ledger() - .entry_no_party(p.entry_id) - .map(str::to_string), - })) - } - - #[tool( - name = "books.search_entries", - title = "Search entries", - description = "Search posted entries by account, date range, and description text. Newest first.", - annotations(read_only_hint = true, open_world_hint = false) - )] - async fn search_entries( - &self, - params: Parameters, - ) -> Result, String> { - let p = params.0; - let store = self.lock()?; - let rows = store - .search_entries( - p.account_id, - p.date_from.as_deref(), - p.date_to.as_deref(), - p.text.as_deref(), - p.limit.unwrap_or(50).min(500), - p.offset.unwrap_or(0), - ) - .map_err(|e| e.to_string())?; - let mut entries = Vec::with_capacity(rows.len()); - for r in rows { - let e = store - .ledger() - .get_entry(r.id) - .ok_or_else(|| format!("entry {} missing from ledger", r.id))?; - let total = ReceiptPolicy::entry_debits(e).map_err(|e| e.to_string())?; - let receipt_count = store - .list_attachments(Some(r.id)) - .map_err(|e| e.to_string())? - .len() as u64; - entries.push(EntrySummary { - entry_id: r.id, - date: r.date, - description: r.description, - total: money(total), - reversal_of: r.reversal_of, - receipt_count, - }); - } - Ok(Json(SearchEntriesOut { entries })) - } - - #[tool( - name = "books.show", - title = "Show in the viewer", - description = "Show something in the human's viewer (the read-only desktop app): an entry (with its lines, receipt, and history), an account's register (optionally for start/end dates), a report (optionally for start/end dates, split into month/quarter/year columns, compared with the prior period or prior year), a stored receipt or document, a fixed asset (by tag or id), a party (by id or name: who they are and everything the company did with them), a project (by id or name), a reconciliation (by id: its account with its statements and reconciliations), a search of the transactions, or a page. Give exactly one target; work out dates yourself (\"last quarter\" means its first and last day). Use it whenever the human asks to see something, and after posting so they can look at what you did. Changes nothing in the books.", - annotations(read_only_hint = true, open_world_hint = false) - )] - async fn show(&self, params: Parameters) -> Result, String> { - use ledger_store::ops::{ViewerTarget, request, viewer_open}; - let p = params.0; - let store = self.lock()?; - let ledger = store.ledger(); - let given = [ - p.entry_id.is_some(), - p.account.is_some(), - p.report.is_some(), - p.attachment_id.is_some(), - p.asset.is_some(), - p.party.is_some(), - p.project.is_some(), - p.reconciliation.is_some(), - p.search.is_some(), - p.page.is_some(), - ] - .iter() - .filter(|x| **x) - .count(); - if given != 1 { - return Err( - "give exactly one of entry_id, account, report, attachment_id, asset, party, project, reconciliation, search, page" - .into(), - ); - } - let start = p.start.as_deref().map(parse_date).transpose()?; - let end = p.end.as_deref().map(parse_date).transpose()?; - // Options only an account or a report takes are refused anywhere - // else (the viewer would ignore them, and the agent believe it - // showed what it didn't). - let dated = p.account.is_some() || p.report.is_some(); - let options: Vec<&str> = [ - ("start", p.start.is_some()), - ("end", p.end.is_some()), - ("columns", p.columns.is_some()), - ("compare", p.compare.is_some()), - ] - .into_iter() - .filter(|(_, given)| *given) - .map(|(name, _)| name) - .collect(); - if !dated && !options.is_empty() { - return Err(format!( - "{} {} for an account or a report; this target takes none", - options.join(" and "), - if options.len() == 1 { "is" } else { "are" } - )); - } - if p.account.is_some() && (p.columns.is_some() || p.compare.is_some()) { - return Err( - "columns and compare are for a report; an account's register takes only start and end" - .into(), - ); - } - const REPORTS: [&str; 11] = [ - "profit_and_loss", - "balance_sheet", - "cash_flow", - "members_equity", - "trial_balance", - "general_ledger", - "tax_summary", - "vendor_1099", - "ar_aging", - "ap_aging", - "fixed_asset_register", - ]; - const PAGES: [&str; 12] = [ - "overview", - "cash", - "profit", - "transactions", - "reports", - "accounts", - "assets", - "parties", - "projects", - "documents", - "company", - "journal", - ]; - let (target, shown) = if let Some(r) = &p.asset { - let id = resolve_asset_ref(&store, r)?; - let a = ledger.get_asset(id).expect("resolved"); - ( - ViewerTarget::Asset { asset_id: id }, - format!( - "asset {}{}", - a.tag - .as_deref() - .map(|t| format!("{t}, ")) - .unwrap_or_default(), - a.description - ), - ) - } else if let Some(r) = &p.party { - let id = resolve_party(&store, r)?; - let party = ledger.get_party(id).expect("resolved"); - ( - ViewerTarget::Party { party_id: id }, - format!("party {id}, {}", party.label()), - ) - } else if let Some(r) = &p.project { - let id = resolve_class(&store, r)?; - let c = ledger.get_class(id).expect("resolved"); - ( - ViewerTarget::Project { class_id: id }, - format!("project {id}, {}", c.name), - ) - } else if let Some(id) = p.reconciliation { - let r = store - .reconciliations(None) - .map_err(|e| e.to_string())? - .into_iter() - .find(|r| r.id == id) - .ok_or_else(|| format!("unknown reconciliation {id}"))?; - let (code, name) = code_name(&store, r.account_id); - ( - ViewerTarget::Reconciliation { id }, - format!( - "reconciliation {id}, {code} {name} through {}", - r.statement_date - ), - ) - } else if let Some(id) = p.entry_id { - let e = ledger - .get_entry(id) - .ok_or_else(|| format!("unknown entry {id}"))?; - ( - ViewerTarget::Entry { entry_id: id }, - format!("entry #{id}, {}", e.description()), - ) - } else if let Some(code) = p.account { - let a = ledger - .chart() - .find_by_code(&code) - .ok_or_else(|| format!("unknown account {code}"))?; - // The dates resolved (rules.md B-30), sent as resolved, so what - // the agent is told and what the viewer shows can't differ. - let (s, e) = self - .periods() - .statement(start, end) - .map_err(|e| e.to_string())?; - ( - ViewerTarget::Account { - code: code.clone(), - start: Some(s.to_string()), - end: Some(e.to_string()), - }, - format!("the register of {} {}, {s} to {e}", a.code, a.name), - ) - } else if let Some(report) = p.report { - if !REPORTS.contains(&report.as_str()) { - return Err(format!( - "report {report:?}: expected one of {}", - REPORTS.join(", ") - )); - } - if let Some(c) = &p.columns - && !["total", "month", "quarter", "year"].contains(&c.as_str()) - { - return Err(format!( - "columns {c:?}: expected total, month, quarter, or year" - )); - } - if let Some(c) = &p.compare - && !["prior_period", "prior_year"].contains(&c.as_str()) - { - return Err(format!( - "compare {c:?}: expected prior_period or prior_year" - )); - } - let dates = show_report_dates(&report, p.columns.as_deref(), p.compare.as_deref())?; - if p.start.is_some() { - match dates { - ShownDates::Period => {} - ShownDates::AsOf => { - return Err(format!( - "{report}{} is as of a day: pass end (the day), not start", - if report == "balance_sheet" { - " in one total column" - } else { - "" - } - )); - } - ShownDates::Year => { - return Err(format!( - "{report} is for a calendar year: pass end (a day in that year), not start" - )); - } - } - } - let (s, e) = self - .periods() - .statement(start, end) - .map_err(|e| e.to_string())?; - // A split past the viewer's columns is shown as one total (the - // viewer says so): a split it would ignore. - let per = match p.columns.as_deref() { - Some("month") => Some(1), - Some("quarter") => Some(3), - Some("year") => Some(12), - _ => None, - }; - if let Some(per) = per { - let n = ledger_store::ops::report_columns(s, e, per); - let max = ledger_store::ops::MAX_REPORT_COLUMNS; - if n > max { - return Err(format!( - "columns {} from {s} to {e} is {n} columns, and the viewer splits a report into at most {max} (past that it shows the total): pass a shorter range or a longer split", - p.columns.as_deref().unwrap_or_default() - )); - } - } - let mut what = format!("the {} report", report.replace('_', " ")); - what.push_str(&match dates { - ShownDates::Period => format!(", {s} to {e}"), - ShownDates::AsOf => format!(", as of {e}"), - ShownDates::Year => format!(", for {}", chrono::Datelike::year(&e)), - }); - if let Some(c) = p.columns.as_deref().filter(|c| *c != "total") { - what.push_str(&format!(", by {c}")); - } - if let Some(c) = &p.compare { - what.push_str(&format!(", compared with the {}", c.replace('_', " "))); - } - ( - ViewerTarget::Report { - report: report.clone(), - start: Some(s.to_string()), - end: Some(e.to_string()), - columns: p.columns.clone(), - compare: p.compare.clone(), - }, - what, - ) - } else if let Some(id) = p.attachment_id { - let a = store - .list_attachments(None) - .map_err(|e| e.to_string())? - .into_iter() - .find(|a| a.id == id) - .ok_or_else(|| format!("unknown attachment {id}"))?; - ( - ViewerTarget::Document { attachment_id: id }, - format!("document {id}, {}", a.file_name), - ) - } else if let Some(query) = p.search { - ( - ViewerTarget::Search { - query: query.clone(), - }, - format!("transactions matching {query:?}"), - ) - } else { - let page = p.page.unwrap_or_default(); - if !PAGES.contains(&page.as_str()) { - return Err(format!( - "page {page:?}: expected one of {}", - PAGES.join(", ") - )); - } - ( - ViewerTarget::Page { page: page.clone() }, - format!("the {page} page"), - ) - }; - request(store.data_dir(), target).map_err(|e| format!("couldn't reach the viewer: {e}"))?; - // Whether a viewer has these books open (ledger_store::ops - // INVARIANT 3): it shows it now; else it will when opened soon. - let open = viewer_open(store.data_dir()); - Ok(Json(ShowOut { - shown: shown.clone(), - viewer_open: open, - note: if open { - format!("The viewer shows {shown}.") - } else { - format!( - "No viewer has these books open: ask the human to open it (opened within a couple of minutes, it shows {shown})." - ) - }, - })) - } - - #[tool( - name = "books.verify", - title = "Verify the books", - description = "Check the books end to end: the journal's hash chain; a fresh replay against the live ledger; every table of the books' database, row for row, against a fresh projection of the journal (rebuilding the database from the journal if it drifted, keeping the old copy); and, by default, every stored receipt and document against its SHA-256. Run it whenever a number looks wrong or a human asks whether the books are intact.", - annotations( - read_only_hint = false, - destructive_hint = false, - idempotent_hint = true, - open_world_hint = false - ) - )] - async fn verify(&self, params: Parameters) -> Result, String> { - let mut store = self.lock()?; - let report = store.verify_and_heal().map_err(|e| e.to_string())?; - let blobs = if params.0.blobs { - Some(store.verify_blobs().map_err(|e| e.to_string())?) - } else { - None - }; - let mut checked = vec![ - format!("journal hash chain ({} events)", report.events), - "fresh replay matches the live ledger (every statement, assets, classes, payroll)" - .to_string(), - format!( - "SQLite database ({}) matches a fresh projection of the journal, every table, row for row", - report.database - ), - ]; - if let Some(n) = blobs { - checked.push(format!("{n} stored files match their SHA-256")); - } - // Receivables and payables equal their documents (rules.md B-38 - // INVARIANT 1): an identity, so a difference is an engine error. - let checks = store - .ledger() - .control_check(chrono::NaiveDate::MAX) - .map_err(|e| e.to_string())?; - if let Some(c) = checks.iter().find(|c| !c.difference.is_zero()) { - return Err(format!( - "{}: its balance {} is not its documents' {} plus its other lines: an engine error, report it", - c.role, - money(c.balance), - money(c.documents_total) - )); - } - checked.push(format!( - "receivables and payables equal their documents and other lines ({} control accounts)", - checks.len() - )); - Ok(Json(VerifyOut { - status: if report.healed.is_some() { - "healed" - } else { - "ok" - } - .to_string(), - events: report.events, - head: report.head, - healed: report.healed, - blobs_verified: blobs, - database: report.database, - other_databases: report.other_databases, - checked, - })) - } - - #[tool( - name = "books.digest", - title = "Activity digest", - description = "What changed since a date: entries posted, per-account movement, and receipt coverage; plus what needs doing: rules due for review, settings problems, cash and card accounts not reconciled through the end of last month (unreconciled_accounts), revenue and expense accounts with no tax category (uncategorized_accounts), and receivables or payables lines that are no document's or documents needing attention (control_problems, books.control_check). Built for human review.", - annotations(read_only_hint = true, open_world_hint = false) - )] - async fn digest(&self, params: Parameters) -> Result, String> { - let p = params.0; - let since = parse_date(&p.since)?; - let today = today(); - let store = self.lock()?; - let ledger = store.ledger(); - let mut entries = Vec::new(); - for e in ledger.posted_entries() { - if e.date() < since { - continue; - } - let receipts = store - .list_attachments(Some(e.id())) - .map_err(|e| e.to_string())?; - let required = self.settings().policy.receipt_required(ledger, e); - entries.push(DigestEntry { - entry_id: e.id(), - date: e.date().to_string(), - description: e.description().to_string(), - total: money(ReceiptPolicy::entry_debits(e).map_err(|e| e.to_string())?), - reversal_of: e.reversal_of(), - receipt_count: receipts.len() as u64, - receipt_required: required, - receipt_missing: required && receipts.is_empty(), - }); - } - entries.sort_by(|a, b| b.date.cmp(&a.date).then(b.entry_id.cmp(&a.entry_id))); - let mut totals = Vec::new(); - for a in ledger.chart().all() { - if a.placeholder { - continue; - } - let m = movement_in(ledger, a.id, since, today); - if !m.is_zero() { - totals.push(DigestTotal { - code: a.code.clone(), - name: a.name.clone(), - movement: money(m), - }); - } - } - totals.sort_by(|a, b| a.code.cmp(&b.code)); - let stale_rules = ledger_core::rules::stale_rules(today) - .into_iter() - .map(|r| StaleRule { - name: r.name.to_string(), - known_through: r.known_through, - source: r.authority.to_string(), - why: r.review_why.to_string(), - }) - .collect(); - let journal_repairs = store - .tail_repairs() - .map_err(|e| e.to_string())? - .into_iter() - .filter(|(at, _, _)| at.get(..10).is_some_and(|d| d >= p.since.as_str())) - // The stamp only: the file's name would name the journal. - .map(|(at, bytes, saved_as)| JournalRepair { - at, - bytes, - set_aside: saved_as - .rsplit_once(".torn.") - .map(|(_, stamp)| stamp.to_string()) - .unwrap_or_default(), - }) - .collect(); - let unreconciled_accounts = self.unreconciled_accounts(&store, today)?; - let uncategorized_accounts = ledger_core::tax::uncategorized_accounts(ledger) - .into_iter() - .map(|a| format!("{} {}", a.code, a.name)) - .collect(); - let control_problems = control_problems(&control_check_out( - ledger, - today, - &ledger.control_check(today).map_err(|e| e.to_string())?, - )); - Ok(Json(DigestOut { - unreconciled_accounts, - uncategorized_accounts, - control_problems, - since: since.to_string(), - entries, - totals_by_account: totals, - stale_rules, - journal_repairs, - settings_problems: settings_problems(store.ledger(), &self.settings()), - })) - } - - #[tool( - name = "books.import_bank_csv", - title = "Import a bank CSV as drafts", - description = "Import a QuickBooks-style bank/credit-card CSV: 3 columns Date,Description,Amount (payments negative), or 4 columns Date,Description, money out, money in (Intuit heads them Credit,Debit). Slash dates must settle month-first or day-first from the file itself or from date_order (\"month_first\" for mm/dd, \"day_first\" for dd/mm); ask the human when the tool says the dates read either way. Creates one DRAFT entry per transaction, all or none: bank account on one side, categorized offset on the other (the longest matching rule pattern, else default_offset_code). Give the CSV as upload_id: upload it with books.begin_upload purpose import and `wanderbooks upload` (a file never passes through you). Review with books.digest, then post. File the export too (books.file_document category import_source) and pass source_attachment_id: it is linked to the bank account, so the import traces to its source (never to the drafts: a bank export is not a receipt).", - annotations( - read_only_hint = false, - destructive_hint = false, - open_world_hint = false - ) - )] - async fn import_bank_csv( - &self, - params: Parameters, - ) -> Result, String> { - let p = params.0; - let order = match p.date_order.as_deref() { - None => None, - Some("month_first") => Some(ledger_import::DateOrder::MonthFirst), - Some("day_first") => Some(ledger_import::DateOrder::DayFirst), - Some(other) => { - return Err(format!( - "date_order must be \"month_first\" or \"day_first\", got {other:?}" - )); - } - }; - let (csv, claim) = self.csv_given(&p.upload_id)?; - let txns = ledger_import::parse_bank_csv_with(&csv, order).map_err(|e| e.to_string())?; - let mut store = self.lock()?; - let bank = store - .ledger() - .chart() - .find_by_code(&p.bank_account_code) - .map(|a| (a.id, a.code.clone())) - .ok_or_else(|| format!("unknown bank account code: {}", p.bank_account_code))?; - // The source file links to the account, checked before any draft. - let link_source = match p.source_attachment_id { - None => false, - Some(att) => { - let a = find_attachment(&store, att)?; - if a.withdrawn_at.is_some() { - return Err(format!("attachment {att} is withdrawn")); - } - !store - .attachment_links(att) - .map_err(|e| e.to_string())? - .contains(&ledger_store::Subject::Account(bank.0)) - } - }; - let default_offset = store - .ledger() - .chart() - .find_by_code(&p.default_offset_code) - .map(|a| (a.id, a.code.clone())) - .ok_or_else(|| format!("unknown offset account code: {}", p.default_offset_code))?; - // Resolve rule account codes upfront. - let mut rules: Vec<(String, (ledger_core::AccountId, String))> = Vec::new(); - for (pattern, code) in &p.rules { - let (id, c) = store - .ledger() - .chart() - .find_by_code(code) - .map(|a| (a.id, a.code.clone())) - .ok_or_else(|| format!("unknown rule account code: {code}"))?; - rules.push((pattern.to_lowercase(), (id, c))); - } - // The most specific pattern wins (longest, then alphabetical): the - // rules arrive as a map with no order, so "first match" would pick - // a different account from run to run when two patterns match. - rules.sort_by(|a, b| b.0.len().cmp(&a.0.len()).then_with(|| a.0.cmp(&b.0))); - let offset_for = |description: &str| { - let desc_lower = description.to_lowercase(); - rules - .iter() - .find(|(pat, _)| desc_lower.contains(pat.as_str())) - .map(|(_, v)| v.clone()) - .unwrap_or_else(|| default_offset.clone()) - }; - let base = store.ledger().base_currency(); - // Rehearse the whole file on a copy first, so a transaction that - // can't become a draft (a date in a closed period, say) refuses the - // file instead of leaving the ones before it behind. - let mut scratch = store.ledger().clone(); - for txn in &txns { - let (offset_id, _) = offset_for(&txn.description); - let amount = Money::new(txn.amount.abs(), base); - let (bank_side, offset_side) = if txn.amount > Decimal::ZERO { - (Side::Debit, Side::Credit) - } else { - (Side::Credit, Side::Debit) - }; - let e = scratch - .try_create_entry(txn.date, &txn.description) - .and_then(|e| scratch.add_line(e, bank.0, bank_side, amount).map(|()| e)) - .and_then(|e| scratch.add_line(e, offset_id, offset_side, amount)) - .map_err(|e| format!("{} {}: {e}", txn.date, txn.description)); - e?; - } - // Rehearsed whole: the upload is used up as the writing begins. - files::filed(claim); - let mut imported = Vec::new(); - for txn in txns { - let (offset_id, offset_code) = offset_for(&txn.description); - let amount = Money::new(txn.amount.abs(), store.ledger().base_currency()); - let entry_id = store - .create_entry(txn.date, &format!("Bank import — {}", txn.description)) - .map_err(|e| e.to_string())?; - // Money in: debit bank, credit offset. Money out: reverse. - let (bank_side, offset_side) = if txn.amount > Decimal::ZERO { - (Side::Debit, Side::Credit) - } else { - (Side::Credit, Side::Debit) - }; - store - .add_line(entry_id, bank.0, bank_side, amount) - .map_err(|e| e.to_string())?; - store - .add_line(entry_id, offset_id, offset_side, amount) - .map_err(|e| e.to_string())?; - imported.push(ImportedTxnOut { - entry_id, - date: txn.date.to_string(), - description: txn.description.clone(), - amount: money(txn.amount), - offset_account: offset_code, - }); - } - if link_source && let Some(att) = p.source_attachment_id { - store - .link_attachment(att, ledger_store::Subject::Account(bank.0)) - .map_err(|e| e.to_string())?; - } - let count = imported.len(); - Ok(Json(ImportBankCsvOut { - imported, - count, - source_attachment_id: p.source_attachment_id, - source_linked: link_source, - })) - } - - #[tool( - name = "books.create_schedule", - title = "Create an amortization schedule", - description = "Spread a prepaid expense (an asset used up as time passes, e.g. a year of insurance paid up front) or deferred revenue (a liability earned as time passes, e.g. an annual subscription collected up front) over the time it covers, to the cent. convention: daily (by the day from start: a mid-month start posts a small first and last month, one month end more than its months) or monthly_even (equal months; starts on the 1st); omitted, the schedule_convention setting, else daily. Record the original payment first (Dr prepaid / Cr cash, with its receipt; or Dr cash / Cr deferred revenue). Returns the plan; post it with books.run_schedules.", - annotations( - read_only_hint = false, - destructive_hint = false, - open_world_hint = false - ) - )] - async fn create_schedule( - &self, - params: Parameters, - ) -> Result, String> { - let p = params.0; - let mut store = self.lock()?; - let kind = match p.kind.as_str() { - "prepaid_expense" => ledger_core::ScheduleKind::PrepaidExpense, - "deferred_revenue" => ledger_core::ScheduleKind::DeferredRevenue, - other => { - return Err(format!( - "kind {other:?}: expected prepaid_expense or deferred_revenue" - )); - } - }; - // The books' setting, else daily (D-11, D-37). - let convention = match p.convention.as_deref() { - None => store.ledger().settings().schedule_convention(), - Some(c) => ledger_core::ScheduleConvention::parse(c) - .ok_or_else(|| format!("convention {c:?}: expected daily or monthly_even"))?, - }; - let currency = store.ledger().base_currency(); - let balance_account = resolve_account(&store, &p.balance_account)?; - let pl_account = resolve_account(&store, &p.pl_account)?; - let id = store - .register_schedule(ledger_core::NewSchedule { - description: p.description, - kind, - total: Money::new(parse_amount(&p.total)?, currency), - start: parse_date(&p.start)?, - months: p.months, - balance_account, - pl_account, - convention, - }) - .map_err(|e| e.to_string())?; - Ok(Json(schedule_out( - store.ledger(), - store.ledger().get_schedule(id).expect("registered"), - ))) - } - - #[tool( - name = "books.list_schedules", - title = "List amortization schedules", - description = "Amortization schedules (prepaid expenses, deferred revenue): each one's convention (daily or monthly_even), the first and last day it covers, each month's amount, what has posted, the next month to post, and what remains.", - annotations(read_only_hint = true, open_world_hint = false) - )] - async fn list_schedules(&self) -> Result, String> { - let store = self.lock()?; - let ledger = store.ledger(); - Ok(Json(ListSchedulesOut { - schedules: ledger - .schedules() - .map(|s| schedule_out(ledger, s)) - .collect(), - })) - } - - #[tool( - name = "books.run_schedules", - title = "Post scheduled amortization", - description = "Post every amortization schedule's months ending on or before `through` (default today) that haven't posted. Idempotent: a month posts once; a month reversed in its own month is due again. A month in a closed period, or reversed after it ended, is listed under skipped, not posted. Without confirmed=true, returns the postings it would make. Exempt from the receipt policy: the schedule is the evidence (the original payment carries the receipt).", - annotations( - read_only_hint = false, - destructive_hint = true, - idempotent_hint = true, - open_world_hint = false - ) - )] - async fn run_schedules( - &self, - params: Parameters, - ) -> Result, String> { - let p = params.0; - let through = match &p.through { - Some(s) => parse_date(s)?, - None => today(), - }; - let mut store = self.lock()?; - // A schedule whose plan can't be computed refuses the run, naming - // it, before any month posts. - let mut due: Vec<(NaiveDate, u64, Decimal)> = Vec::new(); - for s in store.ledger().schedules() { - let months = s - .due(through) - .map_err(|e| format!("schedule {}: {e}", s.id))?; - due.extend(months.into_iter().map(|(d, a)| (d, s.id, a))); - } - due.sort(); - // A month dated in a closed period can't post, nor one whose - // posting was reversed after the month ended (it would count twice - // until then): left for a human, and said, never a failed run. - let (held, due): (Vec<_>, Vec<_>) = due - .into_iter() - .partition(|(d, id, _)| store.ledger().schedule_month_hold(*id, *d).is_some()); - let skipped: Vec = held - .into_iter() - .map(|(d, id, _)| ScheduleSkippedOut { - schedule_id: id, - date: d.to_string(), - reason: store - .ledger() - .schedule_month_hold(id, d) - .unwrap_or_default(), - }) - .collect(); - let describe = |store: &Store, id: u64, date: NaiveDate| -> Result { - Ok(store - .ledger() - .preview_schedule_month(id, date) - .map_err(|e| e.to_string())? - .1) - }; - if !p.confirmed { - let postings = due - .into_iter() - .map(|(date, id, amount)| { - Ok(SchedulePostingOut { - schedule_id: id, - date: date.to_string(), - description: describe(&store, id, date)?, - amount: money(amount), - entry_id: None, - }) - }) - .collect::>()?; - return Ok(Json(RunSchedulesOut { - status: "preview".to_string(), - postings, - skipped, - })); - } - let mut postings = Vec::new(); - for (date, id, amount) in due { - let description = describe(&store, id, date)?; - let entry = store - .post_schedule_month(id, date) - .map_err(|e| e.to_string())?; - postings.push(SchedulePostingOut { - schedule_id: id, - date: date.to_string(), - description, - amount: money(amount), - entry_id: Some(entry), - }); - } - Ok(Json(RunSchedulesOut { - status: "posted".to_string(), - postings, - skipped, - })) - } - - #[tool( - name = "books.create_recurring", - title = "Create a recurring entry", - description = "A recurring entry (rent, a subscription, a loan payment): balanced lines and a frequency (weekly, monthly, quarterly, yearly) from a start date, optionally ending. books.run_recurring turns due occurrences into drafts, never posted entries: attach each actual receipt and post through the normal preview.", - annotations( - read_only_hint = false, - destructive_hint = false, - open_world_hint = false - ) - )] - async fn create_recurring( - &self, - params: Parameters, - ) -> Result, String> { - let p = params.0; - let mut store = self.lock()?; - let currency = store.ledger().base_currency(); - let frequency = ledger_core::Frequency::parse(&p.frequency).ok_or_else(|| { - format!( - "frequency {:?}: expected weekly, monthly, quarterly, or yearly", - p.frequency - ) - })?; - let lines = p - .lines - .iter() - .map(|l| { - Ok(( - resolve_account(&store, &l.account)?, - parse_side(&l.side)?, - Money::new(parse_amount(&l.amount)?, currency), - )) - }) - .collect::, String>>()?; - let id = store - .register_recurring(ledger_core::NewRecurring { - description: p.description, - lines, - frequency, - start: parse_date(&p.start)?, - end: match &p.end { - Some(e) => Some(parse_date(e)?), - None => None, - }, - }) - .map_err(|e| e.to_string())?; - let r = store - .ledger() - .recurring_entries() - .find(|r| r.id == id) - .expect("registered"); - Ok(Json(recurring_out(r))) - } - - #[tool( - name = "books.list_recurring", - title = "List recurring entries", - description = "Recurring entries and the occurrences generated so far.", - annotations(read_only_hint = true, open_world_hint = false) - )] - async fn list_recurring(&self) -> Result, String> { - let store = self.lock()?; - Ok(Json(ListRecurringOut { - recurring: store - .ledger() - .recurring_entries() - .map(recurring_out) - .collect(), - })) - } - - #[tool( - name = "books.run_recurring", - title = "Draft recurring entries", - description = "Turn every recurring entry's occurrences on or before `through` (default today) into drafts. Idempotent: an occurrence generates once. Occurrences dated in a closed period are not generated; they come back in `skipped_closed_period` for the human to decide. Each draft then goes through the normal flow: attach the receipt, preview, post.", - annotations( - read_only_hint = false, - destructive_hint = true, - idempotent_hint = true, - open_world_hint = false - ) - )] - async fn run_recurring( - &self, - params: Parameters, - ) -> Result, String> { - let through = match ¶ms.0.through { - Some(s) => parse_date(s)?, - None => today(), - }; - let mut store = self.lock()?; - let drafts = store - .run_recurring(through) - .map_err(|e| e.to_string())? - .into_iter() - .map(|(id, date, entry)| RecurringDraftOut { - recurring_id: id, - date: date.to_string(), - entry_id: entry, - }) - .collect(); - let skipped_closed_period = store - .ledger() - .recurring_in_closed_periods(through) - .into_iter() - .map(|(id, date)| RecurringSkippedOut { - recurring_id: id, - date: date.to_string(), - }) - .collect(); - Ok(Json(RunRecurringOut { - drafts, - skipped_closed_period, - })) - } - - #[tool( - name = "books.record_payout", - title = "Record a platform payout", - description = "Record a platform payout (app store, payment processor, marketplace) grossed up: Dr the deposit account the net deposit, Dr fee accounts the fees withheld, Dr/Cr adjustments (refunds, chargebacks, reserves, taxes withheld), Cr revenue the gross sales (several lines and classes allowed). Refuses unless gross - fees +/- adjustments equals the deposit to the cent, and says by how much it's off. Creates a draft: attach the payout statement as its receipt, preview, and post.", - annotations( - read_only_hint = false, - destructive_hint = false, - open_world_hint = false - ) - )] - async fn record_payout( - &self, - params: Parameters, - ) -> Result, String> { - let p = params.0; - let mut store = self.lock()?; - let class = |store: &Store, c: &Option| -> Result, String> { - c.as_deref().map(|c| resolve_class(store, c)).transpose() - }; - let lines = |store: &Store, ls: &[PayoutLineParam]| { - ls.iter() - .map(|l| { - Ok(ledger_core::PayoutLine { - account: resolve_account(store, &l.account)?, - amount: parse_amount(&l.amount)?, - class: class(store, &l.class)?, - }) - }) - .collect::, String>>() - }; - let payout = ledger_core::Payout { - deposit_account: resolve_account(&store, &p.deposit_account)?, - deposit: parse_amount(&p.deposit)?, - revenue: lines(&store, &p.revenue)?, - fees: lines(&store, &p.fees)?, - adjustments: p - .adjustments - .iter() - .map(|a| { - Ok(ledger_core::PayoutAdjustment { - account: resolve_account(&store, &a.account)?, - side: parse_side(&a.side)?, - amount: parse_amount(&a.amount)?, - class: class(&store, &a.class)?, - }) - }) - .collect::, String>>()?, - }; - let (plan, totals) = - ledger_core::payout_plan(&payout, store.ledger().chart()).map_err(|e| e.to_string())?; - let currency = store.base_currency(); - let entry_id = store - .create_entry(parse_date(&p.date)?, &p.description) - .map_err(|e| e.to_string())?; - let mut described = Vec::new(); - let mut tagged: Vec = Vec::new(); - for (index, (account, side, amount, class)) in plan.into_iter().enumerate() { - store - .add_line(entry_id, account, side, Money::new(amount, currency)) - .map_err(|e| e.to_string())?; - if let Some(class) = class { - store - .set_line_class(entry_id, index, Some(class)) - .map_err(|e| e.to_string())?; - if !tagged.contains(&class) { - tagged.push(class); - } - } - let code = store - .ledger() - .chart() - .get(account) - .map(|a| a.code.clone()) - .unwrap_or_default(); - described.push(format!("{side:?} {code} {}", money(amount))); - } - Ok(Json(RecordPayoutOut { - entry_id, - status: "draft".to_string(), - gross: money(totals.gross), - fees: money(totals.fees), - adjustments: money(totals.adjustments), - deposit: money(totals.deposit), - lines: described, - notes: tagged - .into_iter() - .filter_map(|c| projects::late_tag_note(store.ledger(), c)) - .collect(), - next: "Attach the payout statement (books.attach_receipt), then preview and post with books.post_entry.".to_string(), - })) - } - - #[tool( - name = "books.list_vendors", - title = "List vendors", - description = "Vendors, each with its 1099 decision in force today (nec, misc, not_reportable with its reason, or undetermined; who decided and when) and every dated decision, whether its W-9 is on file, and the party it belongs to (books.party). undetermined with no decided_by: nobody has decided yet (books.set_vendor_1099).", - annotations(read_only_hint = true, open_world_hint = false) - )] - async fn list_vendors(&self) -> Result, String> { - let store = self.lock()?; - Ok(Json(ListVendorsOut { - vendors: store - .ledger() - .list_vendors() - .into_iter() - .map(|v| instruments::vendor_out(&store, v)) - .collect(), - })) - } - - #[tool( - name = "books.set_vendor_1099", - title = "Record a vendor's 1099 decision", - description = "Record whether a vendor gets a 1099, with who decided (decided_by): nec (1099-NEC: contractors, attorneys), misc (1099-MISC: rent, royalties), not_reportable with reason_kind (corporation, tax_exempt, united_states, state, foreign_government, foreign_person, merchandise, employee, other with a citation) and reason in words, or undetermined. A corporation paid for legal or medical services stays nec/misc. Paid by card is never a reason (cards are excluded per payment). effective: only when the payee's status changed (an LLC's corporate election, from its date); a payee that incorporates is a new vendor. tin_on_file: a W-9 is on file (alone, it keeps the decision). Ask the human; CPA confirms.", - annotations( - read_only_hint = false, - destructive_hint = false, - idempotent_hint = true, - open_world_hint = false - ) - )] - async fn set_vendor_1099( - &self, - params: Parameters, - ) -> Result, String> { - let p = params.0; - let mut store = self.lock()?; - let id = resolve_vendor(&store, &p.vendor)?; - let decision = instruments::decision_in(&p)?; - let (changed, warnings) = match decision { - None => { - let flag = p.tin_on_file.expect("decision_in: one of the two"); - store - .set_vendor_w9_flag(id, flag) - .map_err(|e| e.to_string())?; - (ledger_core::form1099::Applied::Unchanged, Vec::new()) - } - Some(d) => { - let reached = ledger_core::form1099::decision_changes(store.ledger(), id, &d) - .map_err(|e| e.to_string())?; - let applied = store - .set_vendor_1099(id, &d, p.tin_on_file) - .map_err(|e| e.to_string())?; - let name = &store.ledger().get_vendor(id).expect("resolved").name; - let warnings = instruments::filed_year_warnings( - store.ledger(), - &format!("{name}'s 1099 decision"), - &reached, - today(), - ); - (applied, warnings) - } - }; - let v = store.ledger().get_vendor(id).expect("exists"); - Ok(Json(SetVendor1099Out { - vendor: instruments::vendor_out(&store, v), - changed: instruments::applied_key(changed).to_string(), - warnings, - })) - } - - #[tool( - name = "books.set_entry_vendor", - title = "Tag an entry's vendor", - description = "Tag an entry (draft or posted) with the vendor it paid, for 1099 tracking, or omit vendor to clear the tag. Reporting metadata only: amounts never change. On an entry naming one party or none, the vendor's party becomes its party; clearing also clears that party. On an entry naming several parties (books.set_entry_party parties) the parties stay as they are: the vendor of a party already named is tagged with every share kept; any other vendor, or clearing the tag while its party is named, is refused (change the parties instead). An entry and its reversal are tagged together. books.pay_bill tags its payments automatically; books.draft_entry takes a vendor too.", - annotations( - read_only_hint = false, - destructive_hint = false, - open_world_hint = false - ) - )] - async fn set_entry_vendor( - &self, - params: Parameters, - ) -> Result, String> { - let p = params.0; - { - let mut store = self.lock()?; - let vendor = p - .vendor - .as_deref() - .map(|v| resolve_vendor(&store, v)) - .transpose()?; - store - .set_entry_vendor(p.entry_id, vendor) - .map_err(|e| e.to_string())?; - } - self.get_entry(Parameters(GetEntryParams { - entry_id: p.entry_id, - })) - .await - } - - #[tool( - name = "books.vendor_1099", - title = "1099 worksheet", - description = "The 1099 worksheet for a calendar year. Per vendor: what counts toward its 1099 (paid out of the cash accounts, or through a financing account with the method recorded, while not decided not_reportable; an entry with several parties counts only the vendor's share), what was paid by card or payment network (excluded: Form 1099-K's), what is undetermined (a member paid, method not recorded: books.set_entry_instrument), its 1099 decision, and its status (reportable at the threshold: $600 through 2025, $2,000 in 2026, indexed after; under_threshold; not_reportable; undetermined). Lists those payments, W-9s missing, each payee's TIN last four. include_tins adds full TINs from the vault; csv hands it over. cash_accounts and financing_accounts default to the settings' (as books.export_cpa_package's do); through stops the year at a day: given a package's accounts and its as_of, the CSV is the package's 1099 worksheet. A year whose threshold isn't known is refused: pass threshold from the IRS. For the CPA to confirm.", - annotations( - read_only_hint = false, - destructive_hint = false, - open_world_hint = false - ) - )] - async fn vendor_1099( - &self, - params: Parameters, - ) -> Result, String> { - let p = params.0; - let threshold = p.threshold.as_deref().map(parse_amount).transpose()?; - let through = p.through.as_deref().map(parse_date).transpose()?; - if let Some(t) = through - && chrono::Datelike::year(&t) != p.year - { - return Err(format!( - "through ({t}) is a day of {}: the worksheet counts payments made in {} from January 1 through it", - p.year, p.year - )); - } - let store = self.lock()?; - let ledger = store.ledger(); - for code in p.cash_accounts.iter().chain(&p.financing_accounts) { - if ledger.chart().find_by_code(code).is_none() { - return Err(format!( - "no account {code} in the chart (books.chart lists them)" - )); - } - } - // With include_tins, the vault is opened once (never creating a - // key: a locked vault becomes a note on each line). - let vault = p.include_tins.then(|| self.open_vault(&store, false)); - let tins = vault - .as_ref() - .map(|vault| move |id| vault_tin(ledger, vault, id)); - // The accounts given, else the settings': the CPA package's rule - // (ledger_package::Settings::with_accounts), so its 1099 worksheet - // is this one for the same accounts and `through` its as_of. - let settings = self.settings(); - let accounts = - package_settings(&settings).with_accounts(&p.cash_accounts, &p.financing_accounts); - // A year past what wanderbooks knows is refused with guidance - // (research or ask, pass `threshold`, then update the rule). - let w = ledger_package::vendor_1099_worksheet( - ledger, - &accounts, - p.year, - threshold, - through, - tins.as_ref().map(|f| f as ledger_package::TinSource<'_>), - )?; - let warning = p.include_tins.then(|| { - "This worksheet holds full TINs, for the CPA; keep it out of the books.".to_string() - }); - let csv_file = match p.csv { - false => None, - true => { - let csv = ledger_package::vendor_1099_csv(&w, p.include_tins); - if p.include_tins { - self.wrote_tins(&store, csv.as_bytes())?; - } - let name = ledger_package::vendor_1099_csv_name(w.year, p.include_tins); - Some(self.hand_over(&book_name(&store), &name, "text/csv", csv.into_bytes())?) - } - }; - let payments = |list: &[ledger_core::Payment1099]| -> Vec { - list.iter() - .map(|p| Payment1099Out { - entry_id: p.entry_id, - date: p.date.to_string(), - vendor_id: p.vendor_id, - vendor: ledger - .get_vendor(p.vendor_id) - .map(|v| v.name.clone()) - .unwrap_or_default(), - amount: money(p.amount), - instrument: p - .instrument - .and_then(|i| ledger.get_instrument(i)) - .map(|i| i.label.clone()), - }) - .collect() - }; - let excluded_card_payments = payments(&w.excluded_card_payments); - let undetermined_payments = payments(&w.undetermined_payments); - let other_receipts = payments(&w.other_receipts); - let vendors = w - .lines - .into_iter() - .map(|l| Vendor1099LineOut { - vendor_id: l.vendor_id, - name: l.name, - form_1099: l.form_1099, - decision_1099: instruments::decision_out(Some(&ledger_core::Dated1099 { - effective: l.effective, - decision: l.decision.clone(), - source: l.decided.clone(), - })), - decisions_in_year: l - .decisions - .iter() - .map(|d| instruments::decision_out(Some(d))) - .collect(), - tin_on_file: l.tin_on_file, - paid: money(l.paid), - excluded_card: money(l.excluded_card), - undetermined: money(l.undetermined), - not_reportable: money(l.not_reportable), - status: l.status.key().to_string(), - decision_needed: l.decision_needed, - reportable: l.reportable, - party_id: l.party_id, - tin_last4: l.tin_last4, - tin: l.tin, - tin_type: l.tin_type, - tin_note: l.tin_note, - }) - .collect(); - Ok(Json(Vendor1099Out { - year: w.year, - threshold: money(w.threshold), - vendors, - missing_w9: w.missing_w9, - excluded_card_payments, - undetermined_payments, - other_receipts, - untagged_entries: w.untagged_payments, - unresolved: w.unresolved, - notes: w.notes, - warning, - csv: csv_file, - })) - } - - #[tool( - name = "books.chart_template", - title = "Starter chart of accounts", - description = "A starter chart of accounts for a kind of business (sole proprietorship, partnership or multi-member LLC, S corporation, C corporation): the right equity and owner-pay accounts (draws; per-partner capital and distributions plus guaranteed payments; officer compensation and distributions; dividends and income tax), common income and expense accounts with tax categories assigned, and the settings that go with it (changes for books.set_setting). A proposal: review it with the human, adjust, then books.import_chart_csv and books.set_setting.", - annotations(read_only_hint = true, open_world_hint = false) - )] - async fn chart_template( - &self, - params: Parameters, - ) -> Result, String> { - let p = params.0; - let entity = ledger_import::EntityType::parse(&p.entity_type).ok_or_else(|| { - format!( - "entity_type {:?}: expected sole_proprietorship, partnership, s_corporation, or c_corporation (an LLC is one of these for tax purposes)", - p.entity_type - ) - })?; - let t = ledger_import::chart_template(entity, &p.owners)?; - // The template's settings, as the changes that set them in new - // books (the one way settings change: books.set_setting). - let settings = ledger_config::codec::import( - &t.settings_toml, - None, - &ledger_core::settings::Settings::default(), - self.currency(), - )? - .changes - .into_iter() - .filter_map(|c| { - Some(TemplateSettingOut { - value: c.value?, - key: c.key, - }) - }) - .collect(); - Ok(Json(ChartTemplateOut { - csv: t.csv(), - settings, - notes: t.notes, - })) - } - - #[tool( - name = "books.tax_categories", - title = "Tax categories", - description = "The tax categories an account can map to (where it lands on the income tax return), filtered to the business's return when the settings say which (tax_form or tax_classification). Each has a default deductible percent (meals 50, nondeductible 0) and guidance. Categories are stable keys, not form line numbers.", - annotations(read_only_hint = true, open_world_hint = false) - )] - async fn tax_categories(&self) -> Result, String> { - let form = self.settings().company.tax_form; - let categories = ledger_core::tax::CATEGORIES - .iter() - .filter(|c| form.is_none_or(|f| c.forms.contains(&f))) - .map(|c| TaxCategoryOut { - key: c.key.to_string(), - label: c.label.to_string(), - kind: match c.kind { - ledger_core::TaxKind::Income => "income", - ledger_core::TaxKind::CostOfGoods => "cost_of_goods", - ledger_core::TaxKind::Deduction => "deduction", - } - .to_string(), - default_deductible_percent: c.default_deductible_percent, - note: c.note.to_string(), - }) - .collect(); - Ok(Json(TaxCategoriesOut { - form: form.map(|f| f.label().to_string()), - categories, - })) - } - - #[tool( - name = "books.renumber_account", - title = "Renumber an account", - description = "Give an account a new code. Only the code changes: its id, name, and every entry posted to it stay, so balances and reports don't move. Refuses a code in use, or a control account's code (invoices, bills, payroll, closes) for an account of the wrong type. Lists any settings that still name the old code. Never renumber the human's accounts on your own: only when they ask.", - annotations( - read_only_hint = false, - destructive_hint = true, - open_world_hint = false - ) - )] - async fn renumber_account( - &self, - params: Parameters, - ) -> Result, String> { - let p = params.0; - let mut store = self.lock()?; - let id = resolve_account(&store, &p.account)?; - let previous_code = store - .ledger() - .chart() - .get(id) - .expect("resolved") - .code - .clone(); - store - .renumber_account(id, &p.code) - .map_err(|e| e.to_string())?; - Ok(Json(RenumberAccountOut { - id, - config_mentions: self - .config_mentions(&previous_code) - .into_iter() - .chain(convention_mentions(store.ledger(), &previous_code)) - .collect(), - previous_code, - code: p.code, - })) - } - - #[tool( - name = "books.rename_account", - title = "Rename an account", - description = "Give an account a new name. Only the name changes: its id, code, type, tax category and every entry posted to it stay, so no entry, balance or statement amount changes; every report, past periods included, prints the new name, and the journal keeps the old one. Refuses a blank name, the name it has, a name another account under the same parent has (ignoring capitals), one holding nine digits in a row, or one that isn't a single line of at most 200 characters. QuickBooks matches accounts by name: if the CPA imported an earlier IIF, tell them. Never rename the human's accounts on your own: only when they ask.", - annotations( - read_only_hint = false, - destructive_hint = true, - open_world_hint = false - ) - )] - async fn rename_account( - &self, - params: Parameters, - ) -> Result, String> { - use ledger_store::reconcilable; - let p = params.0; - let mut store = self.lock()?; - let id = resolve_account(&store, &p.account)?; - let reason = p.reason.as_deref().map(str::trim).filter(|r| !r.is_empty()); - // The sets the books pick by name, before and after (rules.md - // B-31: a rename moves no amount, but it can move an account into - // or out of one of these). - let configured_cash = self.settings().roles.cash.clone(); - let picked = |ledger: &Ledger| { - ( - configured_cash.is_empty() - && reconcilable::cash_accounts(ledger, &[]).contains(&id), - reconcilable::card_accounts(ledger).contains(&id), - ) - }; - let before = picked(store.ledger()); - let previous_name = store - .ledger() - .chart() - .get(id) - .expect("resolved") - .name - .clone(); - store - .rename_account(id, &p.name, reason) - .map_err(|e| e.to_string())?; - let ledger = store.ledger(); - let a = ledger.chart().get(id).expect("resolved"); - let after = picked(ledger); - let mut notes = vec![format!( - "Every report, past periods included, prints \u{201c}{}\u{201d} now; the journal keeps \u{201c}{previous_name}\u{201d}. QuickBooks matches accounts by name: if the CPA imported an earlier IIF, tell them.", - a.name - )]; - let children = ledger - .chart() - .all() - .filter(|c| c.parent == Some(id)) - .count(); - if children > 0 { - notes.push(format!( - "Its {children} sub-account{} carry the new name in their QuickBooks full names (IIF) too.", - if children == 1 { "" } else { "s" } - )); - } - for (was, is, what) in [ - ( - before.0, - after.0, - "a cash account (books.cash_accounts is unset, so cash accounts are the asset accounts whose names say cash, bank, checking, savings or money market): the reconciliation reminders and the viewer's cash figures, Cash page and cash flow statement follow it. Set books.cash_accounts to choose them by code", - ), - ( - before.1, - after.1, - "a card account (a liability whose name says card): the reconciliation reminders follow it", - ), - ] { - if was != is { - notes.push(format!( - "{} {} is {} {what}.", - a.code, - a.name, - if is { "now" } else { "no longer" } - )); - } - } - // A blank old name (an earlier version wrote one) is in every - // text: it names no convention. - let old = previous_name.trim().to_lowercase(); - for c in ledger - .conventions() - .conventions() - .filter(|c| c.retired.is_none() && !old.is_empty()) - .filter(|c| { - c.title.to_lowercase().contains(&old) || c.body_md.to_lowercase().contains(&old) - }) - { - notes.push(format!( - "Convention {} names \u{201c}{previous_name}\u{201d} in its text: if it means this account, revise it with books.set_convention.", - c.id - )); - } - Ok(Json(RenameAccountOut { - id, - code: a.code.clone(), - previous_name, - name: a.name.clone(), - notes, - })) - } - - #[tool( - name = "books.label_asset", - title = "Label a fixed asset", - description = "Change a fixed asset's description, link it to the entry that acquired it (acquisition_entry), or set or change its tag (the label on the thing: letters, digits, - _ . /, at most 32; unique ignoring case and never reused by another asset, even after a retag or disposal) and its manufacturer serial number. The asset is its id or current tag. Journaled. Print labels with books.asset_labels.", - annotations( - read_only_hint = false, - destructive_hint = false, - open_world_hint = false - ) - )] - async fn label_asset( - &self, - params: Parameters, - ) -> Result, String> { - let p = params.0; - let mut store = self.lock()?; - let id = resolve_asset_ref(&store, &p.asset)?; - let clear = p.clear_serial.unwrap_or(false); - if clear && p.serial.is_some() { - return Err("pass serial or clear_serial, not both".into()); - } - if p.tag.is_none() - && p.serial.is_none() - && !clear - && p.acquisition_entry.is_none() - && p.description.is_none() - { - return Err( - "nothing to change: pass tag, serial, clear_serial, description, or acquisition_entry" - .into(), - ); - } - if let Some(d) = &p.description { - store - .ledger() - .check_asset_description(id, d) - .map_err(|e| e.to_string())?; - } - if let Some(entry) = p.acquisition_entry { - store - .ledger() - .check_asset_acquisition(id, entry) - .map_err(|e| e.to_string())?; - } - // Both checked first, so a bad serial doesn't leave a new tag behind. - if let Some(tag) = &p.tag { - store - .ledger() - .check_asset_tag_for(Some(id), tag) - .map_err(|e| e.to_string())?; - } - let serial = if clear { None } else { p.serial.as_deref() }; - if p.serial.is_some() || clear { - store - .ledger() - .check_asset_serial(id, serial) - .map_err(|e| e.to_string())?; - } - if let Some(tag) = &p.tag { - store.set_asset_tag(id, tag).map_err(|e| e.to_string())?; - } - if p.serial.is_some() || clear { - store - .set_asset_serial(id, serial) - .map_err(|e| e.to_string())?; - } - if let Some(entry) = p.acquisition_entry { - store - .set_asset_acquisition(id, entry) - .map_err(|e| e.to_string())?; - } - if let Some(d) = &p.description { - store - .set_asset_description(id, d) - .map_err(|e| e.to_string())?; - } - let a = store.ledger().get_asset(id).expect("resolved"); - Ok(Json(LabelAssetOut { - asset_id: id, - tag: a.tag.clone(), - serial: a.serial.clone(), - acquisition_entry: a.acquired_by, - description: a.description.clone(), - })) - } - - #[tool( - name = "books.correct_asset", - title = "Correct a fixed asset", - description = "Correct a registered fixed asset's cost, salvage value, useful life, or in-service date (never before its acquisition entry's date) when it was registered wrong, with the reason; omitted fields keep their values. Refused while the books carry depreciation for it (a run posted and not reversed: correct with entries then). Its tag, serial, links and basis record stay (a recorded value at contribution never moves; a pre-P3 basis reads it as the book cost, so its built-in gain moves, noted); correct the entry that put it on the books too (books.export fixed_asset_register shows any difference). Journaled with the old figures.", - annotations( - read_only_hint = false, - destructive_hint = true, - open_world_hint = false - ) - )] - async fn correct_asset( - &self, - params: Parameters, - ) -> Result, String> { - let p = params.0; - let mut notes = Vec::new(); - let in_service = aliased( - "in_service_date", - p.in_service_date.as_deref(), - "in_service", - p.in_service.as_deref(), - &mut notes, - same_day, - )?; - let mut store = self.lock()?; - let id = resolve_asset_ref(&store, &p.asset)?; - let a = store.ledger().get_asset(id).expect("resolved").clone(); - let base = store.ledger().base_currency(); - let fix = ledger_core::AssetCorrection { - cost: match &p.cost { - Some(c) => Money::new(parse_amount(c)?, base), - None => a.cost, - }, - salvage_value: match &p.salvage_value { - Some(c) => Money::new(parse_amount(c)?, base), - None => a.salvage_value, - }, - useful_life_months: p.useful_life_months.unwrap_or(a.useful_life_months), - in_service_date: match in_service { - Some(d) => parse_date(d)?, - None => a.in_service_date, - }, - }; - let before = a.built_in_gain_704c(); - store - .correct_asset(id, fix, &p.reason) - .map_err(|e| e.to_string())?; - let ledger = store.ledger(); - let a = ledger.get_asset(id).expect("resolved"); - if a.built_in_gain_704c() != before { - notes.push(format!( - "its basis was recorded before P3 with no value at contribution, which reads as the book cost, so its §704(c) built-in gain moved with the cost ({} to {}): record the value at contribution with books.set_asset_tax_basis", - before.map_or("none".into(), money), - a.built_in_gain_704c().map_or("none".into(), money) - )); - } - Ok(Json(AssetSummaryOne { - asset_id: id, - tag: a.tag.clone(), - cost: money(a.cost.amount), - salvage_value: money(a.salvage_value.amount), - useful_life_months: a.useful_life_months, - in_service_date: a.in_service_date.to_string(), - acquired_date: ledger - .asset_acquired_date(id) - .expect("known asset") - .to_string(), - basis: basis_out(a)?, - notes, - })) - } - - #[tool( - name = "books.set_asset_tax_basis", - title = "Set an asset's tax basis", - description = "Record how the company got a fixed asset and its tax figures beside book value. rule: purchase (basis is cost, IRC §1012); carryover_723 (a member's business property: their adjusted basis carries over, IRC §723); personal_use_conversion (a member's personal property: §723 basis likewise, depreciable basis the lesser of value and that basis, Treas. Reg. §1.168(i)-4(b)). A contribution needs contributor_adjusted_basis and fmv_at_contribution (ask the human; never assume the cost); built_in_gain_704c = value − §723 basis (Treas. Reg. §1.704-3(a)(3)(ii)), a loss counting only for the contributor. sec704c_method once chosen; basis_source: who says so. Journaled; book figures don't change.", - annotations( - read_only_hint = false, - destructive_hint = false, - open_world_hint = false - ) - )] - async fn set_asset_tax_basis( - &self, - params: Parameters, - ) -> Result, String> { - let p = params.0; - let mut notes = Vec::new(); - let adjusted = aliased( - "contributor_adjusted_basis", - p.contributor_adjusted_basis.as_deref(), - "contributor_basis", - p.contributor_basis.as_deref(), - &mut notes, - same_amount, - )?; - let basis = parse_asset_basis( - &p.rule, - adjusted, - p.fmv_at_contribution.as_deref(), - p.sec704c_method.as_deref(), - p.basis_source.as_ref(), - &mut notes, - )?; - let mut store = self.lock()?; - let id = resolve_asset_ref(&store, &p.asset)?; - store - .set_asset_basis(id, basis) - .map_err(|e| e.to_string())?; - let a = store.ledger().get_asset(id).expect("resolved"); - Ok(Json(SetAssetTaxBasisOut { - asset_id: id, - basis: basis_out(a)?.ok_or("the basis just set has no figures")?, - notes, - })) - } - - #[tool( - name = "books.asset_labels", - title = "Asset label sheet", - description = "A printable sheet of asset labels (a PDF, handed over: run its download to save it): each label shows the company, the tag, a QR code of the tag, the description, and the serial number. Avery 5160/8160 (US letter, 30 labels of 1\" x 2-5/8\"); skip reuses a partly used sheet. Omit assets for every active asset with a tag. Save the file and tell the human to print it at 100% scale.", - annotations(read_only_hint = true, open_world_hint = false) - )] - async fn asset_labels( - &self, - params: Parameters, - ) -> Result, String> { - let p = params.0; - let store = self.lock()?; - let ledger = store.ledger(); - let assets: Vec<&ledger_core::Asset> = match &p.assets { - Some(refs) => refs - .iter() - .map(|r| { - let id = resolve_asset_ref(&store, r)?; - Ok(ledger.get_asset(id).expect("resolved")) - }) - .collect::>()?, - None => ledger - .registered_assets() - .filter(|a| a.status == ledger_core::AssetStatus::Active && a.tag.is_some()) - .collect(), - }; - let labels = assets - .iter() - .map(|a| { - Ok(ledger_import::AssetLabel { - tag: a.tag.clone().ok_or_else(|| { - format!( - "asset {} has no tag: give it one with books.label_asset first", - a.id - ) - })?, - description: a.description.clone(), - serial: a.serial.clone(), - }) - }) - .collect::, String>>()?; - let pdf = ledger_import::asset_labels_pdf( - &labels, - &self.settings().company.name, - ledger_import::LabelSheet::Avery5160, - p.skip.unwrap_or(0) as usize, - ) - .map_err(|e| e.to_string())?; - let file = self.hand_over( - &book_name(&store), - &format!("asset-labels-{}.pdf", today()), - "application/pdf", - pdf, - )?; - Ok(Json(AssetLabelsOut { - file, - tags: labels.into_iter().map(|l| l.tag).collect(), - })) - } - - #[tool( - name = "books.settings", - title = "Settings in force", - description = "The settings in force (company, fiscal year, tax form, members, basis, receipt rule with every account it requires or exempts, cash flow roles, close target, time zone, approval rules), every key the books know with its value and who set it (change them with books.set_setting), and problems: settings or conventions that disagree with the chart, each saying what to change.", - annotations(read_only_hint = true, open_world_hint = false) - )] - async fn settings_tool(&self) -> Result, String> { - // The books first: their settings sync as they lock, so the - // settings read after are theirs. - let store = self.lock()?; - let cfg = self.settings(); - let c = &cfg.company; - Ok(Json(SettingsOut { - timezone: cfg.timezone.clone(), - today: today().to_string(), - approval_rules: match store.ledger().settings().approval_rules() { - ledger_core::settings::ApprovalMode::Off => "off", - ledger_core::settings::ApprovalMode::Enforce => "enforce", - } - .to_string(), - keys: kept::keys_out(&store.ledger().settings().clone()), - company: c.name.clone(), - fiscal_year_start: format!("{:02}-{:02}", c.fiscal_year_start.0, c.fiscal_year_start.1), - tax_form: c.tax_form.map(|f| f.label().to_string()), - basis: c.basis.clone(), - receipt_rule: cfg.policy.rule(self.currency()), - receipt_required_accounts: cfg.policy.receipt_required_accounts.clone(), - receipt_exempt_accounts: cfg.policy.receipt_exempt_accounts.clone(), - parties: cfg.parties.key().to_string(), - members: c - .members - .iter() - .map(|m| MemberSettingOut { - name: m.name.clone(), - share: m.share.normalize().to_string(), - capital_account: m.capital_account.clone(), - distribution_account: m.distribution_account.clone(), - files_jointly_with: m.files_jointly_with.clone(), - }) - .collect(), - cash_accounts: cfg.roles.cash.clone(), - financing_accounts: cfg.roles.financing.clone(), - investing_accounts: cfg.roles.investing.clone(), - close_into: c.close_into.clone(), - close_sweep: c.close_sweep.clone(), - problems: settings_problems(store.ledger(), &cfg), - })) - } - - #[tool( - name = "books.roles", - title = "Control account roles", - description = "The control roles invoices, bills, payroll, and closes post to (receivables, revenue, payables, general expense, retained earnings, payroll expense and withholdings), and the account filling each: the one assigned with books.assign_role, else the account at the role's default code.", - annotations(read_only_hint = true, open_world_hint = false) - )] - async fn roles(&self) -> Result, String> { - let store = self.lock()?; - Ok(Json(RolesOut { - roles: roles_out(store.ledger()), - })) - } - - #[tool( - name = "books.assign_role", - title = "Assign a control role", - description = "Assign a control role (see books.roles) one of the chart's own accounts, so invoices, bills, payroll, or closes post there instead of the default code; account null returns the role to its default code. Journaled. Refuses an account of the wrong type, a placeholder, one already filling another role, and moving a receivables, payables, withholding, or retained earnings role while its account holds a balance (move the balance with an entry first). Posted entries never change. Only when the human agrees.", - annotations( - read_only_hint = false, - destructive_hint = true, - open_world_hint = false - ) - )] - async fn assign_role( - &self, - params: Parameters, - ) -> Result, String> { - let p = params.0; - let mut store = self.lock()?; - let account = match &p.account { - Some(a) => Some(resolve_account(&store, a)?), - None => None, - }; - store - .assign_role(&p.role, account) - .map_err(|e| e.to_string())?; - Ok(Json(RolesOut { - roles: roles_out(store.ledger()), - })) - } - - #[tool( - name = "books.set_tax_category", - title = "Set an account's tax category", - description = "Map a revenue or expense account to a tax category (see books.tax_categories), optionally overriding its deductible percent; category null clears it (refused under policy.require_tax_category = refuse, for a revenue or expense account that isn't a header). Drives the tax_summary report.", - annotations( - read_only_hint = false, - destructive_hint = false, - open_world_hint = false - ) - )] - async fn set_tax_category( - &self, - params: Parameters, - ) -> Result, String> { - let p = params.0; - let mut store = self.lock()?; - let id = resolve_account(&store, &p.account)?; - let pct = match &p.deductible_percent { - Some(s) => Some(parse_amount(s)?), - None => None, - }; - if let Some(cat) = &p.category { - let t = store - .ledger() - .chart() - .get(id) - .expect("resolved") - .account_type; - ledger_core::tax::check_category(cat, t, self.settings().company.tax_form) - .map_err(|e| e.to_string())?; - } - store - .set_account_tax(id, p.category.as_deref(), pct) - .map_err(|e| e.to_string())?; - Ok(Json(SetTaxCategoryOut { ok: true })) - } - - #[tool( - name = "books.import_chart_csv", - title = "Import a chart of accounts", - description = "Import a chart of accounts from CSV (header: code,name,type,parent_code,tax_category; the last is optional), as upload_id: upload it with books.begin_upload purpose import and `wanderbooks upload` (a file never passes through you). Types: asset, liability, equity, revenue, expense. Parents must come before children. Fails on the first bad row; nothing is half-imported. A revenue or expense row without a tax_category is listed in warnings, or, under policy.require_tax_category = refuse, refuses the file.", - annotations( - read_only_hint = false, - destructive_hint = false, - open_world_hint = false - ) - )] - async fn import_chart_csv( - &self, - params: Parameters, - ) -> Result, String> { - let p = params.0; - let (csv, claim) = self.csv_given(&p.upload_id)?; - let rows = ledger_import::parse_chart_csv(&csv).map_err(|e| e.to_string())?; - let mut store = self.lock()?; - let parse_type = |t: &str| match t { - "asset" => Ok(AccountType::Asset), - "liability" => Ok(AccountType::Liability), - "equity" => Ok(AccountType::Equity), - "revenue" => Ok(AccountType::Revenue), - "expense" => Ok(AccountType::Expense), - _ => Err(format!("bad account type: {t}")), - }; - // Rehearse the whole import on a copy of the ledger first, so a bad - // row anywhere (a duplicate within the file, a parent listed after - // its child or of another type, a blank code, a name a sibling - // has, a missing category the policy requires) refuses the file - // and nothing is half-imported. The old up-front checks covered - // only clashes with the existing chart and tax categories. - let mut scratch = store.ledger().clone(); - let mut warnings = Vec::new(); - for row in &rows { - let account_type = parse_type(&row.account_type)?; - let parent = match &row.parent_code { - Some(code) => Some( - scratch - .chart() - .find_by_code(code) - .map(|a| a.id) - .ok_or_else(|| format!("{}: unknown parent code {code}", row.code))?, - ), - None => None, - }; - let id = scratch - .add_account(&row.code, &row.name, account_type, parent, false) - .map_err(|e| format!("{}: {e}", row.code))?; - // A row is never a header account (the file can't say so), so - // a revenue or expense row needs a category under refuse. - scratch - .check_tax_category_required( - &row.code, - &row.name, - account_type, - false, - row.tax_category.as_deref(), - ) - .map_err(|e| { - format!( - "{}: {e}, as the file's fifth column (tax_category); a row of the file is never a header account", - row.code - ) - })?; - if let Some(cat) = &row.tax_category { - ledger_core::tax::check_category( - cat, - account_type, - self.settings().company.tax_form, - ) - .map_err(|e| format!("{}: {e}", row.code))?; - // A category an earlier version doesn't know waits for the - // books' format upgrade: refused here, with the file whole, - // never after the rows before it were written. - if let Some(f) = ledger_store::format::tax_category_feature(cat) { - store - .ensure_feature(f) - .map_err(|e| format!("{}: {e}", row.code))?; - } - scratch - .set_account_tax(id, Some(cat), None) - .map_err(|e| format!("{}: {e}", row.code))?; - } else if ledger_core::tax::needs_category(account_type, false) { - warnings.push(uncategorized_warning(&row.code, &row.name)); - } - } - // Rehearsed whole: the upload is used up as the writing begins. - files::filed(claim); - let mut created = Vec::new(); - for row in rows { - let account_type = match row.account_type.as_str() { - "asset" => AccountType::Asset, - "liability" => AccountType::Liability, - "equity" => AccountType::Equity, - "revenue" => AccountType::Revenue, - "expense" => AccountType::Expense, - _ => return Err(format!("bad account type: {}", row.account_type)), - }; - let parent = match &row.parent_code { - Some(code) => Some( - store - .ledger() - .chart() - .find_by_code(code) - .map(|a| a.id) - .ok_or_else(|| format!("unknown parent code: {code}"))?, - ), - None => None, - }; - store - .add_account_with_tax( - &row.code, - &row.name, - account_type, - parent, - false, - row.tax_category.as_deref().map(|c| (c, None)), - ) - .map_err(|e| e.to_string())?; - created.push(row.code.clone()); - } - let count = created.len(); - Ok(Json(ImportChartCsvOut { - created, - count, - warnings, - })) - } - - #[tool( - name = "books.export", - title = "Export a report", - description = "Export a report. Reports: profit_and_loss, balance_sheet, cash_flow (direct method, with its reconciliation and noncash schedules; needs cash_accounts), trial_balance (as_of), members_equity (multi-member LLC; members from the settings), tax_summary, general_ledger (by account: running balances, splits), general_journal (each entry posted in the period; alias journal), fixed_asset_register (as_of: every asset with tag, serial, cost, depreciation, book value, reconciled to the books). The file is handed over (run its download to save it; never its bytes). Formats: pdf and xlsx for the statements, tax_summary and fixed_asset_register; xlsx, csv, json for every report; iif for general_journal or journal (QuickBooks Desktop: plain ASCII text, CR LF; every posted entry from the first through as_of (start and end are refused), period closes left out, DOCNUM the entry id; names, numbers and memos fit QuickBooks' limits; warnings says what is written differently from the books, memos grouped, to tell the human). Dates: start/end for period reports (omitted: the fiscal year to date), as_of for balance_sheet, trial_balance, fixed_asset_register and iif (omitted: today).", - annotations(read_only_hint = true, open_world_hint = false) - )] - async fn export(&self, params: Parameters) -> Result, String> { - let p = params.0; - let format = p.format.to_lowercase(); - if !["csv", "json", "xlsx", "pdf", "iif"].contains(&format.as_str()) { - return Err(format!( - "bad format {:?}: expected \"csv\", \"json\", \"xlsx\", \"pdf\", or \"iif\"", - p.format - )); - } - let report = p.report.to_lowercase(); - if format == "iif" { - if !matches!(report.as_str(), "journal" | "general_journal") { - return Err( - "iif format is only valid for the general_journal report (alias journal)" - .to_string(), - ); - } - if p.start.is_some() || p.end.is_some() { - return Err("iif takes as_of, not start or end: the file holds every posted entry from the first through as_of, so QuickBooks' balances match the books'".to_string()); - } - // As of today by default, like every other as-of export - // (rules.md B-30); never a period, so the general journal's - // default period never shortens it (D-36, B-34). - let as_of = self.as_of(p.as_of.as_deref())?; - let iif = self.export_iif(as_of, &p.cash_accounts)?; - let warnings = iif.warnings(); - let file = self.hand_over_export(&report, &format, iif.text.into_bytes())?; - return Ok(Json(ExportOut { - format, - report, - file, - warnings, - })); - } - if format == "xlsx" || format == "pdf" { - let bytes = if format == "xlsx" { - self.export_xlsx(&report, &p)? - } else { - self.export_pdf(&report, &p)? - }; - let file = self.hand_over_export(&report, &format, bytes)?; - return Ok(Json(ExportOut { - format, - report, - file, - warnings: Vec::new(), - })); - } - let content = match report.as_str() { - "journal" | "general_journal" => self.export_journal(&format, &p)?, - "trial_balance" => self.export_trial_balance(&format, p.as_of.as_deref())?, - "general_ledger" => self.export_general_ledger(&format, &p)?, - "members_equity" => self.export_members_equity(&format, &p)?, - "tax_summary" => self.export_tax_summary(&format, &p)?, - "profit_and_loss" => self.export_pnl(&format, p.start.as_deref(), p.end.as_deref())?, - "balance_sheet" => self.export_balance_sheet(&format, p.as_of.as_deref())?, - "cash_flow" => self.export_cash_flow(&format, &p)?, - "fixed_asset_register" => { - self.export_fixed_asset_register(&format, p.as_of.as_deref())? - } - _ => { - return Err(format!( - "bad report {:?}: expected general_journal, general_ledger, trial_balance, profit_and_loss, balance_sheet, cash_flow, members_equity, tax_summary, or fixed_asset_register", - p.report - )); - } - }; - let file = self.hand_over_export(&report, &format, content.into_bytes())?; - Ok(Json(ExportOut { - format, - report, - file, - warnings: Vec::new(), - })) - } - - /// An export's file, handed over (files.rs INVARIANT 6, D-46): named - /// for its report and today, typed by its format. - fn hand_over_export( - &self, - report: &str, - format: &str, - bytes: Vec, - ) -> Result { - let mime = match format { - "csv" => "text/csv", - "json" => "application/json", - "xlsx" => "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", - "pdf" => "application/pdf", - // IIF is tab-separated ASCII text. - _ => "text/plain", - }; - let book = book_name(&*self.lock()?); - self.hand_over( - &book, - &format!("{report}-{}.{format}", today()), - mime, - bytes, - ) - } - - /// Lay out a financial statement for rendering, from export params. - fn layout_statement( - &self, - ledger: &Ledger, - report: &str, - p: &ExportParams, - ) -> Result { - use ledger_import::report; - let members = self.report_members(); - Ok(match report { - "profit_and_loss" => report::profit_and_loss(&self.pnl_statement( - ledger, - p.start.as_deref(), - p.end.as_deref(), - None, - )?), - "balance_sheet" => { - let bs = self.bs_statement(ledger, p.as_of.as_deref())?; - let earnings = self.year_earnings(ledger, bs.as_of)?; - report::balance_sheet(&bs, earnings, &members) - } - "cash_flow" => report::cash_flow(&self.cash_flow_for( - ledger, - p.start.as_deref(), - p.end.as_deref(), - &p.cash_accounts, - &p.financing_accounts, - &p.investing_accounts, - )?), - "trial_balance" => { - let as_of = self.as_of(p.as_of.as_deref())?; - report::trial_balance(&reports::trial_balance_as_of(ledger, as_of), as_of) - } - "tax_summary" => { - let (start, end) = self.statement_range(p.start.as_deref(), p.end.as_deref())?; - report::tax_summary( - &ledger_core::tax_summary(ledger, start, end, self.settings().company.tax_form) - .map_err(|e| e.to_string())?, - ) - } - "members_equity" => { - let (start, end) = self.statement_range(p.start.as_deref(), p.end.as_deref())?; - let me = - ledger_core::members_equity(ledger, start, end, &self.member_capitals(ledger)?) - .map_err(|e| e.to_string())?; - report::members_equity(&me) - } - _ => { - return Err(format!( - "bad report {report:?}: expected profit_and_loss, balance_sheet, cash_flow, trial_balance, members_equity, or tax_summary" - )); - } - }) - } - - /// Render a report to XLSX bytes via ledger-import. - fn export_xlsx(&self, report: &str, p: &ExportParams) -> Result, String> { - use ledger_import::{XlsxReport, report_to_xlsx}; - let store = self.lock()?; - let meta = self.report_meta(&store); - let ledger = store.ledger(); - if report == "fixed_asset_register" { - let as_of = self.as_of(p.as_of.as_deref())?; - let r = ledger_core::fixed_asset_register(ledger, as_of).map_err(|e| e.to_string())?; - return ledger_import::asset_register_xlsx(&r, &meta).map_err(|e| e.to_string()); - } - let xlsx = |r: XlsxReport<'_>| report_to_xlsx(&r, &meta).map_err(|e| e.to_string()); - match report { - "general_ledger" => { - let (start, end) = self.statement_range(p.start.as_deref(), p.end.as_deref())?; - let gl = - ledger_core::general_ledger(ledger, start, end).map_err(|e| e.to_string())?; - xlsx(XlsxReport::GeneralLedger(&gl)) - } - "general_journal" | "journal" => { - let (start, end) = self.statement_range(p.start.as_deref(), p.end.as_deref())?; - xlsx(XlsxReport::GeneralJournal(ledger, start, end)) - } - _ => xlsx(XlsxReport::Statement( - &self.layout_statement(ledger, report, p)?, - )), - } - } - - /// Render a financial statement to PDF bytes via ledger-import. - fn export_pdf(&self, report: &str, p: &ExportParams) -> Result, String> { - if matches!(report, "general_ledger" | "general_journal" | "journal") { - return Err(format!("{report} exports as xlsx, csv, or json, not pdf")); - } - let store = self.lock()?; - let meta = self.report_meta(&store); - if report == "fixed_asset_register" { - let as_of = self.as_of(p.as_of.as_deref())?; - let r = ledger_core::fixed_asset_register(store.ledger(), as_of) - .map_err(|e| e.to_string())?; - return ledger_import::asset_register_pdf(&r, &meta).map_err(|e| e.to_string()); - } - let st = self.layout_statement(store.ledger(), report, p)?; - ledger_import::statement_pdf(&st, &meta).map_err(|e| e.to_string()) - } - - /// The journal through `as_of` as IIF via ledger-package (the CPA - /// package's own general-journal.iif): the cash accounts (`cash`, else - /// the settings') BANK, the receivable and payable control accounts AR - /// and AP, the accounts of an asset bought by `as_of` FIXASSET, the - /// rest by their wanderbooks type; every posted entry from the first, - /// closes left out. - fn export_iif( - &self, - as_of: chrono::NaiveDate, - cash: &[String], - ) -> Result { - let store = self.lock()?; - let settings = self.settings(); - let cash = if cash.is_empty() { - settings.roles.cash.as_slice() - } else { - cash - }; - ledger_package::export_iif(store.ledger(), cash, as_of) - } - - // ------------------------------------------------------------------ - // Fixed assets - // ------------------------------------------------------------------ - - #[tool( - name = "books.register_asset", - title = "Register a fixed asset", - description = "Register a fixed asset (equipment, vehicles, buildings). Links three accounts: cost (asset), accumulated depreciation (asset, contra), depreciation expense. in_service_date (ready and available for its use) starts depreciation; the acquisition_entry (its purchase or contribution, dated on or before that) gives the acquired date, which puts it on the register. Its tax basis (basis_rule and the contribution facts) as books.set_asset_tax_basis takes it. Depreciation is straight-line, book basis only — tax depreciation (MACRS, Section 179) stays with the CPA.", - annotations( - read_only_hint = false, - destructive_hint = false, - open_world_hint = false - ) - )] - async fn register_asset( - &self, - params: Parameters, - ) -> Result, String> { - let p = params.0; - let mut notes = Vec::new(); - let in_service_date = parse_date( - aliased( - "in_service_date", - p.in_service_date.as_deref(), - "purchase_date", - p.purchase_date.as_deref(), - &mut notes, - same_day, - )? - .ok_or("in_service_date is required: the day it was placed in service")?, - )?; - let mut store = self.lock()?; - let base = store.ledger().base_currency(); - let cost_account = resolve_account(&store, &p.cost_account)?; - let accum_depr_account = resolve_account(&store, &p.accum_depr_account)?; - let expense_account = resolve_account(&store, &p.expense_account)?; - let tag = match p.tag { - Some(t) => t, - None => store.ledger().next_asset_tag(self.settings().tag_prefix()), - }; - // Checked before registering, so a bad tag or serial registers - // nothing. - store - .ledger() - .check_asset_tag_for(None, &tag) - .map_err(|e| e.to_string())?; - if let Some(serial) = &p.serial { - ledger_core::assets::check_serial(serial) - .map_err(|why| format!("bad serial number: {why}"))?; - } - if let Some(entry) = p.acquisition_entry { - let e = store - .ledger() - .get_entry(entry) - .ok_or_else(|| format!("unknown entry {entry}"))?; - if !e - .lines() - .iter() - .any(|l| l.account == cost_account && l.side == Side::Debit) - || e.status() != ledger_core::EntryStatus::Posted - { - return Err(format!( - "entry {entry} can't be the acquisition: it must be posted and debit the cost account" - )); - } - let reversed = (1..store.ledger().next_entry_id()) - .filter_map(|id| store.ledger().get_entry(id)) - .any(|x| x.reversal_of() == Some(entry)); - if e.reversal_of().is_some() || reversed { - return Err(format!( - "entry {entry} can't be the acquisition: it is a reversal or has been reversed (link the entry that stands)" - )); - } - if e.date() > in_service_date { - return Err(format!( - "entry {entry} can't be the acquisition: it is dated {}, after the in-service date {in_service_date} (an asset is acquired before it serves: correct the in-service date, or the entry)", - e.date() - )); - } - } - // Checked before registering, so a bad tax basis registers nothing - // (found by the spec-blind MCP suite, 2026-10-15). - let rule = aliased( - "basis_rule", - p.basis_rule.as_deref(), - "tax_basis_rule", - p.tax_basis_rule.as_deref(), - &mut notes, - same_rule, - )?; - let adjusted = aliased( - "contributor_adjusted_basis", - p.contributor_adjusted_basis.as_deref(), - "contributor_basis", - p.contributor_basis.as_deref(), - &mut notes, - same_amount, - )?; - let basis = match rule { - Some(rule) => Some(parse_asset_basis( - rule, - adjusted, - p.fmv_at_contribution.as_deref(), - p.sec704c_method.as_deref(), - p.basis_source.as_ref(), - &mut notes, - )?), - None if adjusted.is_some() - || p.fmv_at_contribution.is_some() - || p.sec704c_method.is_some() - || p.basis_source.is_some() => - { - return Err("the basis facts need basis_rule".into()); - } - None => None, - }; - let new = NewAsset { - description: p.description, - in_service_date, - cost: Money::new(parse_amount(&p.cost)?, base), - salvage_value: Money::new( - parse_amount(p.salvage_value.as_deref().unwrap_or("0"))?, - base, - ), - useful_life_months: p.useful_life_months, - cost_account, - accum_depr_account, - expense_account, - }; - // One registration is several commands: rehearse them all on a - // copy first, and ask for the journal feature the basis needs - // (B-27), so a refusal anywhere (the tag, the serial, the link, the - // basis, books waiting for their format upgrade) journals nothing. - { - let mut copy = store.ledger().clone(); - let id = copy - .register_asset(new.clone()) - .map_err(|e| e.to_string())?; - copy.set_asset_tag(id, &tag).map_err(|e| e.to_string())?; - if let Some(serial) = &p.serial { - copy.set_asset_serial(id, Some(serial)) - .map_err(|e| e.to_string())?; - } - if let Some(entry) = p.acquisition_entry { - copy.set_asset_acquisition(id, entry) - .map_err(|e| e.to_string())?; - } - if let Some(basis) = &basis { - copy.set_asset_basis(id, basis.clone()) - .map_err(|e| e.to_string())?; - store - .ensure_feature(ledger_store::format::ASSET_BASIS) - .map_err(|e| e.to_string())?; - } - } - let asset_id = store.register_asset(new).map_err(|e| e.to_string())?; - store - .set_asset_tag(asset_id, &tag) - .map_err(|e| e.to_string())?; - if let Some(serial) = &p.serial { - store - .set_asset_serial(asset_id, Some(serial)) - .map_err(|e| e.to_string())?; - } - if let Some(entry) = p.acquisition_entry { - store - .set_asset_acquisition(asset_id, entry) - .map_err(|e| e.to_string())?; - } - if let Some(basis) = basis { - store - .set_asset_basis(asset_id, basis) - .map_err(|e| e.to_string())?; - } - let ledger = store.ledger(); - let a = ledger.get_asset(asset_id).expect("registered"); - Ok(Json(RegisterAssetOut { - asset_id, - tag, - in_service_date: a.in_service_date.to_string(), - acquired_date: ledger - .asset_acquired_date(asset_id) - .expect("registered") - .to_string(), - basis: basis_out(a)?, - notes, - })) - } - - #[tool( - name = "books.asset_schedule", - title = "Depreciation schedule", - description = "Month-by-month straight-line depreciation schedule for an asset: period, amount, accumulated, book value. Depreciation accrues by the day: a partial first month, the life's final month, and a month the schedule stops in (through a date, or a disposed asset's disposal day, which is held) are prorated by days; the lifetime total is exactly cost minus salvage.", - annotations(read_only_hint = true, open_world_hint = false) - )] - async fn asset_schedule( - &self, - params: Parameters, - ) -> Result, String> { - let p = params.0; - let store = self.lock()?; - let asset_id = resolve_asset(&store, p.asset_id, p.tag.as_deref())?; - let asset = store - .ledger() - .get_asset(asset_id) - .ok_or_else(|| format!("unknown asset {asset_id}"))? - .clone(); - // The schedule clamps to the depreciation end internally, so an - // unbounded default just means "the whole life". - let through = match p.through { - Some(t) => parse_date(&t)?, - None => NaiveDate::MAX, - }; - let schedule = depreciation_schedule(&asset, through) - .map_err(|e| format!("asset {asset_id}'s schedule: {e}"))? - .into_iter() - .map(|l| ScheduleLineOut { - period: l.period, - amount: money(l.amount), - accumulated: money(l.accumulated), - book_value: money(l.book_value), - }) - .collect(); - Ok(Json(AssetScheduleOut { - asset_id: asset.id, - description: asset.description.clone(), - cost: money(asset.cost.amount), - salvage_value: money(asset.salvage_value.amount), - useful_life_months: asset.useful_life_months, - status: match asset.status { - AssetStatus::Active => "active".to_string(), - AssetStatus::Disposed { .. } => "disposed".to_string(), - }, - schedule, - })) - } - - #[tool( - name = "books.list_assets", - title = "List fixed assets", - description = "List every registered fixed asset as of a date (as_of, default today): in-service and acquired dates, cost, and per asset accumulated_posted (what the books carry: runs posted, less reversals), accumulated_scheduled_through (what the schedule says is due by as_of), unposted_depreciation (the difference: run books.run_depreciation for it) and book_value (cost less posted: what the books carry); status (active or disposed) and held (acquired and not yet disposed) are as of as_of too, so a later disposal never shows. With its basis facts and tax figures. An asset whose figures can't be computed (one an earlier version took) lists with null figures and schedule_error.", - annotations(read_only_hint = true, open_world_hint = false) - )] - async fn list_assets( - &self, - params: Parameters, - ) -> Result, String> { - let p = params.0; - let as_of = match &p.as_of { - Some(d) => parse_date(d)?, - None => today(), - }; - let store = self.lock()?; - let ledger = store.ledger(); - // The register's rows (one derivation, reports::register_row): an - // asset acquired by as_of has one; a later one has no figures yet. - let assets = ledger - .registered_assets() - .map(|a| { - let acquired = ledger.asset_acquired_date(a.id).expect("registered"); - let disposed = - matches!(a.status, AssetStatus::Disposed { date, .. } if date <= as_of); - // One asset whose figures can't be computed (one an earlier - // version took) lists with why, never failing the whole - // list. - let row = ledger - .accumulated_posted(a.id, as_of) - .map(|posted| std::collections::BTreeMap::from([(a.id, posted)])) - .and_then(|posted| reports::register_row(ledger, a, as_of, &posted)); - let (row, schedule_error) = match row { - Ok(row) => (row, None), - Err(e) => (None, Some(e.to_string())), - }; - let row = row.as_ref(); - let figure = - |f: fn(&ledger_core::AssetRegisterRow) -> Decimal| row.map(|r| money(f(r))); - Ok(AssetSummary { - asset_id: a.id, - tag: a.tag.clone(), - serial: a.serial.clone(), - acquisition_entry: a.acquired_by, - basis: basis_out(a)?, - description: a.description.clone(), - in_service_date: a.in_service_date.to_string(), - acquired_date: acquired.to_string(), - cost: money(a.cost.amount), - useful_life_months: a.useful_life_months, - // As of as_of, like every figure here: a disposal - // after it hasn't happened yet (rules.md B-33). - status: if disposed { "disposed" } else { "active" }.to_string(), - held: acquired <= as_of && !disposed, - accumulated_posted: figure(|r| r.accumulated_posted), - accumulated_scheduled_through: figure(|r| r.accumulated_scheduled), - unposted_depreciation: figure(|r| { - r.accumulated_scheduled - r.accumulated_posted - }), - book_value: figure(|r| r.book_value), - schedule_error, - }) - }) - .collect::>()?; - Ok(Json(ListAssetsOut { - as_of: as_of.to_string(), - assets, - })) - } - - #[tool( - name = "books.run_depreciation", - title = "Post depreciation", - description = "Post one month of depreciation (Dr Depreciation Expense / Cr Accumulated Depreciation) for one asset, or for every active asset when asset_id is omitted: what the schedule says is due through the month's end, so a month skipped (or reversed) is caught up in the month run (caught_up says how much; a material miss is a restatement for the CPA, ASC 250). Idempotent: re-running a posted period returns the original entry. Nothing due (rounds to nothing, already caught up, life over) is listed under skipped. Exempt from the receipt policy: the asset and its schedule are the evidence.", - annotations( - read_only_hint = false, - destructive_hint = true, - idempotent_hint = true, - open_world_hint = false - ) - )] - async fn run_depreciation( - &self, - params: Parameters, - ) -> Result, String> { - let p = params.0; - let (py, pm) = parse_period_mcp(&p.period)?; - let period_end = last_day_of_month(py, pm)?; - let mut store = self.lock()?; - let targets: Vec<(u64, String)> = match p.asset_id { - Some(id) => { - let a = store - .ledger() - .get_asset(id) - .ok_or_else(|| format!("unknown asset {id}"))?; - vec![(id, a.description.clone())] - } - None => store - .ledger() - .registered_assets() - .filter(|a| a.status == AssetStatus::Active) - .map(|a| (a.id, a.description.clone())) - .collect(), - }; - let mut runs = Vec::with_capacity(targets.len()); - let mut skipped = Vec::new(); - for (id, description) in targets { - let skip = |reason: String| SkippedDepreciation { - asset_id: id, - description: description.clone(), - reason, - }; - // Run-all skips assets not yet in service, and any whose - // schedule can't be computed (an earlier version's), instead of - // failing the whole batch. - if p.asset_id.is_none() - && let Some(asset) = store.ledger().get_asset(id) - && asset.in_service_date > period_end - { - skipped.push(skip(format!( - "placed in service {}, after {}", - asset.in_service_date, p.period - ))); - continue; - } - let r = match store.run_depreciation(id, &p.period) { - Ok(r) => r, - Err(ledger_store::StoreError::Ledger( - e @ (ledger_core::LedgerError::TooLargeToCompute(_) - | ledger_core::LedgerError::DateOutOfRange(_)), - )) if p.asset_id.is_none() => { - skipped.push(skip(format!("its schedule can't be computed: {e}"))); - continue; - } - Err(e) => return Err(e.to_string()), - }; - let Some(entry_id) = r.entry_id else { - skipped - .push(skip(r.nothing_due.unwrap_or_else(|| { - format!("nothing is due for {}", p.period) - }))); - continue; - }; - runs.push(DepreciationRunOut { - asset_id: r.asset_id, - description, - period: r.period, - amount: money(r.amount), - caught_up: money((r.amount - r.scheduled).max(Decimal::ZERO)), - entry_id, - already_posted: r.already_posted, - }); - } - Ok(Json(RunDepreciationOut { runs, skipped })) - } - - #[tool( - name = "books.dispose_asset", - title = "Dispose of a fixed asset", - description = "Dispose of a fixed asset (sell or retire). Without confirmed=true returns a preview (book value, gain/loss, the depreciation through the disposal day, which is held, and the disposal entry's lines) and posts nothing. With confirmed=true posts it after the receipt policy: file the bill of sale with books.file_document and pass its ids as receipt_attachment_ids. Depreciation a run posted past the disposal day is credited back (excess_depreciation); a later month's run is refused (reverse it first). Name the buyer (a party id or name): [books] parties = \"require\" refuses a sale without one.", - annotations( - read_only_hint = false, - destructive_hint = true, - open_world_hint = false - ) - )] - async fn dispose_asset( - &self, - params: Parameters, - ) -> Result, String> { - let p = params.0; - let mut store = self.lock()?; - let date = parse_date(&p.date)?; - let base = store.ledger().base_currency(); - let proceeds = Money::new(parse_amount(&p.proceeds)?, base); - let proceeds_account = resolve_account(&store, &p.proceeds_account)?; - let gain_loss_account = resolve_account(&store, &p.gain_loss_account)?; - let asset_id = resolve_asset(&store, p.asset_id, p.tag.as_deref())?; - let buyer = p - .buyer - .as_deref() - .map(|r| resolve_party(&store, r)) - .transpose()?; - let asset = store - .ledger() - .get_asset(asset_id) - .ok_or_else(|| format!("unknown asset {asset_id}"))? - .clone(); - let preview = store - .dispose_preview(asset_id, date, proceeds) - .map_err(|e| e.to_string())?; - - // The would-be disposal entry's lines, for the preview and the - // receipt-policy check. - let raw: Vec<(AccountId, Side, Money)> = Store::disposal_lines( - &asset, - &preview, - proceeds.amount, - proceeds_account, - gain_loss_account, - ) - .into_iter() - .map(|(a, side, amount)| (a, side, Money::new(amount, base))) - .collect(); - // Disposals are money movement: the bill of sale is the receipt. - let chart = store.ledger().chart(); - let code_of = |id: AccountId| { - chart - .get(id) - .map(|a| a.code.clone()) - .ok_or_else(|| format!("unknown account {id}")) - }; - let adjusts = - !(preview.excess_depreciation.is_zero() && preview.catch_up_depreciation.is_zero()); - let codes: Vec = [ - asset.accum_depr_account, - proceeds_account, - asset.cost_account, - gain_loss_account, - ] - .into_iter() - .chain(adjusts.then_some(asset.expense_account)) - .map(code_of) - .collect::>()?; - let code_refs: Vec<&str> = codes.iter().map(|s| s.as_str()).collect(); - let debits = preview.accumulated - + proceeds.amount - + preview.catch_up_depreciation - + if preview.gain_loss < Decimal::ZERO { - -preview.gain_loss - } else { - Decimal::ZERO - }; - let receipt_required = self - .settings() - .policy - .receipt_required_for(&code_refs, debits); - - let out = DisposeAssetOut { - status: "preview".to_string(), - asset_id, - entry_id: 0, - accumulated: money(preview.accumulated), - book_value: money(preview.book_value), - proceeds: money(proceeds.amount), - gain_loss: money(preview.gain_loss), - final_depreciation: money(preview.final_depreciation), - excess_depreciation: money(preview.excess_depreciation), - catch_up_depreciation: money(preview.catch_up_depreciation), - lines: preview_lines_for(&store, &raw)?, - receipt_required, - }; - if !p.confirmed.unwrap_or(false) { - return Ok(Json(out)); - } - let attachment_ids = p.receipt_attachment_ids.unwrap_or_default(); - if receipt_required && attachment_ids.is_empty() { - return Err(ReceiptRequiredError { - code: "receipt_required".to_string(), - message: format!( - "disposing {} moves {} in debits ({}); file the bill of sale with books.file_document, then retry with receipt_attachment_ids", - asset.description, - money(debits), - self.settings().policy.describe() - ), - entry_id: None, - asset_id: Some(asset_id), - debits: debits.to_string(), - policy: self.settings().policy.describe(), - remedy_tool: "books.file_document".to_string(), - remedy_hint: - "file the bill of sale with books.file_document, then retry books.dispose_asset with confirmed=true and receipt_attachment_ids=[]" - .to_string(), - split_group: Vec::new(), - } - .to_tool_error()); - } - for aid in &attachment_ids { - store.check_linkable(*aid).map_err(|e| e.to_string())?; - } - if buyer.is_none() - && !proceeds.amount.is_zero() - && self.settings().parties == ledger_config::PartyPolicy::Require - { - return Err(party_required( - "pass buyer: the party the asset was sold to", - )); - } - let entry_id = store - .dispose_asset( - asset_id, - date, - proceeds, - proceeds_account, - gain_loss_account, - ) - .map_err(|e| e.to_string())?; - for aid in &attachment_ids { - store - .link_receipt(*aid, entry_id) - .map_err(|e| e.to_string())?; - } - match buyer { - Some(b) => store.set_entry_party(entry_id, Some(b)), - None if proceeds.amount.is_zero() => { - store.set_entry_no_party(entry_id, "asset retired with no proceeds") - } - None => Ok(()), - } - .map_err(|e| e.to_string())?; - let mut posted = out; - posted.status = "posted".to_string(); - posted.entry_id = entry_id; - Ok(Json(posted)) - } - - // ------------------------------------------------------------------ - // Periods - // ------------------------------------------------------------------ - - #[tool( - name = "books.create_period", - title = "Create a period", - description = "Create an accounting period (month, quarter, year). Dates are YYYY-MM-DD, inclusive.", - annotations( - read_only_hint = false, - destructive_hint = false, - open_world_hint = false - ) - )] - async fn create_period( - &self, - params: Parameters, - ) -> Result, String> { - let p = params.0; - let mut store = self.lock()?; - let id = store - .create_period(&p.name, parse_date(&p.start)?, parse_date(&p.end)?) - .map_err(|e| e.to_string())?; - Ok(Json(CreatePeriodOut { period_id: id })) - } - - #[tool( - name = "books.close_period", - title = "Close a period", - description = "Close an accounting period. The closing entry, dated at period end, zeroes revenue and expense balances into Retained Earnings or, for a multi-member LLC (members in the settings), into each member's capital account by share, cumulatively: each close gives a member their share of everything closed to the members so far, less what earlier closes gave them (odd cents by largest remainder), so no member is ever a cent off their exact share; a close may carry a one-time correction of earlier rounding. Periods close in order. Without confirmed=true, returns the exact closing entry and members' split, closing nothing; with confirmed=true, posts it and closes the period to new entries.", - annotations( - read_only_hint = false, - destructive_hint = true, - open_world_hint = false - ) - )] - async fn close_period( - &self, - params: Parameters, - ) -> Result, String> { - let p = params.0; - let mut store = self.lock()?; - // Verify the period exists. - if store.ledger().get_period(p.period_id).is_none() { - return Err(format!("unknown period {}", p.period_id)); - } - let to = self.close_target(store.ledger())?; - let describe = |ledger: &Ledger, eid: Option| -> Vec { - let Some(entry) = eid.and_then(|e| ledger.get_entry(e)) else { - return Vec::new(); - }; - entry - .lines() - .iter() - .map(|line| { - let code = ledger - .chart() - .get(line.account) - .map(|a| a.code.clone()) - .unwrap_or_default(); - format!("{:?} {} {}", line.side, code, money(line.amount.amount)) - }) - .collect() - }; - // The members' split, from the closed ledger (rules.md B-13). - let split = |ledger: &Ledger| -> Option { - let (since_close, parts) = ledger.member_close_split(p.period_id)?; - let names = self.member_capitals(ledger).unwrap_or_default(); - let members = parts - .iter() - .map(|m| MemberCloseOut { - name: names - .iter() - .find(|c| c.capital_account == m.capital) - .map(|c| c.name.clone()) - .unwrap_or_default(), - capital_account: ledger - .chart() - .get(m.capital) - .map(|a| a.code.clone()) - .unwrap_or_default(), - share: ledger_package::share_text(m.share), - this_close: money(m.part), - running: money(m.running), - exact: money(m.exact.normalize()), - }) - .collect(); - Some(MembersCloseOut { - since_close, - members, - }) - }; - if !p.confirmed { - // Preview: run the real close on a copy of the ledger, so the - // preview is exactly what confirming would post. - let mut scratch = store.ledger().clone(); - let closing = scratch - .close_period_to(p.period_id, &to) - .map_err(|e| e.to_string())?; - return Ok(Json(ClosePeriodOut { - status: "preview".to_string(), - period_id: p.period_id, - closing_entry: None, - lines: describe(&scratch, closing), - members: split(&scratch), - })); - } - let closing = store - .close_period_to(p.period_id, &to) - .map_err(|e| e.to_string())?; - let lines = describe(store.ledger(), closing); - Ok(Json(ClosePeriodOut { - status: "closed".to_string(), - period_id: p.period_id, - closing_entry: closing, - lines, - members: split(store.ledger()), - })) - } - - #[tool( - name = "books.list_periods", - title = "List periods", - description = "List all accounting periods with their status, and for a period closed to the members, the rounding rule it was closed under.", - annotations(read_only_hint = true, open_world_hint = false) - )] - async fn list_periods(&self) -> Result, String> { - let store = self.lock()?; - let periods = store - .ledger() - .list_periods() - .into_iter() - .map(|p| PeriodOut { - id: p.id, - name: p.name.clone(), - start: p.start.to_string(), - end: p.end.to_string(), - status: match p.status { - PeriodStatus::Open => "open".to_string(), - PeriodStatus::Closed => "closed".to_string(), - }, - closing_entry: p.closing_entry, - close_rule: p.members_closed.as_ref().map(|m| match m.rule { - ledger_core::MemberRule::Cumulative { since_seq } => { - format!("members_cumulative_since:{since_seq}") - } - ledger_core::MemberRule::Run => "members_run".to_string(), - ledger_core::MemberRule::PerClose => "members_per_close".to_string(), - ledger_core::MemberRule::LastAbsorbs => "members_last_absorbs".to_string(), - }), - }) - .collect(); - Ok(Json(ListPeriodsOut { periods })) - } - - // ------------------------------------------------------------------ - // Bank reconciliation - // ------------------------------------------------------------------ - - #[tool( - name = "books.mark_cleared", - title = "Mark a line cleared", - description = "Mark a posted entry's line as cleared (or uncleared) for bank reconciliation. Only posted lines can be marked.", - annotations( - read_only_hint = false, - destructive_hint = false, - open_world_hint = false - ) - )] - async fn mark_cleared( - &self, - params: Parameters, - ) -> Result, String> { - let p = params.0; - let mut store = self.lock()?; - store - .mark_cleared(p.entry_id, p.line_index, p.cleared) - .map_err(|e| e.to_string())?; - Ok(Json(MarkClearedOut { ok: true })) - } - - /// ```text - /// GOAL: a monthly reconciliation that leaves proof: which statement, - /// which balance, which lines it cleared. - /// SOURCE: CONVENTION (bank reconciliation); docs/parties-and- - /// attachments.md "Reconciliations"; Store::complete_reconciliation. - /// INVARIANTS: - /// 1. Without confirmed=true nothing is recorded: the preview reports - /// the difference, the cleared and uncleared lines, and the - /// problems a confirmation would hit. - /// 2. A reconciliation is recorded only against a recorded statement - /// (statement_attachment), only at a difference of exactly zero, - /// and only after the account's last reconciliation not voided. - /// 3. A refusal journals nothing. - /// SCENARIOS: - /// 1. IF statement_attachment is given: the account, date, and - /// balance are the statement's; any given that disagree: SHOULD - /// refuse. IF its attachment is withdrawn (the statement retired) - /// or its statement voided: SHOULD refuse, preview or not. - /// 2. IF it isn't: account, statement_date, and statement_balance are - /// required, and confirmed=true SHOULD refuse. - /// 3. IF confirmed=true and problems is not empty: SHOULD refuse, - /// naming them. - /// ``` - #[tool( - name = "books.reconcile", - title = "Reconcile an account", - description = "Reconcile a bank, cash, or card account against a statement. Give statement_attachment (a statement recorded with books.record_statement: its account, period end, and closing balance), or account, statement_date, and statement_balance for a quick check against any balance. Returns the book balance, cleared balance, statement balance, the difference (cleared minus statement; zero means reconciled), the cleared and uncleared lines through the statement date, and the problems that would stop it being recorded. Without confirmed=true it is a preview and records nothing. With confirmed=true and a recorded statement it records the reconciliation (journaled as reconciliation.completed, linked to the statement), refused unless the difference is exactly zero and it comes after the account's last reconciliation. Mark lines cleared with books.mark_cleared first.", - annotations( - read_only_hint = false, - destructive_hint = true, - open_world_hint = false - ) - )] - async fn reconcile( - &self, - params: Parameters, - ) -> Result, String> { - let p = params.0; - let confirmed = p.confirmed.unwrap_or(false); - let mut store = self.lock()?; - let given_account = p - .account - .as_deref() - .map(|a| resolve_account(&store, a)) - .transpose()?; - let given_date = p.statement_date.as_deref().map(parse_date).transpose()?; - let given_balance = p - .statement_balance - .as_deref() - .map(parse_amount) - .transpose()?; - let (account, date, balance) = match p.statement_attachment { - Some(att) => { - if find_attachment(&store, att)?.withdrawn_at.is_some() { - return Err(format!( - "attachment {att} is withdrawn, so its statement is retired: file the statement again and record it (books.record_statement)" - )); - } - let st = store - .statement(att) - .map_err(|e| e.to_string())? - .ok_or_else(|| { - format!( - "attachment {att} records no statement: record it with books.record_statement first" - ) - })?; - if let Some(a) = given_account.filter(|a| *a != st.account_id) { - return Err(format!( - "statement {att} is for account {}, not {}", - code_name(&store, st.account_id).0, - code_name(&store, a).0 - )); - } - if let Some(d) = given_date.filter(|d| *d != st.period_end) { - return Err(format!("statement {att} ends {}, not {d}", st.period_end)); - } - if let Some(b) = given_balance.filter(|b| *b != st.closing_balance) { - return Err(format!( - "statement {att} closes at {}, not {}", - money(st.closing_balance), - money(b) - )); - } - (st.account_id, st.period_end, st.closing_balance) - } - None => { - if confirmed { - return Err("a reconciliation is recorded only against a recorded statement: file it (books.file_document category statement), record what it says (books.record_statement), then pass statement_attachment".into()); - } - match (given_account, given_date, given_balance) { - (Some(a), Some(d), Some(b)) => (a, d, b), - _ => { - return Err("give statement_attachment, or account, statement_date, and statement_balance".into()); - } - } - } - }; - let report = store - .ledger() - .reconciliation_report(account, date, balance) - .map_err(|e| e.to_string())?; - let ledger = store.ledger(); - let mut cleared_lines = Vec::new(); - for e in ledger.posted_entries() { - if e.date() > date { - continue; - } - for (i, l) in e.lines().iter().enumerate() { - if l.account == account && ledger.is_cleared(e.id(), i) { - cleared_lines.push(reconcile_line( - e.id(), - i, - e.date(), - e.description(), - l.side, - l.amount.amount, - )); - } - } - } - cleared_lines.sort_by(|a, b| { - a.date - .cmp(&b.date) - .then(a.entry_id.cmp(&b.entry_id)) - .then(a.line_index.cmp(&b.line_index)) - }); - let mut problems = Vec::new(); - if p.statement_attachment.is_none() { - problems.push("no recorded statement: file it, books.record_statement, then pass statement_attachment".to_string()); - } - if !report.difference.is_zero() { - problems.push(format!( - "the cleared balance differs from the statement by {}: mark the lines the statement shows with books.mark_cleared (or find what's missing)", - money(report.difference) - )); - } - if let Some(last) = reconciled_through(&store, account)? - && last >= date - { - problems.push(format!( - "already reconciled through {last}: void that reconciliation (books.void_reconciliation) to redo it" - )); - } - let mut out = ReconcileOut { - status: "preview".to_string(), - account_code: code_name(&store, account).0, - statement_date: date.to_string(), - statement_attachment: p.statement_attachment, - book_balance: money(report.book_balance), - cleared_balance: money(report.cleared_balance), - statement_balance: money(report.statement_balance), - difference: money(report.difference), - uncleared_lines: report - .uncleared_lines - .into_iter() - .map(|l| { - reconcile_line( - l.entry_id, - l.line_index, - l.date, - &l.description, - l.side, - l.amount, - ) - }) - .collect(), - cleared_lines, - problems, - reconciliation: None, - }; - if !confirmed { - return Ok(Json(out)); - } - if !out.problems.is_empty() { - return Err(format!("not recorded: {}", out.problems.join("; "))); - } - let att = p.statement_attachment.expect("required when confirmed"); - let r = store - .complete_reconciliation(att) - .map_err(|e| e.to_string())?; - out.status = "reconciled".to_string(); - out.reconciliation = Some(reconciliation_out(&store, &r)); - Ok(Json(out)) - } - - /// ```text - /// GOAL: the bank and card statements are in the books, so a - /// reconciliation names the one it agrees with. - /// SOURCE: Store::record_statement. - /// INVARIANTS: - /// 1. Journaled as `statement.recorded`, then `attachment.linked` to - /// the account (unless linked already). A refusal journals nothing. - /// SCENARIOS: - /// 1. IF the attachment is unknown, withdrawn, or already records a - /// statement; the account is unknown or not an asset or liability; - /// the period runs backwards; or a balance isn't a decimal string - /// in cents: SHOULD refuse. - /// ``` - #[tool( - name = "books.record_statement", - title = "Record a statement", - description = "Record what a filed bank or card statement says (file it first: books.file_document category statement): the account it is for (code or id; an asset or liability), the period it covers (period_start, period_end: the statement date), and its opening and closing balances as printed, in the account's normal direction (what the bank holds; what the card is owed), decimal strings. Links the statement to the account. Then reconcile to it: books.reconcile statement_attachment. Journaled as statement.recorded. Returns the statement.", - annotations( - read_only_hint = false, - destructive_hint = false, - open_world_hint = false - ) - )] - async fn record_statement( - &self, - params: Parameters, - ) -> Result, String> { - let p = params.0; - let mut store = self.lock()?; - find_attachment(&store, p.attachment_id)?; - let st = ledger_store::Statement { - attachment_id: p.attachment_id, - account_id: resolve_account(&store, &p.account)?, - period_start: parse_date(&p.period_start)?, - period_end: parse_date(&p.period_end)?, - opening_balance: parse_amount(&p.opening_balance)?, - closing_balance: parse_amount(&p.closing_balance)?, - }; - store - .record_statement(st.clone()) - .map_err(|e| e.to_string())?; - Ok(Json(statement_out(&store, &st)?)) - } - - #[tool( - name = "books.statements", - title = "List statements", - description = "Recorded bank and card statements (books.record_statement), optionally one account's (code or id), by statement date: the attachment, account, period, opening and closing balances, and the reconciliation made against it, if any.", - annotations(read_only_hint = true, open_world_hint = false) - )] - async fn statements( - &self, - params: Parameters, - ) -> Result, String> { - let store = self.lock()?; - let account = params - .0 - .account - .as_deref() - .map(|a| resolve_account(&store, a)) - .transpose()?; - let statements = store - .statements(account) - .map_err(|e| e.to_string())? - .iter() - .map(|st| statement_out(&store, st)) - .collect::>()?; - Ok(Json(StatementsOut { statements })) - } - - #[tool( - name = "books.reconciliations", - title = "List reconciliations", - description = "Recorded reconciliations (books.reconcile confirmed), optionally one account's (code or id), by statement date: the account, the statement date and balance, the statement's attachment, the lines it found cleared, and whether it was voided (and why).", - annotations(read_only_hint = true, open_world_hint = false) - )] - async fn reconciliations( - &self, - params: Parameters, - ) -> Result, String> { - let store = self.lock()?; - let account = params - .0 - .account - .as_deref() - .map(|a| resolve_account(&store, a)) - .transpose()?; - let reconciliations = store - .reconciliations(account) - .map_err(|e| e.to_string())? - .iter() - .map(|r| reconciliation_out(&store, r)) - .collect(); - Ok(Json(ReconciliationsOut { reconciliations })) - } - - /// ```text - /// GOAL: correct a mis-recorded statement on the record. - /// SOURCE: Store::void_statement. - /// SCENARIOS: - /// 1. IF the attachment records no statement, a reconciliation not - /// voided rests on it, or the reason is blank: SHOULD refuse, - /// journaling nothing. - /// 2. Otherwise: journaled as `statement.voided`; books.statements no - /// longer lists it, and the same attachment can record the - /// statement again (books.record_statement). - /// ``` - #[tool( - name = "books.void_statement", - title = "Void a statement", - description = "Void a recorded statement that was recorded wrong (a mistyped balance or period), with the reason recorded in the journal, so the same filed statement can be recorded again with books.record_statement. Refused while a reconciliation not voided rests on it (books.void_reconciliation first). Nothing is erased: the journal keeps what it said. Journaled as statement.voided. Returns the statement as it was recorded.", - annotations( - read_only_hint = false, - destructive_hint = true, - open_world_hint = false - ) - )] - async fn void_statement( - &self, - params: Parameters, - ) -> Result, String> { - let p = params.0; - let mut store = self.lock()?; - let st = store - .statement(p.attachment_id) - .map_err(|e| e.to_string())? - .ok_or_else(|| format!("attachment {} records no statement", p.attachment_id))?; - let out = statement_out(&store, &st)?; - store - .void_statement(p.attachment_id, &p.reason) - .map_err(|e| e.to_string())?; - Ok(Json(out)) - } - - /// ```text - /// GOAL: undo a mistaken reconciliation on the record, to redo it. - /// SOURCE: Store::void_reconciliation. - /// SCENARIOS: - /// 1. IF it is unknown or already voided, or the reason is blank: - /// SHOULD refuse, journaling nothing. - /// 2. Otherwise: journaled as `reconciliation.voided`; it stays - /// listed, marked voided, and the account can be reconciled - /// through that date again. - /// ``` - #[tool( - name = "books.void_reconciliation", - title = "Void a reconciliation", - description = "Void a recorded reconciliation that was a mistake, with the reason recorded in the journal, so the account can be reconciled through that date again. Nothing is erased: it stays listed in books.reconciliations, marked voided. Journaled as reconciliation.voided. Returns it.", - annotations( - read_only_hint = false, - destructive_hint = true, - open_world_hint = false - ) - )] - async fn void_reconciliation( - &self, - params: Parameters, - ) -> Result, String> { - let p = params.0; - let mut store = self.lock()?; - store - .void_reconciliation(p.reconciliation_id, &p.reason) - .map_err(|e| e.to_string())?; - let r = store - .reconciliations(None) - .map_err(|e| e.to_string())? - .into_iter() - .find(|r| r.id == p.reconciliation_id) - .ok_or_else(|| format!("unknown reconciliation {}", p.reconciliation_id))?; - Ok(Json(reconciliation_out(&store, &r))) - } - - // ------------------------------------------------------------------ - // A/R and A/P - // ------------------------------------------------------------------ - - #[tool( - name = "books.add_customer", - title = "Add a customer", - description = "Add a customer record for accounts receivable (invoices). It links to the party of the same name that has no customer record yet (ignoring case), else to a new party; the output names both ids.", - annotations( - read_only_hint = false, - destructive_hint = false, - open_world_hint = false - ) - )] - async fn add_customer( - &self, - params: Parameters, - ) -> Result, String> { - let p = params.0; - let mut store = self.lock()?; - let id = store.add_customer(&p.name).map_err(|e| e.to_string())?; - let party_id = store - .ledger() - .party_of_customer(id) - .ok_or_else(|| format!("customer {id} has no party"))?; - Ok(Json(AddRecordOut { id, party_id })) - } - - #[tool( - name = "books.add_vendor", - title = "Add a vendor", - description = "Add a vendor record for accounts payable (bills) and 1099s. It links to the party of the same name that has no vendor record yet (ignoring case), else to a new party; the output names both ids.", - annotations( - read_only_hint = false, - destructive_hint = false, - open_world_hint = false - ) - )] - async fn add_vendor( - &self, - params: Parameters, - ) -> Result, String> { - let p = params.0; - let mut store = self.lock()?; - let id = store.add_vendor(&p.name).map_err(|e| e.to_string())?; - let party_id = store - .ledger() - .party_of_vendor(id) - .ok_or_else(|| format!("vendor {id} has no party"))?; - Ok(Json(AddRecordOut { id, party_id })) - } - - // ------------------------------------------------------------------ - // Parties (ledger_core::parties) - // ------------------------------------------------------------------ - - #[tool( - name = "books.add_party", - title = "Add a party", - description = "Add a party to the directory: anyone the company deals with (a contractor, landlord, payout platform, tax agency, government agency, bank or card issuer, member, employee). Name it as on its documents (unique among parties not archived); give its roles: contractor, employee, member, payout_platform, tax_agency, government_agency, financial_institution, landlord. Vendor and customer aren't given by hand: books.add_vendor and books.add_customer add those records and link them to the party of the same name. Never put a full TIN or SSN in any party field: the engine refuses nine-digit numbers. Journaled as party.added; returns the party.", - annotations( - read_only_hint = false, - destructive_hint = false, - open_world_hint = false - ) - )] - async fn add_party( - &self, - params: Parameters, - ) -> Result, String> { - let p = params.0; - let mut roles = parse_roles(&p.roles)?; - roles.sort(); - roles.dedup(); - let mut store = self.lock()?; - let id = store - .add_party(&p.name, &roles) - .map_err(|e| e.to_string())?; - let party = store.ledger().get_party(id).expect("just added"); - Ok(Json(party_out(store.ledger(), party))) - } - - #[tool( - name = "books.update_party", - title = "Update a party", - description = "Change a party's facts: name, display_name, website, address, notes, archived, creditor_of. Give only the fields to change; \"\" clears display_name, website, address or notes. creditor_of replaces the list of liabilities the company owes the party itself (its card, its loan, what the members advanced): a credit to one never counts as paying it. Every field is checked before any is written, so a bad one (a blank or taken name, a full TIN, over 500 characters, not a liability) changes nothing; each is journaled as party.updated or party.creditor_set. Archive a party you no longer deal with (its history stays; it then goes by id). Returns the party.", - annotations( - read_only_hint = false, - destructive_hint = false, - open_world_hint = false - ) - )] - async fn update_party( - &self, - params: Parameters, - ) -> Result, String> { - use ledger_core::PartyField as F; - let p = params.0; - let mut store = self.lock()?; - let id = resolve_party(&store, &p.party)?; - let mut fields = Vec::new(); - if let Some(n) = p.name { - fields.push(F::Name(n)); - } - if let Some(v) = p.display_name { - fields.push(F::DisplayName(optional_text(&v))); - } - if let Some(v) = p.website { - fields.push(F::Website(optional_text(&v))); - } - if let Some(v) = p.address { - fields.push(F::Address(optional_text(&v))); - } - if let Some(v) = p.notes { - fields.push(F::Notes(optional_text(&v))); - } - if let Some(a) = p.archived { - fields.push(F::Archived(a)); - } - // creditor_of replaces the list: each account added or dropped is - // one change. - let mut creditor: Vec<(AccountId, bool)> = Vec::new(); - if let Some(codes) = &p.creditor_of { - let want: std::collections::BTreeSet = codes - .iter() - .map(|c| resolve_account(&store, c.trim())) - .collect::>()?; - let has = &store.ledger().get_party(id).expect("resolved").creditor_of; - creditor.extend(want.difference(has).map(|a| (*a, true))); - creditor.extend(has.difference(&want).map(|a| (*a, false))); - } - if fields.is_empty() && p.creditor_of.is_none() { - return Err( - "give at least one field to change: name, display_name, website, address, notes, archived, creditor_of" - .into(), - ); - } - // Every change, in order, before any is written (INVARIANT 2). - let mut rehearsal = store.ledger().clone(); - for f in &fields { - rehearsal - .update_party(id, f.clone()) - .map_err(|e| e.to_string())?; - } - for (a, on) in &creditor { - rehearsal - .set_party_creditor(id, *a, *on) - .map_err(|e| e.to_string())?; - } - // The creditor lines' journal feature, asked for before this - // command's first line, never between its lines (rules.md B-27: - // books waiting for their format upgrade must not be left with the - // name changed and the creditors not). - if !creditor.is_empty() { - store - .ensure_feature(ledger_store::format::PARTY_CREDITORS) - .map_err(|e| e.to_string())?; - } - for f in fields { - store.update_party(id, f).map_err(|e| e.to_string())?; - } - for (a, on) in creditor { - store - .set_party_creditor(id, a, on) - .map_err(|e| e.to_string())?; - } - let party = store.ledger().get_party(id).expect("resolved"); - Ok(Json(party_out(store.ledger(), party))) - } - - #[tool( - name = "books.set_party_roles", - title = "Set a party's roles", - description = "Give a party roles (add) or take them away (remove): contractor, employee, member, payout_platform, tax_agency, government_agency, financial_institution, landlord. Vendor and customer follow their records and can't be set by hand. Adds apply before removes; a role it already has (or lacks) is left alone. Removing a party's last 1099 role (contractor, landlord) needs its W-9 facts cleared first (books.set_w9 clear), and removing employee its W-4 facts. All or nothing: a refusal changes nothing. Each change is journaled as party.role_set. Returns the party.", - annotations( - read_only_hint = false, - destructive_hint = false, - idempotent_hint = true, - open_world_hint = false - ) - )] - async fn set_party_roles( - &self, - params: Parameters, - ) -> Result, String> { - let p = params.0; - let add = parse_roles(&p.add)?; - let remove = parse_roles(&p.remove)?; - if add.is_empty() && remove.is_empty() { - return Err("give at least one role to add or remove".into()); - } - if let Some(r) = add.iter().find(|r| remove.contains(r)) { - return Err(format!("role {} is both added and removed", r.key())); - } - if add - .iter() - .chain(&remove) - .any(|r| matches!(r, ledger_core::Role::Vendor | ledger_core::Role::Customer)) - { - return Err( - "vendor and customer roles follow their records: books.add_vendor or books.add_customer" - .into(), - ); - } - let mut store = self.lock()?; - let id = resolve_party(&store, &p.party)?; - let has = store - .ledger() - .get_party(id) - .expect("resolved") - .roles - .clone(); - let mut changes: Vec<(ledger_core::Role, bool)> = Vec::new(); - for r in add { - if !has.contains(&r) && !changes.contains(&(r, true)) { - changes.push((r, true)); - } - } - for r in remove { - if has.contains(&r) && !changes.contains(&(r, false)) { - changes.push((r, false)); - } - } - // The whole change, in order, before any is written (INVARIANT 2). - let mut rehearsal = store.ledger().clone(); - for (r, on) in &changes { - rehearsal - .set_party_role(id, *r, *on) - .map_err(|e| e.to_string())?; - } - for (r, on) in changes { - store.set_party_role(id, r, on).map_err(|e| e.to_string())?; - } - let party = store.ledger().get_party(id).expect("resolved"); - Ok(Json(party_out(store.ledger(), party))) - } - - /// ```text - /// GOAL: the books know a W-9 arrived and what it said, and the full - /// TIN (and the form, which shows it) is kept where it can be - /// handed to the CPA and purged, never in the journal. - /// SOURCE: docs/parties-and-attachments.md "The TIN vault"; B-26; - /// Treas. Reg. §301.6109-4 (truncated TINs); DECISION (owner, - /// 2026-10-15): full TINs only in the encrypted vault. - /// INVARIANTS: - /// 1. A full TIN goes only to the vault (`vault/` in the book's own - /// folder, its key in `keys/` beside it, created on first use); - /// the facts journaled (`party.w9_set`) carry its last four and - /// the vault record's hash. The output never holds more than the - /// last four. - /// 2. The W-9 file goes only to the vault, never the attachment store. - /// 3. The facts are checked (on a copy of the ledger) before the vault - /// is written; a refusal journals nothing and writes no record. - /// 4. The facts never disagree with the vault: a TIN in the vault has - /// the facts' last four. - /// SCENARIOS: - /// 1. IF tin is given: SHOULD validate it (Tin::new, with tin_type), - /// take tin_last4 from it (refusing a given one that differs), and - /// default kept_at to "wanderbooks vault". - /// 2. IF no tin is given but the vault holds one whose last four - /// differ: SHOULD refuse (pass the new TIN, or books.purge_tin). - /// 3. IF clearing while the vault holds a record: SHOULD refuse - /// (books.purge_tin first). - /// ``` - #[tool( - name = "books.set_w9", - title = "Record W-9 facts", - description = "Record what a contractor's or landlord's W-9 says, or clear it (clear: true alone): the date received (received_on), line 3 entity type, line 4 exempt payee and FATCA codes, the TIN's last four digits, and where the form is kept. Give the full TIN as tin with tin_type (ssn, ein, itin) and it goes only to the encrypted TIN vault (its key kept apart from it, created on first use): the books keep its last four (tin_last4 is then optional, and refused if it disagrees) and a hash of the vault record, in the W-9 facts and the party's TIN facts alike (party.tin_set, as books.set_tin), and kept_at defaults to \"wanderbooks vault\". The CPA package carries the full TIN to the CPA. Give the signed form (a PDF, PNG, or JPEG) as w9_upload_id (books.begin_upload purpose w9, then `wanderbooks upload`: a file never passes through you), and it goes to the vault too, never to the receipts and documents; read it back with books.get_w9. Never put a TIN in any other field, note, description, or file: the engine refuses any nine-digit number, and no tool output echoes one. The party needs a 1099 role (contractor or landlord: books.set_party_roles). Record it before paying them. A party with a vendor record also gets its W-9 marked on file there (books.vendor_1099 missing_w9). Clearing W-9 facts whose TIN is in the vault needs books.purge_tin first. Journaled as party.w9_set. Returns the party (the TIN's last four only).", - annotations( - read_only_hint = false, - destructive_hint = true, - open_world_hint = false - ) - )] - async fn set_w9(&self, params: Parameters) -> Result, String> { - let p = params.0; - let w9_given = p.w9_upload_id.is_some(); - if p.w9_file_name.is_some() && !w9_given { - return Err("w9_file_name names the W-9 given with it (w9_upload_id)".into()); - } - let vault_input = p.tin.is_some() || p.tin_type.is_some() || w9_given; - let tin = match (&p.tin, &p.tin_type) { - (Some(t), Some(k)) => Some( - ledger_vault::Tin::new(t.0.trim(), parse_tin_type(k)?) - .map_err(|e| e.to_string())?, - ), - (Some(_), None) => return Err("tin needs tin_type: ssn, ein, or itin".into()), - (None, Some(_)) => return Err("tin_type goes with tin".into()), - (None, None) => None, - }; - let facts = if p.clear { - if p.received_on.is_some() - || p.entity_type.is_some() - || p.exempt_payee_code.is_some() - || p.fatca_code.is_some() - || p.tin_last4.is_some() - || p.kept_at.is_some() - || vault_input - { - return Err("clear: true takes no other W-9 fact".into()); - } - None - } else { - let need = |v: &Option, name: &str| { - v.as_deref() - .map(|s| s.trim().to_string()) - .ok_or_else(|| format!("{name} is required (or pass clear: true)")) - }; - let tin_last4 = match (&tin, p.tin_last4.as_deref().map(str::trim)) { - (Some(t), Some(given)) if given != t.last4() => { - return Err( - "tin_last4 doesn't match the TIN's last four digits (leave it out: it comes from tin)" - .into(), - ); - } - (Some(t), _) => t.last4().to_string(), - (None, _) => need(&p.tin_last4, "tin_last4")?, - }; - let kept_at = match &p.kept_at { - Some(k) => k.trim().to_string(), - None if vault_input => "wanderbooks vault".to_string(), - None => need(&p.kept_at, "kept_at")?, - }; - Some(ledger_core::W9Facts { - received: parse_date(&need(&p.received_on, "received_on")?)?, - entity_type: need(&p.entity_type, "entity_type")?, - exempt_payee_code: p.exempt_payee_code.as_deref().and_then(optional_text), - fatca_code: p.fatca_code.as_deref().and_then(optional_text), - tin_last4, - kept_at, - vault_record: None, - }) - }; - let (w9_file, mut w9_claim) = if let Some(upload_id) = &p.w9_upload_id { - let (file, claim) = self.w9_file(&p, upload_id)?; - (Some(file), claim) - } else { - (None, None) - }; - let mut store = self.lock()?; - let id = resolve_party(&store, &p.party)?; - let existing = store.ledger().get_party(id).and_then(|p| p.w9.clone()); - let party_tin = store.ledger().get_party(id).and_then(|p| p.tin.clone()); - // A new TIN with another last four replaces the party's TIN facts: - // cleared first, so the two never disagree on the record (B-26). - let replace_tin = tin - .as_ref() - .is_some_and(|t| party_tin.as_ref().is_some_and(|pt| pt.last4 != t.last4())); - let mut tin_facts: Option = None; - let vault_dir = self.vault_dir(&store); - let (held_tin, held_w9) = - ledger_vault::records_held(&vault_dir, id).map_err(|e| e.to_string())?; - let facts = match facts { - None => { - if held_tin - || held_w9 - || existing.as_ref().is_some_and(|f| f.vault_record.is_some()) - { - return Err(format!( - "the TIN vault holds party {id}'s TIN or W-9: purge it first (books.purge_tin, with the reason), then clear" - )); - } - None - } - Some(mut f) => { - // The facts must agree with a TIN the vault keeps - // (INVARIANT 4). - if tin.is_none() && held_tin { - let held = self - .open_vault(&store, false)? - .tin(id) - .map_err(|e| e.to_string())?; - if let Some(t) = held - && t.last4() != f.tin_last4 - { - return Err(format!( - "the TIN vault holds a TIN ending in {} for party {id}: pass the new tin (with tin_type), or purge the old one first (books.purge_tin)", - t.last4() - )); - } - } - // Rehearse with a stand-in hash before writing the vault - // (INVARIANT 3). - let mut rehearsal = store.ledger().clone(); - f.vault_record = Some("0".repeat(64)); - let stand_in = tin.as_ref().map(|t| ledger_core::TinFacts { - tin_type: tin_kind(t.kind), - last4: t.last4().to_string(), - vault_record: Some("0".repeat(64)), - purged: None, - }); - if replace_tin { - rehearsal - .set_party_tin(id, None) - .map_err(|e| e.to_string())?; - } - rehearsal - .set_party_w9(id, Some(f.clone())) - .map_err(|e| e.to_string())?; - if stand_in.is_some() { - rehearsal - .set_party_tin(id, stand_in.clone()) - .map_err(|e| e.to_string())?; - } - f.vault_record = if tin.is_some() || w9_file.is_some() { - let vault = self.open_vault(&store, true)?; - // The W-9 read is recorded before the vault keeps it: - // never filed into the books (tin_files.rs INVARIANT - // 1). Only now: every check has passed. - if let Some(w) = &w9_file { - self.wrote_tins(&store, &w.bytes)?; - } - let tin_hash = match &tin { - Some(t) => Some(vault.put_tin(id, t).map_err(|e| e.to_string())?), - None => None, - }; - let w9_hash = match &w9_file { - Some(w) => Some(vault.put_w9(id, w).map_err(|e| e.to_string())?), - None => None, - }; - // The W-9 is in the vault: its upload is used up. - files::filed(w9_claim.take()); - match tin_hash { - Some(h) => Some(h), - None if held_tin => vault.record_hash(id).map_err(|e| e.to_string())?, - None => w9_hash, - } - } else if held_tin { - ledger_vault::Vault::open(&vault_dir, &self.key_file(&store), false) - .ok() - .and_then(|v| v.record_hash(id).ok().flatten()) - .or_else(|| existing.as_ref().and_then(|e| e.vault_record.clone())) - } else if held_w9 { - existing.as_ref().and_then(|e| e.vault_record.clone()) - } else { - None - }; - // The TIN's facts carry the same record as the W-9's. - tin_facts = stand_in.map(|t| ledger_core::TinFacts { - vault_record: f.vault_record.clone(), - ..t - }); - Some(f) - } - }; - let on_file = facts.is_some(); - // The vendor record's W-9 flag follows (below): its feature is - // asked for before this command's first line, never after it - // (B-27: books waiting for their format upgrade must not be left - // with the W-9 recorded and the flag not). - let vendor = store.ledger().get_party(id).and_then(|p| p.vendor); - if vendor - .and_then(|v| store.ledger().get_vendor(v)) - .is_some_and(|v| v.tin_on_file != on_file) - { - store - .ensure_feature(ledger_store::format::VENDOR_1099_DECISIONS) - .map_err(|e| e.to_string())?; - } - if replace_tin { - store.set_party_tin(id, None).map_err(|e| e.to_string())?; - } - store.set_party_w9(id, facts).map_err(|e| e.to_string())?; - if tin_facts.is_some() { - store - .set_party_tin(id, tin_facts) - .map_err(|e| e.to_string())?; - } - // Keep the vendor record's W-9 flag (the 1099 worksheet's) in - // step, its 1099 decision and who made it kept (P3 review R8). - if let Some(v) = vendor { - store - .set_vendor_w9_flag(v, on_file) - .map_err(|e| e.to_string())?; - } - let party = store.ledger().get_party(id).expect("resolved"); - Ok(Json(party_out(store.ledger(), party))) - } - - /// ```text - /// GOAL: any party's full TIN (a member's SSN for their K-1, a payee's - /// from their W-9) kept where the CPA package can carry it, and - /// nowhere else. - /// SOURCE: docs/parties-and-attachments.md "The TIN vault"; B-26; - /// DECISION (owner, 2026-10-15): "In the vault, that's the - /// WHOLE reason we built it. And it needs to be in the damn CPA - /// package." - /// INVARIANTS: - /// 1. The full TIN goes only to the vault (set_w9's path: key outside - /// the books, created on first use; books in git must ignore - /// vault/); `party.tin_set` journals its type, last four, and the - /// record's hash. The output never holds more than the last four. - /// 2. Any role. - /// 3. The facts are checked (on a copy of the ledger) before the vault - /// is written; a refusal journals nothing and writes no record. - /// 4. W-9 facts on file must have the same last four (else refused: - /// a payee's new TIN comes with its W-9, books.set_w9); when they - /// do, their vault record follows the new record (`party.w9_set`). - /// ``` - #[tool( - name = "books.set_tin", - title = "Store a TIN in the vault", - description = "Put a party's full taxpayer identification number in the encrypted TIN vault: tin (nine digits, hyphens allowed) and tin_type (ssn, ein, itin). Any role: collect every member's (their K-1 needs it) and every 1099 payee's (books.set_w9 tin records a W-9's TIN the same way). The books keep only its type, last four, and a hash of the vault record (journaled as party.tin_set); books.export_cpa_package carries the full number to the CPA. A party whose W-9 facts name another last four is refused: a payee's new TIN comes with its new W-9 (books.set_w9). Never repeat a TIN in chat, descriptions, notes, version-history messages, or file names. Returns the party (the last four only).", - annotations( - read_only_hint = false, - destructive_hint = true, - open_world_hint = false - ) - )] - async fn set_tin(&self, params: Parameters) -> Result, String> { - let p = params.0; - let tin = ledger_vault::Tin::new(p.tin.0.trim(), parse_tin_type(&p.tin_type)?) - .map_err(|e| e.to_string())?; - let mut store = self.lock()?; - let id = resolve_party(&store, &p.party)?; - let w9 = store.ledger().get_party(id).and_then(|p| p.w9.clone()); - let stand_in = ledger_core::TinFacts { - tin_type: tin_kind(tin.kind), - last4: tin.last4().to_string(), - vault_record: Some("0".repeat(64)), - purged: None, - }; - // Rehearse on a copy before the vault is written (INVARIANT 3). - let mut rehearsal = store.ledger().clone(); - rehearsal - .set_party_tin(id, Some(stand_in.clone())) - .map_err(|e| e.to_string())?; - if let Some(f) = &w9 { - rehearsal - .set_party_w9( - id, - Some(ledger_core::W9Facts { - vault_record: Some("0".repeat(64)), - ..f.clone() - }), - ) - .map_err(|e| e.to_string())?; - } - let vault = self.open_vault(&store, true)?; - let hash = vault.put_tin(id, &tin).map_err(|e| e.to_string())?; - store - .set_party_tin( - id, - Some(ledger_core::TinFacts { - vault_record: Some(hash.clone()), - ..stand_in - }), - ) - .map_err(|e| e.to_string())?; - if let Some(f) = w9 - && f.vault_record.as_deref() != Some(hash.as_str()) - { - store - .set_party_w9( - id, - Some(ledger_core::W9Facts { - vault_record: Some(hash), - ..f - }), - ) - .map_err(|e| e.to_string())?; - } - let party = store.ledger().get_party(id).expect("resolved"); - Ok(Json(party_out(store.ledger(), party))) - } - - /// ```text - /// GOAL: hand the human (for the CPA) the signed W-9 the vault keeps, - /// without it passing through the books or the conversation. - /// SOURCE: docs/parties-and-attachments.md "The TIN vault"; D-29. - /// INVARIANTS: - /// 1. The file is handed over (tools/files.rs INVARIANT 6: an export - /// for the caller's token), never written to a path; a name kept - /// before names were checked is made plain, never refused. It is - /// recorded (tin_files.rs) first, so it is never filed back into - /// the books. - /// 2. Nothing is journaled. - /// SCENARIOS: - /// 1. IF the vault is locked or holds no W-9 file for the party: - /// SHOULD refuse, saying which. - /// ``` - #[tool( - name = "books.get_w9", - title = "Write out a W-9", - description = "A party's signed W-9 file from the TIN vault, for the CPA or the human: run the download command it returns to save it in a folder. Never file it into the books (it shows the full TIN). Nothing is journaled.", - annotations( - read_only_hint = false, - destructive_hint = false, - open_world_hint = false - ) - )] - async fn get_w9(&self, params: Parameters) -> Result, String> { - let p = params.0; - let store = self.lock()?; - let id = resolve_party(&store, &p.party)?; - let file = self - .open_vault(&store, false)? - .w9(id) - .map_err(|e| e.to_string())? - .ok_or_else(|| { - format!( - "the TIN vault holds no W-9 file for party {id}: store one with books.set_w9 w9_upload_id" - ) - })?; - self.wrote_tins(&store, &file.bytes)?; - let handed = self.hand_over( - &book_name(&store), - &file.file_name, - &file.mime_type, - file.bytes, - )?; - Ok(Json(GetW9Out { - party_id: id, - file: handed, - })) - } - - /// ```text - /// GOAL: a TIN kept no longer than needed can be destroyed, on the - /// record. - /// SOURCE: docs/parties-and-attachments.md "The TIN vault" ("Purge"); - /// IRS Pub. 583 (keep records while needed; then dispose). - /// INVARIANTS: - /// 1. Without confirmed=true: a preview, changing nothing. - /// 2. Confirmed: the party's vault records (TIN and W-9 file) are - /// deleted, needing no key; when the party has TIN facts, - /// `party.tin_set` journals them with the vault record cleared - /// and `purged` saying when and why, and when it has W-9 facts, - /// `party.w9_set` likewise in kept_at (the last four stay). - /// 3. The new facts are checked before anything is deleted; a refusal - /// changes nothing. - /// SCENARIOS: - /// 1. IF the reason is blank: SHOULD refuse. - /// 2. IF the vault holds nothing for the party and its facts name no - /// vault record: SHOULD refuse (nothing to purge). - /// ``` - #[tool( - name = "books.purge_tin", - title = "Purge a TIN from the vault", - description = "Delete a party's full TIN and W-9 file from the TIN vault (after the retention period, at the payee's request, or to replace a wrong one), with the reason. Without confirmed=true it previews what the vault holds and changes nothing. Confirmed, the records are deleted from the vault (backups made before keep their copies) and the books journal it: the party's TIN facts (party.tin_set: purged says when and why) and W-9 facts (party.w9_set: kept_at says so) keep the last four and drop the vault record. Works without the vault key. The next CPA package then lacks that TIN (and says so).", - annotations( - read_only_hint = false, - destructive_hint = true, - open_world_hint = false - ) - )] - async fn purge_tin( - &self, - params: Parameters, - ) -> Result, String> { - let p = params.0; - let reason = p.reason.trim(); - if reason.is_empty() { - return Err("give the reason for purging".into()); - } - let mut store = self.lock()?; - let id = resolve_party(&store, &p.party)?; - let party = store.ledger().get_party(id).expect("resolved").clone(); - let vault_dir = self.vault_dir(&store); - let (held_tin, held_w9) = - ledger_vault::records_held(&vault_dir, id).map_err(|e| e.to_string())?; - let recorded = party.w9.as_ref().is_some_and(|f| f.vault_record.is_some()) - || party.tin.as_ref().is_some_and(|t| t.vault_record.is_some()); - if !held_tin && !held_w9 && !recorded { - return Err(format!( - "the TIN vault holds nothing for party {id} ({}) and its W-9 and TIN facts name no vault record", - party.name - )); - } - let why = format!("purged from the wanderbooks vault on {}: {reason}", today()); - let new_facts = party.w9.clone().map(|f| ledger_core::W9Facts { - vault_record: None, - kept_at: format!("not kept: {why}"), - ..f - }); - let new_tin = party.tin.clone().map(|t| ledger_core::TinFacts { - vault_record: None, - purged: Some(why.clone()), - ..t - }); - // Both checked before anything is deleted (INVARIANT 3). - let mut rehearsal = store.ledger().clone(); - if let Some(f) = &new_facts { - rehearsal - .set_party_w9(id, Some(f.clone())) - .map_err(|e| e.to_string())?; - } - if let Some(t) = &new_tin { - rehearsal - .set_party_tin(id, Some(t.clone())) - .map_err(|e| e.to_string())?; - } - let mut out = PurgeTinOut { - status: "preview".into(), - party_id: id, - name: party.name.clone(), - tin_in_vault: held_tin, - w9_file_in_vault: held_w9, - tin_last4: party.tin_last4().map(str::to_string), - journaled: false, - note: format!( - "Confirming deletes {}{}; backups made before keep their copies. Make sure the CPA has what they need first.", - match (held_tin, held_w9) { - (true, true) => "the TIN and the W-9 file", - (true, false) => "the TIN", - (false, true) => "the W-9 file", - (false, false) => "nothing in the vault (the books' record of it is cleared)", - }, - match (new_facts.is_some(), new_tin.is_some()) { - (true, true) => - " and journals the purge in the TIN and W-9 facts (party.tin_set, party.w9_set; the last four stay)", - (false, true) => - " and journals the purge in the TIN facts (party.tin_set; the last four stay)", - (true, false) => - " and journals the purge in the W-9 facts (party.w9_set; the last four stay)", - (false, false) => "", - } - ), - }; - if !p.confirmed.unwrap_or(false) { - return Ok(Json(out)); - } - ledger_vault::purge_records(&vault_dir, id).map_err(|e| e.to_string())?; - if let Some(f) = new_facts { - store.set_party_w9(id, Some(f)).map_err(|e| e.to_string())?; - out.journaled = true; - } - if let Some(t) = new_tin { - store - .set_party_tin(id, Some(t)) - .map_err(|e| e.to_string())?; - out.journaled = true; - } - out.status = "purged".into(); - out.note = format!( - "Purged from the vault{}.", - if out.journaled { - "; the books record when and why" - } else { - "" - } - ); - Ok(Json(out)) - } - - #[tool( - name = "books.set_w4", - title = "Record W-4 facts", - description = "Record what an employee's W-4 says, or clear it (clear: true alone): the date received (received_on), the work state (two letters), and where the form is kept. Never the SSN. The party needs the employee role. Record it before their first payroll. Journaled as party.w4_set. Returns the party.", - annotations( - read_only_hint = false, - destructive_hint = false, - open_world_hint = false - ) - )] - async fn set_w4(&self, params: Parameters) -> Result, String> { - let p = params.0; - let facts = if p.clear { - if p.received_on.is_some() || p.state.is_some() || p.kept_at.is_some() { - return Err("clear: true takes no other W-4 fact".into()); - } - None - } else { - let need = |v: &Option, name: &str| { - v.as_deref() - .map(|s| s.trim().to_string()) - .ok_or_else(|| format!("{name} is required (or pass clear: true)")) - }; - Some(ledger_core::W4Facts { - received: parse_date(&need(&p.received_on, "received_on")?)?, - state: need(&p.state, "state")?, - kept_at: need(&p.kept_at, "kept_at")?, - }) - }; - let mut store = self.lock()?; - let id = resolve_party(&store, &p.party)?; - store.set_party_w4(id, facts).map_err(|e| e.to_string())?; - let party = store.ledger().get_party(id).expect("resolved"); - Ok(Json(party_out(store.ledger(), party))) - } - - #[tool( - name = "books.parties", - title = "List parties", - description = described( - "The party directory, by id: each party's name, label, roles (vendor and customer included), vendor and customer record ids, whether W-9 and W-4 facts are on file, the TIN's last four when the books know it (TIN or W-9 facts), archived, and what was paid to and received from it in the year on the basis asked: cash by default (money), or all (adds property contributed or distributed in kind); defined as in books.party, the basis echoed. Filter by role; archived parties only with include_archived.", - ledger_core::calendar::PeriodKind::CalendarYear - ), - annotations(read_only_hint = true, open_world_hint = false) - )] - async fn parties( - &self, - params: Parameters, - ) -> Result, String> { - let p = params.0; - let role = p - .role - .as_deref() - .map(|r| parse_roles(&[r.to_string()]).map(|v| v[0])) - .transpose()?; - let year = p.year.unwrap_or_else(|| self.periods().calendar_year()); - let basis = parse_basis(p.basis.as_deref())?; - let settings = self.settings(); - let store = self.lock()?; - let mut parties = Vec::new(); - for party in store.ledger().parties() { - if party.archived && !p.include_archived { - continue; - } - if role.is_some_and(|r| !party.all_roles().contains(&r)) { - continue; - } - let act = party_activity(&store, party, &settings, year, basis)?; - parties.push(PartySummaryOut { - party_id: party.id, - name: party.name.clone(), - label: party.label().to_string(), - roles: party_roles(party), - vendor_id: party.vendor, - customer_id: party.customer, - w9_on_file: party.w9.is_some(), - tin_last4: party.tin_last4().map(str::to_string), - w4_on_file: party.w4.is_some(), - archived: party.archived, - paid: money(act.paid), - received: money(act.received), - }); - } - Ok(Json(ListPartiesOut { - year, - basis: basis.key().to_string(), - parties, - })) - } - - #[tool( - name = "books.party", - title = "Get a party", - description = described( - "One party, by id or name: its facts and roles, vendor and customer record ids, the year's totals (paid to it and received from it on the basis asked, cash by default or all with property in kind, the basis echoed; invoiced, billed, the 1099 amount for a vendor, payroll gross and net), what it is owed and owes now (open bills and invoices), every entry it is on (tagged with it or its vendor record, its invoices and their payments, and their reversals), its invoices and bills, its projects, and the payroll lines naming it. Totals come from the journal: the output's schema says exactly how.", - ledger_core::calendar::PeriodKind::CalendarYear - ), - annotations(read_only_hint = true, open_world_hint = false) - )] - async fn party(&self, params: Parameters) -> Result, String> { - let p = params.0; - let year = p.year.unwrap_or_else(|| self.periods().calendar_year()); - let basis = parse_basis(p.basis.as_deref())?; - let settings = self.settings(); - let store = self.lock()?; - let ledger = store.ledger(); - let id = resolve_party(&store, &p.party)?; - let party = ledger.get_party(id).expect("resolved"); - let act = party_activity(&store, party, &settings, year, basis)?; - let in_year = |d: NaiveDate| chrono::Datelike::year(&d) == year; - let mut entries: Vec = act - .entries - .iter() - .filter_map(|&e| ledger.get_entry(e)) - .map(|e| PartyEntryOut { - entry_id: e.id(), - date: e.date().to_string(), - description: e.description().to_string(), - status: match e.status() { - ledger_core::EntryStatus::Draft => "draft", - ledger_core::EntryStatus::Posted => "posted", - } - .to_string(), - total: ReceiptPolicy::entry_debits(e).ok().map(money), - reversal_of: e.reversal_of(), - }) - .collect(); - entries.sort_by(|a, b| b.date.cmp(&a.date).then(b.entry_id.cmp(&a.entry_id))); - let invoices = party_invoices(ledger, party); - let bills = party_bills(ledger, party); - // The worksheet's figure; none when it can't be made (no cash - // accounts named), never a failed answer. - let amount_1099 = party.vendor.and_then(|v| { - let r = vendor_1099_report(ledger, &self.settings(), year, Some(Decimal::ZERO)).ok()?; - Some(money( - r.vendors - .iter() - .find(|l| l.vendor_id == v) - .map(|l| l.paid) - .unwrap_or_default(), - )) - }); - let decided_now = party - .vendor - .and_then(|v| ledger.get_vendor(v)) - .map(|v| v.decision_on(today()).cloned().unwrap_or_default()); - // Documents and runs are restored as journaled (rules.md B-28): a - // total past exact arithmetic refuses the answer, never a panic. - let mut payroll = Vec::new(); - let (mut gross, mut net) = (Decimal::ZERO, Decimal::ZERO); - for run in ledger.list_payroll_runs() { - for line in run.lines.iter().filter(|l| l.employee == Some(id)) { - let line_net = line.net_pay().map_err(|e| e.to_string())?; - if in_year(run.date) { - gross = checked_sum([Ok(gross), Ok(line.gross_pay)], "gross pay")?; - net = checked_sum([Ok(net), Ok(line_net)], "net pay")?; - } - payroll.push(PartyPayrollOut { - run_id: run.id, - date: run.date.to_string(), - entry_id: run.entry_id, - gross_pay: money(line.gross_pay), - net_pay: money(line_net), - }); - } - } - let totals = PartyTotalsOut { - basis: basis.key().to_string(), - paid: money(act.paid), - received: money(act.received), - invoiced: money(checked_sum( - invoices - .iter() - .filter(|i| in_year(i.date)) - .map(|i| i.total()), - "the year's invoices", - )?), - billed: money(checked_sum( - bills.iter().filter(|b| in_year(b.date)).map(|b| b.total()), - "the year's bills", - )?), - owed: money(checked_sum( - bills.iter().map(|b| b.balance()), - "what is owed", - )?), - receivable: money(checked_sum( - invoices.iter().map(|i| i.balance()), - "what is receivable", - )?), - amount_1099, - form_1099: form_1099_label(decided_now.as_ref().and_then(|d| d.form())), - decision_1099: decided_now.as_ref().map(|d| d.key().to_string()), - gross_pay: money(gross), - net_pay: money(net), - }; - Ok(Json(PartyDetailOut { - party: party_out(ledger, party), - year, - totals, - entries, - invoices: invoices - .iter() - .map(|i| { - party_document( - i.id, - (i.date, i.due_date), - (i.total(), i.balance(), i.amount_paid), - i.status, - i.entry_id, - ) - }) - .collect::>()?, - bills: bills - .iter() - .map(|b| { - party_document( - b.id, - (b.date, b.due_date), - (b.total(), b.balance(), b.amount_paid), - b.status, - b.entry_id, - ) - }) - .collect::>()?, - payroll, - projects: ledger - .projects_of_party(id) - .into_iter() - .map(|c| projects::project_ref(ledger, c)) - .collect(), - })) - } - - #[tool( - name = "books.set_entry_party", - title = "Tag an entry's parties", - description = "Tag an entry (draft or posted) with the party it was with (id or name), or pass party null (or omit it) to clear the tag. A party with a vendor record also tags the entry's vendor, so a payment to a contractor or landlord counts in books.vendor_1099; a party without one, or clearing, clears the vendor tag. For an entry with several counterparties pass parties instead: [{party, amount?|percent?}], each share an exact amount (cents), a percent (summing to 100; leftover cents to the first party), or neither (derived: a sole party gets all; members named together get what their capital or distributions lines say, else their [[company.members]] share; once the entry is posted, a derived split among several parties is recorded as the exact amounts it resolves to, so a later change to [[company.members]] never re-splits posted history). Name both members on a joint member transaction (a reimbursement on 2100, a joint contribution) and let the shares derive; give amounts when a split is uneven. Refused: a party twice, mixed kinds of share, two parties with vendor records, and, once the entry has balanced lines, shares that don't add up to what it moved. Metadata only: amounts never change. An entry and its reversal carry the same parties: tagging either tags both, so each party's part nets to zero. Journaled as entry.party_set (one party) or entry.parties_set (parties). Returns the entry (as books.get_entry), each party's share resolved, with its direction and what of it was money or property in kind.", - annotations( - read_only_hint = false, - destructive_hint = false, - open_world_hint = false - ) - )] - async fn set_entry_party( - &self, - params: Parameters, - ) -> Result, String> { - let p = params.0; - { - let settings = self.settings(); - let mut store = self.lock()?; - let party = p - .party - .as_deref() - .map(|r| resolve_party(&store, r)) - .transpose()?; - let shares = p - .parties - .as_deref() - .map(|ps| parse_shares(&store, ps)) - .transpose()?; - let reason = p - .no_party_reason - .as_deref() - .map(str::trim) - .filter(|r| !r.is_empty()); - match (party, reason, shares) { - (Some(_), _, Some(_)) => { - return Err( - "pass party (one) or parties (several, with shares), not both".into(), - ); - } - (_, Some(_), Some(_)) => { - return Err("pass parties or a no_party_reason, not both".into()); - } - (Some(_), Some(_), None) => { - return Err("pass a party or a no_party_reason, not both".into()); - } - (None, Some(r), _) => { - if !store.ledger().entry_parties(p.entry_id).is_empty() { - // Check the mark before clearing, so a refusal - // journals nothing. - if r.trim().is_empty() { - return Err("say why the entry has no counterparty".into()); - } - ledger_core::parties::check_text("reason", r)?; - store - .set_entry_party(p.entry_id, None) - .map_err(|e| e.to_string())?; - } - store - .set_entry_no_party(p.entry_id, r) - .map_err(|e| e.to_string())?; - } - (None, None, Some(sh)) => { - store - .ledger() - .check_set_entry_parties(p.entry_id, &sh) - .map_err(|e| e.to_string())?; - check_shares_resolve(&store, &settings, p.entry_id, &sh)?; - store - .set_entry_parties(p.entry_id, &sh) - .map_err(|e| e.to_string())?; - fix_derived_shares(&mut store, &settings, p.entry_id)?; - } - (party, None, None) => { - store - .set_entry_party(p.entry_id, party) - .map_err(|e| e.to_string())?; - } - } - } - self.get_entry(Parameters(GetEntryParams { - entry_id: p.entry_id, - })) - .await - } - - #[tool( - name = "books.create_invoice", - title = "Create and post an invoice", - description = "Create a sales invoice. Auto-posts Dr Accounts Receivable / Cr Revenue. Lines are {description, amount, class?} with decimal-string amounts; class (a project: name or id, books.projects) tags that line's revenue: one revenue line per project, the untagged lines' as one; the receivable line is never tagged. Its result gains notes for a finished, abandoned or on-spec project (on spec: is there now an agreement?), tagged anyway.", - annotations( - read_only_hint = false, - destructive_hint = true, - open_world_hint = false - ) - )] - async fn create_invoice( - &self, - params: Parameters, - ) -> Result, String> { - let p = params.0; - let mut store = self.lock()?; - let lines = document_lines(&store, p.lines)?; - let first_new = store.ledger().next_account_id(); - let id = store - .create_invoice( - p.party_id, - parse_date(&p.date)?, - parse_date(&p.due_date)?, - lines, - ) - .map_err(|e| e.to_string())?; - let inv = store - .ledger() - .get_invoice(id) - .ok_or_else(|| format!("invoice {id} not found after creation"))?; - Ok(Json(CreateDocumentOut { - id, - total: inv.total().map_err(|e| e.to_string())?.to_string(), - entry_id: inv.entry_id, - warnings: created_uncategorized(store.ledger(), first_new), - notes: document_notes(store.ledger(), &inv.lines, true), - })) - } - - #[tool( - name = "books.create_bill", - title = "Create and post a bill", - description = "Create a vendor bill. Auto-posts Dr General Expenses / Cr Accounts Payable. Lines are {description, amount, class?} with decimal-string amounts; class (a project: name or id, books.projects) tags that line's cost: one expense line per project, the untagged lines' as one; the payable line is never tagged. Its result gains notes for a finished or abandoned project, tagged anyway.", - annotations( - read_only_hint = false, - destructive_hint = true, - open_world_hint = false - ) - )] - async fn create_bill( - &self, - params: Parameters, - ) -> Result, String> { - let p = params.0; - let mut store = self.lock()?; - let lines = document_lines(&store, p.lines)?; - let first_new = store.ledger().next_account_id(); - let id = store - .create_bill( - p.party_id, - parse_date(&p.date)?, - parse_date(&p.due_date)?, - lines, - ) - .map_err(|e| e.to_string())?; - let bill = store - .ledger() - .get_bill(id) - .ok_or_else(|| format!("bill {id} not found after creation"))?; - Ok(Json(CreateDocumentOut { - id, - total: bill.total().map_err(|e| e.to_string())?.to_string(), - entry_id: bill.entry_id, - warnings: created_uncategorized(store.ledger(), first_new), - notes: document_notes(store.ledger(), &bill.lines, false), - })) - } - - #[tool( - name = "books.receive_payment", - title = "Receive an invoice payment", - description = "Record a customer payment against an invoice. Posts Dr Cash / Cr A/R, the receivable line tagged with the invoice (its figures are derived from its lines that stand: a reversed payment no longer counts). Name instrument (how the money moved, books.instruments) and the processor's or bank's reference when known. The books record payments; they never collect them (D-45). Refused above what it owes, before its date, or into an account that isn't cash.", - annotations( - read_only_hint = false, - destructive_hint = true, - open_world_hint = false - ) - )] - async fn receive_payment( - &self, - params: Parameters, - ) -> Result, String> { - let p = params.0; - let mut store = self.lock()?; - let cash = resolve_account(&store, &p.cash_account)?; - let details = payment_details(&store, &p)?; - let entry_id = store - .receive_payment_with( - p.document_id, - parse_date(&p.date)?, - parse_amount(&p.amount)?, - cash, - &details, - ) - .map_err(|e| e.to_string())?; - payment_out( - store.ledger(), - ledger_core::DocRef::Invoice(p.document_id), - entry_id, - ) - } - - #[tool( - name = "books.pay_bill", - title = "Pay a bill", - description = "Record a payment of a vendor bill. Posts Dr A/P / Cr Cash, the payable line tagged with the bill (its figures are derived from its lines that stand: a reversed payment no longer counts). Name instrument (how the money moved, books.instruments: a card payment is left out of 1099-NEC) and the payment's reference when known. The books record payments; they never make them (D-45). Refused above what it owes, before its date, or from an account that isn't cash.", - annotations( - read_only_hint = false, - destructive_hint = true, - open_world_hint = false - ) - )] - async fn pay_bill( - &self, - params: Parameters, - ) -> Result, String> { - let p = params.0; - let mut store = self.lock()?; - let cash = resolve_account(&store, &p.cash_account)?; - let details = payment_details(&store, &p)?; - let entry_id = store - .pay_bill_with( - p.document_id, - parse_date(&p.date)?, - parse_amount(&p.amount)?, - cash, - &details, - ) - .map_err(|e| e.to_string())?; - payment_out( - store.ledger(), - ledger_core::DocRef::Bill(p.document_id), - entry_id, - ) - } - - #[tool( - name = "books.void_document", - title = "Void an invoice or bill", - description = "Void an invoice or bill that should never have existed (a duplicate, the wrong customer or vendor): reverses its own entry, dated as given, with the reason. It keeps its number and shows as void; nothing is deleted. Refused while a payment stands on it (reverse that payment first: the refusal names it), when it is void already, before its date, or in a closed period. To lower a valid document, not void it: a credit memo (later). Posts at once.", - annotations( - read_only_hint = false, - destructive_hint = true, - open_world_hint = false - ) - )] - async fn void_document( - &self, - params: Parameters, - ) -> Result, String> { - let p = params.0; - let doc = doc_ref(&p.kind, p.id)?; - let date = parse_date(&p.date)?; - let mut store = self.lock()?; - let entry_id = store - .void_document(doc, date, &p.reason) - .map_err(|e| e.to_string())?; - let status = store - .ledger() - .doc_state(doc, date) - .map_err(|e| e.to_string())? - .map_or("void", |st| st.status.key()); - Ok(Json(VoidDocumentOut { - entry_id, - status: status.to_string(), - })) - } - - #[tool( - name = "books.documents", - title = "List invoices or bills", - description = described( - "List invoices (kind invoices: money owed to the company) or bills (kind bills: money it owes), due date first: each with its ref (for books.document), party, dates, total, what it still owes and its status, all derived from its ledger lines that stand. status: open (default: anything still owed), overdue, paid, void, written_off, overpaid, or all; party and due_by narrow it (due_by: what is due by a day, e.g. what to pay this week).", - ledger_core::calendar::PeriodKind::AsOf - ), - annotations(read_only_hint = true, open_world_hint = false) - )] - async fn documents( - &self, - params: Parameters, - ) -> Result, String> { - let p = params.0; - let as_of = self.as_of(p.as_of.as_deref())?; - let due_by = p.due_by.as_deref().map(parse_date).transpose()?; - let invoices = match p.kind.trim().to_ascii_lowercase().as_str() { - "invoices" | "invoice" => true, - "bills" | "bill" => false, - other => return Err(format!("bad kind {other:?}: expected invoices or bills")), - }; - let want = p - .status - .as_deref() - .unwrap_or("open") - .trim() - .to_ascii_lowercase(); - if !matches!( - want.as_str(), - "open" | "overdue" | "paid" | "void" | "written_off" | "overpaid" | "all" - ) { - return Err(format!( - "bad status {want:?}: expected open, overdue, paid, void, written_off, overpaid or all" - )); - } - let store = self.lock()?; - let ledger = store.ledger(); - let docs: Vec<(ledger_core::DocRef, String, NaiveDate, NaiveDate)> = if invoices { - ledger - .invoices() - .into_iter() - .map(|i| { - let who = ledger - .get_customer(i.customer_id) - .map(|c| c.name.clone()) - .unwrap_or_default(); - (ledger_core::DocRef::Invoice(i.id), who, i.date, i.due_date) - }) - .collect() - } else { - ledger - .bills() - .into_iter() - .map(|b| { - let who = ledger - .get_vendor(b.vendor_id) - .map(|v| v.name.clone()) - .unwrap_or_default(); - (ledger_core::DocRef::Bill(b.id), who, b.date, b.due_date) - }) - .collect() - }; - let party = p.party.as_deref().map(|s| s.trim().to_lowercase()); - let mut rows = Vec::new(); - let mut owed = Decimal::ZERO; - for (doc, who, date, due) in docs { - if let Some(want_party) = &party - && who.to_lowercase() != *want_party - && doc_id(doc).to_string() != *want_party - { - continue; - } - if due_by.is_some_and(|d| due > d) { - continue; - } - let Some(st) = ledger.doc_state(doc, as_of).map_err(|e| e.to_string())? else { - continue; - }; - let key = st.status.key(); - let keep = match want.as_str() { - "all" => true, - "open" => !st.outstanding.is_zero() && key != "void", - "overdue" => st.overdue_days > 0, - other => key == other, - }; - if !keep { - continue; - } - owed = owed - .checked_add(st.outstanding) - .ok_or("the total is too large to compute")?; - rows.push(( - due, - doc_id(doc), - DocumentRowOut { - r#ref: doc_ref_text(doc), - party: who, - date: date.to_string(), - due_date: due.to_string(), - total: money(st.total), - outstanding: money(st.outstanding), - status: key.to_string(), - overdue_days: st.overdue_days, - }, - )); - } - rows.sort_by_key(|r| (r.0, r.1)); - Ok(Json(DocumentsOut { - as_of: as_of.to_string(), - kind: if invoices { "invoices" } else { "bills" }.to_string(), - documents: rows.into_iter().map(|r| r.2).collect(), - outstanding_total: money(owed), - })) - } - - #[tool( - name = "books.document", - title = "An invoice or bill", - description = described( - "One invoice or bill (ref \"invoice:7\" or \"bill:12\", from books.documents) as of a day: party, dates, lines with projects, total, paid, discounted, written off, still owed, status; and its timeline, every ledger line tagged with it in posting order, each saying whether its entry still stands (a reversed payment shows as the payment, not standing, and its reversal, standing: they net to nothing). pdf: true also hands over an invoice's PDF to download, re-created from the books.", - ledger_core::calendar::PeriodKind::AsOf - ), - annotations(read_only_hint = true, open_world_hint = false) - )] - async fn document( - &self, - params: Parameters, - ) -> Result, String> { - let p = params.0; - let as_of = self.as_of(p.as_of.as_deref())?; - let doc = parse_doc_ref(&p.r#ref)?; - let store = self.lock()?; - let ledger = store.ledger(); - let (who, date, due, entry_id, lines) = match doc { - ledger_core::DocRef::Invoice(id) => { - let i = ledger - .get_invoice(id) - .ok_or_else(|| format!("no invoice {id}: see books.documents"))?; - let who = ledger - .get_customer(i.customer_id) - .map(|c| c.name.clone()) - .unwrap_or_default(); - (who, i.date, i.due_date, i.entry_id, i.lines.clone()) - } - ledger_core::DocRef::Bill(id) => { - let b = ledger - .get_bill(id) - .ok_or_else(|| format!("no bill {id}: see books.documents"))?; - let who = ledger - .get_vendor(b.vendor_id) - .map(|v| v.name.clone()) - .unwrap_or_default(); - (who, b.date, b.due_date, b.entry_id, b.lines.clone()) - } - }; - let st = ledger - .doc_state(doc, as_of) - .map_err(|e| e.to_string())? - .ok_or_else(|| format!("{} is dated after {as_of}", doc_ref_text(doc)))?; - let pdf = match (p.pdf, doc) { - (false, _) => None, - (true, ledger_core::DocRef::Bill(_)) => { - return Err( - "a bill is the vendor's document: its PDF is the file filed with it (books.list_receipts)" - .into(), - ); - } - (true, ledger_core::DocRef::Invoice(id)) => { - let settings = self.settings(); - let printed = ledger_package::invoice_document( - ledger, - &settings.company, - id, - as_of, - today(), - )?; - let bytes = - ledger_import::invoice_pdf::invoice_pdf(&printed).map_err(|e| e.to_string())?; - Some(self.hand_over( - &book_name(&store), - &format!("invoice-{id}.pdf"), - "application/pdf", - bytes, - )?) - } - }; - let timeline = ledger - .doc_lines(doc) - .into_iter() - .filter(|l| l.date <= as_of) - .map(|l| DocumentEventOut { - date: l.date.to_string(), - entry_id: l.entry, - kind: movement_key(l.movement).to_string(), - amount: money(l.amount), - reversal: l.reversal, - stands: ledger.stands_on(l.entry, as_of), - }) - .collect(); - Ok(Json(DocumentOut { - r#ref: doc_ref_text(doc), - party: who, - date: date.to_string(), - due_date: due.to_string(), - entry_id, - lines: lines - .iter() - .map(|l| DocumentLineOut { - description: l.description.clone(), - amount: money(l.amount), - project: l - .class_id - .and_then(|c| ledger.get_class(c)) - .map(|c| c.name.clone()), - }) - .collect(), - total: money(st.total), - paid: money(st.paid), - discounted: money(st.discounted), - written_off: money(st.written_off), - outstanding: money(st.outstanding), - status: st.status.key().to_string(), - overdue_days: st.overdue_days, - timeline, - pdf, - })) - } - - #[tool( - name = "books.control_check", - title = "Receivables and payables agree", - description = described( - "Check that Accounts Receivable and Accounts Payable equal their documents as of a day: for each control account, its ledger balance, what its open invoices or bills still owe on it, every line on it that is no document's (a hand entry or an older version's), the difference (always 0.00), and the documents needing attention (overpaid, or not in the ledger). agrees is true when there is nothing to look at. The first thing a CPA ties out.", - ledger_core::calendar::PeriodKind::AsOf - ), - annotations(read_only_hint = true, open_world_hint = false) - )] - async fn control_check( - &self, - params: Parameters, - ) -> Result, String> { - let p = params.0; - let as_of = self.as_of(p.as_of.as_deref())?; - let store = self.lock()?; - let ledger = store.ledger(); - let checks = ledger.control_check(as_of).map_err(|e| e.to_string())?; - Ok(Json(control_check_out(ledger, as_of, &checks))) - } - - #[tool( - name = "books.ar_aging", - title = "Receivables aging", - description = described( - "A/R aging report as of a date: outstanding balances per customer, bucketed by days past due (current, 30, 60, 90, 90+).", - ledger_core::calendar::PeriodKind::AsOf - ), - annotations(read_only_hint = true, open_world_hint = false) - )] - async fn ar_aging(&self, params: Parameters) -> Result, String> { - let p = params.0; - let as_of = self.as_of(p.as_of.as_deref())?; - let store = self.lock()?; - let rows = store - .ledger() - .ar_aging(as_of) - .map_err(|e| e.to_string())? - .into_iter() - .map(|r| { - let total = r.total().map_err(|e| e.to_string())?.to_string(); - Ok(AgingRowOut { - name: r.name, - current: money(r.current), - days_30: money(r.days_30), - days_60: money(r.days_60), - days_90: money(r.days_90), - days_90_plus: money(r.days_90_plus), - total, - }) - }) - .collect::>()?; - Ok(Json(AgingOut { - as_of: as_of.to_string(), - rows, - })) - } - - #[tool( - name = "books.ap_aging", - title = "Payables aging", - description = described( - "A/P aging report as of a date: outstanding balances per vendor, bucketed by days past due (current, 30, 60, 90, 90+).", - ledger_core::calendar::PeriodKind::AsOf - ), - annotations(read_only_hint = true, open_world_hint = false) - )] - async fn ap_aging(&self, params: Parameters) -> Result, String> { - let p = params.0; - let as_of = self.as_of(p.as_of.as_deref())?; - let store = self.lock()?; - let rows = store - .ledger() - .ap_aging(as_of) - .map_err(|e| e.to_string())? - .into_iter() - .map(|r| { - let total = r.total().map_err(|e| e.to_string())?.to_string(); - Ok(AgingRowOut { - name: r.name, - current: money(r.current), - days_30: money(r.days_30), - days_60: money(r.days_60), - days_90: money(r.days_90), - days_90_plus: money(r.days_90_plus), - total, - }) - }) - .collect::>()?; - Ok(Json(AgingOut { - as_of: as_of.to_string(), - rows, - })) - } - - // ------------------------------------------------------------------ - // Foreign exchange - // ------------------------------------------------------------------ - - #[tool( - name = "books.set_fx_rate", - title = "Set an exchange rate", - description = "Record a historical FX rate: how much base currency one unit of the foreign currency buys on the given date. For reference; lines store their own rate at entry time.", - annotations( - read_only_hint = false, - destructive_hint = false, - open_world_hint = false - ) - )] - async fn set_fx_rate( - &self, - params: Parameters, - ) -> Result, String> { - let p = params.0; - let mut store = self.lock()?; - let currency = Currency::new(&p.currency).map_err(|e| e.to_string())?; - store - .set_fx_rate(currency, parse_date(&p.date)?, parse_amount(&p.rate)?) - .map_err(|e| e.to_string())?; - Ok(Json(SetFxRateOut { ok: true })) - } - - // ------------------------------------------------------------------ - // Payroll (accounting only — the caller supplies every number; no tax tables) - // ------------------------------------------------------------------ - - #[tool( - name = "books.run_payroll", - title = "Run payroll", - description = "Run payroll for a pay date. Each line carries one employee's gross pay and every withholding/deduction as decimal strings — the engine cuts one balanced entry (Dr Salary & Wage Expense, Dr Payroll Tax Expense for the employer share, Cr the withholding payables, Cr cash for net pay). Auto-creates the 61xx/21xx accounts. All amounts are decimal strings, never floats. Name each line's employee party (employee: id or name, a party with the employee role; add one with books.add_party and record its W-4 with books.set_w4 first): new runs should, so the run shows on the employee's books.party.", - annotations( - read_only_hint = false, - destructive_hint = true, - open_world_hint = false - ) - )] - async fn run_payroll( - &self, - params: Parameters, - ) -> Result, String> { - let p = params.0; - let mut store = self.lock()?; - let cash = resolve_account(&store, &p.cash_account)?; - let lines: Vec = p - .lines - .into_iter() - .map(|l| { - let employee = l - .employee - .as_deref() - .map(|r| resolve_party(&store, r)) - .transpose()?; - let employee_name = match (l.employee_name, employee) { - (Some(n), _) => n, - (None, Some(id)) => store - .ledger() - .get_party(id) - .map(|p| p.name.clone()) - .unwrap_or_default(), - (None, None) => { - return Err( - "each payroll line names its employee: employee (a party) or employee_name" - .to_string(), - ); - } - }; - Ok(ledger_core::PayrollLine { - employee_name, - employee, - gross_pay: parse_amount(&l.gross_pay)?, - federal_withholding: parse_amount(&l.federal_withholding)?, - state_withholding: parse_amount(&l.state_withholding)?, - ss_employee: parse_amount(&l.ss_employee)?, - medicare_employee: parse_amount(&l.medicare_employee)?, - ss_employer: parse_amount(&l.ss_employer)?, - medicare_employer: parse_amount(&l.medicare_employer)?, - other_deductions: parse_amount(&l.other_deductions)?, - }) - }) - .collect::>()?; - if self.settings().parties == ledger_config::PartyPolicy::Require - && lines - .iter() - .any(|l| l.employee.is_none() && l.net_pay().is_ok_and(|n| !n.is_zero())) - { - return Err(party_required( - "name each payroll line's employee party (employee), so the entry names who was paid", - )); - } - let first_new = store.ledger().next_account_id(); - let run_id = store - .run_payroll(parse_date(&p.date)?, lines, cash) - .map_err(|e| e.to_string())?; - let run = store - .ledger() - .get_payroll_run(run_id) - .ok_or_else(|| format!("payroll run {run_id} not found after creation"))?; - let total_gross = checked_sum(run.lines.iter().map(|l| Ok(l.gross_pay)), "gross pay")?; - let total_net = checked_sum(run.lines.iter().map(|l| l.net_pay()), "net pay")?; - Ok(Json(RunPayrollOut { - run_id, - entry_id: run.entry_id, - total_gross: money(total_gross), - total_net: money(total_net), - warnings: created_uncategorized(store.ledger(), first_new), - })) - } - - #[tool( - name = "books.remit_payroll_taxes", - title = "Remit payroll taxes", - description = "Remit all outstanding payroll tax payables (federal/state withholding, SS, Medicare, other deductions) to cash in one balanced entry. Name the tax agency paid (agency: a party id or name, e.g. the IRS): it is the entry's party, and [books] parties = \"require\" refuses without it.", - annotations( - read_only_hint = false, - destructive_hint = true, - open_world_hint = false - ) - )] - async fn remit_payroll_taxes( - &self, - params: Parameters, - ) -> Result, String> { - let p = params.0; - let mut store = self.lock()?; - let cash = resolve_account(&store, &p.cash_account)?; - let agency = p - .agency - .as_deref() - .map(|r| resolve_party(&store, r)) - .transpose()?; - if agency.is_none() && self.settings().parties == ledger_config::PartyPolicy::Require { - return Err(party_required("pass agency: the tax agency paid")); - } - // Total before remitting, for the response. - let total: Decimal = store - .ledger() - .payroll_tax_payables() - .iter() - .map(|(_, b)| *b) - .sum(); - let entry_id = store - .remit_payroll_taxes(parse_date(&p.date)?, cash) - .map_err(|e| e.to_string())?; - if let Some(a) = agency { - store - .set_entry_party(entry_id, Some(a)) - .map_err(|e| e.to_string())?; - } - Ok(Json(RemitPayrollTaxesOut { - entry_id, - total: money(total), - })) - } - - #[tool( - name = "books.list_payroll_runs", - title = "List payroll runs", - description = "List payroll runs in id order: date, employee count, totals, and the posted entry id.", - annotations(read_only_hint = true, open_world_hint = false) - )] - async fn list_payroll_runs(&self) -> Result, String> { - let store = self.lock()?; - let runs = store - .list_payroll_runs() - .into_iter() - .map(|r| { - // A run is as journaled (rules.md B-28): a total past exact - // arithmetic refuses the list, never a panic. - let total_gross = - checked_sum(r.lines.iter().map(|l| Ok(l.gross_pay)), "gross pay")?; - Ok(PayrollRunOut { - id: r.id, - date: r.date.to_string(), - employee_count: r.lines.len(), - total_gross: money(total_gross), - entry_id: r.entry_id, - }) - }) - .collect::>()?; - Ok(Json(ListPayrollRunsOut { runs })) - } - - // ------------------------------------------------------------------ - // Classes (divisional tracking) - // ------------------------------------------------------------------ - - #[tool( - name = "books.add_class", - title = "Add a class", - description = "Add a class: a project with only its name (books.add_project records its facts too; books.update_project adds them later). Names are unique, ignoring capitals. Tag journal lines with it via books.add_line's class param.", - annotations( - read_only_hint = false, - destructive_hint = false, - open_world_hint = false - ) - )] - async fn add_class( - &self, - params: Parameters, - ) -> Result, String> { - let p = params.0; - let mut store = self.lock()?; - let id = store.add_class(&p.name).map_err(|e| e.to_string())?; - Ok(Json(AddClassOut { id })) - } - - #[tool( - name = "books.list_classes", - title = "List classes", - description = "List classes (projects) in id order; books.projects gives their facts and figures.", - annotations(read_only_hint = true, open_world_hint = false) - )] - async fn list_classes(&self) -> Result, String> { - let store = self.lock()?; - let classes = store - .list_classes() - .into_iter() - .map(|c| ClassOut { - id: c.id, - name: c.name.clone(), - }) - .collect(); - Ok(Json(ListClassesOut { classes })) - } - - #[tool( - name = "books.profit_and_loss_by_class", - title = "Profit and loss by class", - description = described( - "P&L broken down by class (a project: see books.projects) for a period, plus an unclassified row for untagged lines.", - ledger_core::calendar::PeriodKind::Statement - ), - annotations(read_only_hint = true, open_world_hint = false) - )] - async fn profit_and_loss_by_class( - &self, - params: Parameters, - ) -> Result, String> { - let p = params.0; - let store = self.lock()?; - let ledger = store.ledger(); - let (start, end) = self.statement_range(p.start.as_deref(), p.end.as_deref())?; - // One class, if asked (validated like every reference). - let only = match &p.class { - Some(c) => Some(resolve_class(&store, c)?), - None => None, - }; - // Per-class P&L over the date range: accumulate line by line. - use std::collections::HashMap; - #[derive(Default)] - struct Acc { - revenue: Decimal, - expenses: Decimal, - } - let mut by_class: HashMap, Acc> = HashMap::new(); - // A closing entry isn't income or spending (rules.md B-11): left - // out, as profit_and_loss_for_period leaves it out (found by - // pnl_by_class_excludes_closing_entries, 2026-10-14). - let closings = ledger.closing_entry_ids(); - for entry in ledger.posted_entries() { - if entry.date() < start || entry.date() > end || closings.contains(&entry.id()) { - continue; - } - for line in entry.lines() { - let account = match ledger.chart().get(line.account) { - Some(a) => a, - _ => continue, - }; - let signed = match line.side { - Side::Debit => line.amount.amount, - Side::Credit => -line.amount.amount, - }; - // Normal-side positive. - let normal = match account.account_type.normal_side() { - Side::Debit => signed, - Side::Credit => -signed, - }; - let acc = by_class.entry(line.class_id).or_default(); - match account.account_type { - AccountType::Revenue => acc.revenue += normal, - AccountType::Expense => acc.expenses += normal, - _ => {} - } - } - } - // Sort: named classes by name, unclassified last. - let mut rows: Vec<(Option, Acc)> = by_class - .into_iter() - .filter(|(cid, _)| only.is_none() || *cid == only) - .map(|(cid, acc)| { - let name = cid - .and_then(|id| ledger.get_class(id)) - .map(|c| c.name.clone()); - (name, acc) - }) - // Drop rows with no P&L movement. - .filter(|(_, acc)| !acc.revenue.is_zero() || !acc.expenses.is_zero()) - .collect(); - rows.sort_by(|a, b| match (&a.0, &b.0) { - (Some(x), Some(y)) => x.cmp(y), - (Some(_), None) => std::cmp::Ordering::Less, - (None, Some(_)) => std::cmp::Ordering::Greater, - (None, None) => std::cmp::Ordering::Equal, - }); - let classes = rows - .into_iter() - .map(|(class, acc)| ClassPnlOut { - class, - revenue: money(acc.revenue), - expenses: money(acc.expenses), - net_income: money(acc.revenue - acc.expenses), - }) - .collect(); - Ok(Json(ProfitAndLossByClassOut { - start: start.to_string(), - end: end.to_string(), - classes, - })) - } - - // ------------------------------------------------------------------ - // CPA handoff package - // ------------------------------------------------------------------ - - /// ```text - /// GOAL: everything the CPA needs for the return in one zip, the K-1s' - /// and 1099s' TINs included, without a full TIN ever landing in - /// the books (a git repository) or the conversation. - /// SOURCE: docs/mcp-interface.md; B-26; DECISION (owner, - /// 2026-10-15): "it needs to be in the damn CPA package." - /// INVARIANTS: - /// 1. The zip is handed over (tools/files.rs INVARIANT 6: an export - /// the caller fetches), never written to a path; with include_tins - /// (the default) it and each file in it holding a TIN are recorded - /// (tin_files.rs), so none is filed back in. include_tins false: - /// last fours only. - /// 2. With members, k1-members.csv has one row per [[company.members]] - /// member: name, party, share, capital account, TIN and type, and - /// the K-1 worksheet's capital figures (ledger_core::k1) for the - /// fiscal year to date; k1-tax-basis-capital.csv the same capital on - /// the tax basis beside them (ledger_core::tax_capital). The 1099 - /// worksheet adds each payee's party, last four, TIN, and type. - /// 3. A locked vault or a missing TIN never fails the package: the - /// row says why (vault_tin), the README lists it, and so does - /// tin_gaps (members, and 1099 payees that are reportable). - /// 4. Read-only: nothing is journaled. - /// ``` - #[tool( - name = "books.export_cpa_package", - title = "CPA handoff package", - description = "Build the CPA handoff package as a zip: the profit and loss, balance sheet, cash flow (direct method, with its reconciliation and noncash schedules), statement of changes in members' equity (multi-member LLCs), tax summary, and trial balance, each as PDF and XLSX; the fixed asset register as PDF and XLSX when an asset was acquired by as_of; the general ledger (by account) and general journal (entry by entry) as XLSX; the journal through as_of as IIF (imports into QuickBooks Desktop; plain ASCII within its limits: warnings says what is written differently from the books, and so does the README); the 1099 worksheet as CSV (payments from January 1 through as_of); with members, the K-1 worksheet (k1-members.csv: per member their party, share, capital account, TIN, and beginning capital, contributions in cash and in property, share of income, distributions, and ending capital for the fiscal year to date) and its tax-basis capital beside the book figures (k1-tax-basis-capital.csv: Schedule K-1 Item L on the tax basis, property at its §723 basis, with the reconciliation; what only the CPA can supply, like tax depreciation, left open and named); every receipt behind the year's entries, filed on them or linked to them (receipts/, with an index.csv tying each file to its entry) and the company's documents (documents/), unless include_receipts is false; and a README with the journal's verified hash. By default (include_tins) the K-1 and 1099 worksheets carry every member's and payee's full TIN from the TIN vault; include_tins: false, last fours only. The zip is handed over: run its download command to save it in a folder. Never file it into the books. A locked vault or a missing TIN doesn't stop the package: tin_gaps lists each one (collect them with books.set_tin or books.set_w9 and export again). cash_accounts (e.g. [\"1000\"]) is required: the cash flow statement reconciles to them. as_of defaults to today; period reports cover the fiscal year to date.", - annotations( - read_only_hint = false, - destructive_hint = false, - open_world_hint = false - ) - )] - async fn export_cpa_package( - &self, - params: Parameters, - ) -> Result, String> { - let p = params.0; - let as_of = self.as_of(p.as_of.as_deref())?; - let store = self.lock()?; - let book = book_name(&store); - // The vault, opened once and never creating a key: locked, each - // TIN row says so and the package is built anyway (INVARIANT 3). - let vault = p.include_tins.then(|| self.open_vault(&store, false)); - let ledger = store.ledger(); - let tins = vault - .as_ref() - .map(|vault| move |id| vault_tin(ledger, vault, id)); - // Built from the books under the lock, written nowhere - // (ledger_package INVARIANT 1). - let package = ledger_package::build_package( - &*store, - &package_settings(&self.settings()), - &ledger_package::PackageRequest { - as_of, - prepared_on: today(), - cash_accounts: &p.cash_accounts, - financing_accounts: &p.financing_accounts, - investing_accounts: &p.investing_accounts, - include_receipts: p.include_receipts, - tins: tins.as_ref().map(|f| f as ledger_package::TinSource<'_>), - }, - )?; - let vault_dir = if p.include_tins { - self.tin_record_dir(&store)? - } else { - self.vault_dir(&store) - }; - drop(store); - - if p.include_tins { - // The zip and each file in it that holds a TIN (the K-1 and - // 1099 worksheets): one given back to be filed is refused - // (tin_files.rs INVARIANT 1). - for (name, bytes) in &package.files { - let filed = name.starts_with("receipts/") || name.starts_with("documents/"); - if !filed && ledger_core::parties::has_full_tin(&String::from_utf8_lossy(bytes)) { - tin_files::record(&vault_dir, bytes)?; - } - } - tin_files::record(&vault_dir, &package.zip)?; - } - let tin_gaps = package.tin_gaps; - let mut note = match (p.include_tins, tin_gaps.len()) { - (false, _) => "The package, with last fours only.".to_string(), - (true, 0) => "The package, with full TINs, for the CPA.".to_string(), - (true, n) => format!( - "The package, with full TINs, for the CPA. {n} TIN(s) are missing (tin_gaps): collect them with books.set_tin or books.set_w9 and export again." - ), - }; - let warnings = package.warnings; - if package.iif_changes > 0 { - note.push_str(&format!( - " {} account or class name(s), number(s), place(s) or memo(s) are written differently in general-journal.iif to fit QuickBooks Desktop (warnings says how; so does the README): tell the human.", - package.iif_changes - )); - } - let file = self.hand_over(&book, &package.file_name, "application/zip", package.zip)?; - Ok(Json(ExportCpaPackageOut { - file, - tins_included: p.include_tins, - tin_gaps, - warnings, - note, - })) - } -} - -// --------------------------------------------------------------------------- -// Export builders (called under the tool lock via &self) -// --------------------------------------------------------------------------- - -impl BooksServer { - /// The general journal as JSON or CSV: each entry posted in the - /// period (rules.md B-30, as the XLSX journal), in posting order. - fn export_journal(&self, format: &str, p: &ExportParams) -> Result { - let (start, end) = self.statement_range(p.start.as_deref(), p.end.as_deref())?; - let store = self.lock()?; - let ledger = store.ledger(); - let in_period = |e: &&ledger_core::JournalEntry| (start..=end).contains(&e.date()); - if format == "json" { - let rows: Vec = ledger - .posted_entries() - .filter(in_period) - .map(|e| { - serde_json::json!({ - "entry_id": e.id(), - "date": e.date().to_string(), - "description": e.description(), - "reversal_of": e.reversal_of(), - "lines": e.lines().iter().map(|l| { - let a = ledger.chart().get(l.account).unwrap(); - serde_json::json!({ - "account_code": a.code, - "account_name": a.name, - "side": match l.side { Side::Debit => "debit", Side::Credit => "credit" }, - "amount": money(l.amount.amount), - "currency": l.amount.currency.code(), - }) - }).collect::>(), - }) - }) - .collect(); - return serde_json::to_string_pretty(&rows).map_err(|e| e.to_string()); - } - let mut csv = String::from( - "entry_id,date,description,reversal_of,account_code,account_name,side,amount,currency\n", - ); - for e in ledger.posted_entries().filter(in_period) { - for l in e.lines() { - let a = ledger.chart().get(l.account).unwrap(); - csv.push_str(&format!( - "{},{},{},{},{},{},{},{},{}\n", - e.id(), - e.date(), - csv_escape(e.description()), - e.reversal_of().map(|v| v.to_string()).unwrap_or_default(), - a.code, - csv_escape(&a.name), - match l.side { - Side::Debit => "debit", - Side::Credit => "credit", - }, - money(l.amount.amount), - l.amount.currency.code(), - )); - } - } - Ok(csv) - } - - fn export_trial_balance(&self, format: &str, as_of: Option<&str>) -> Result { - let store = self.lock()?; - let as_of = self.as_of(as_of)?; - let tb = reports::trial_balance_as_of(store.ledger(), as_of); - if format == "json" { - return serde_json::to_string_pretty(&serde_json::json!({ - "as_of": as_of.to_string(), - "lines": tb.lines.iter().map(|l| serde_json::json!({ - "code": l.code, "name": l.name, - "debit": money(l.debit), "credit": money(l.credit), - })).collect::>(), - "total_debits": money(tb.total_debits), - "total_credits": money(tb.total_credits), - "balanced": tb.balanced(), - })) - .map_err(|e| e.to_string()); - } - let mut csv = String::from("code,name,debit,credit\n"); - for l in &tb.lines { - csv.push_str(&format!( - "{},{},{},{}\n", - l.code, - csv_escape(&l.name), - money(l.debit), - money(l.credit) - )); - } - csv.push_str(&format!( - "TOTAL,,{},{}", - money(tb.total_debits), - money(tb.total_credits) - )); - Ok(csv) - } - - /// The fixed asset register (ledger_core::fixed_asset_register) as - /// JSON or CSV, reconciled to the books. - fn export_fixed_asset_register( - &self, - format: &str, - as_of: Option<&str>, - ) -> Result { - let store = self.lock()?; - let as_of = self.as_of(as_of)?; - let r = - ledger_core::fixed_asset_register(store.ledger(), as_of).map_err(|e| e.to_string())?; - // The same words as the XLSX and PDF register. - let status = |row: &ledger_core::AssetRegisterRow| match row.disposed { - Some((d, p)) => format!("Disposed {d}, proceeds {}", money(p)), - None => "Active".to_string(), - }; - let source = |row: &ledger_core::AssetRegisterRow| { - row.basis_source.as_ref().map(|s| { - let mut t = format!("{}, {}", s.decided_by, s.decided_on); - if let Some(c) = &s.citation { - t.push_str(&format!(": {c}")); - } - t - }) - }; - if format == "json" { - return serde_json::to_string_pretty(&serde_json::json!({ - "as_of": as_of.to_string(), - "tax_figures_are": "at contribution (or purchase): the CPA adjusts them for tax depreciation, and the §704(c) gain or loss as it is reduced after (Treas. Reg. §1.704-3(a)(3)(ii))", - "assets": r.rows.iter().map(|a| serde_json::json!({ - "asset_id": a.asset_id, - "tag": a.tag, - "description": a.description, - "serial": a.serial, - "acquired_date": a.acquired_date.to_string(), - "in_service_date": a.in_service_date.to_string(), - "cost": money(a.cost), - "salvage": money(a.salvage), - "useful_life_months": a.useful_life_months, - "accumulated_scheduled": money(a.accumulated_scheduled), - "accumulated_posted": money(a.accumulated_posted), - "book_value": money(a.book_value), - "status": status(a), - "proceeds": a.disposed.map(|(_, p)| money(p)), - "basis_rule": a.basis_rule.map(|r| r.key()), - "contributor_adjusted_basis": a.contributor_adjusted_basis.map(money), - "fmv_at_contribution": a.fmv_at_contribution.map(money), - "fmv_recorded": a.fmv_recorded, - "tax_basis_723": a.tax_basis_723.map(money), - "depreciable_basis": a.depreciable_basis.map(money), - "built_in_gain_704c": a.built_in_gain_704c.map(money), - "sec704c_1c": a.sec704c_1c, - "sec704c_method": a.sec704c_method.map(|m| m.key()), - "basis_source": source(a), - })).collect::>(), - "total_cost": money(r.total_cost), - "total_accumulated_scheduled": money(r.total_accumulated_scheduled), - "total_accumulated_posted": money(r.total_accumulated_posted), - "total_book_value": money(r.total_book_value), - "total_depreciable_basis": money(r.total_depreciable_basis), - "total_tax_basis_723": money(r.total_tax_basis_723), - "missing_basis": r.missing_basis, - "ledger_cost": money(r.ledger_cost), - "ledger_accumulated": money(r.ledger_accumulated), - "unposted_depreciation": money(r.unposted_depreciation()), - "unattributed_accumulated": money(r.unattributed_accumulated()), - "unregistered_cost": money(r.unregistered_cost()), - })) - .map_err(|e| e.to_string()); - } - let mut csv = String::from( - "tag,description,serial,acquired,in_service,cost,salvage,useful_life_months,accumulated_scheduled,accumulated_posted,book_value,status,proceeds,basis_rule,contributor_adjusted_basis,fmv_at_contribution,fmv_recorded,tax_basis_723,depreciable_basis,built_in_gain_704c,sec704c_1c,sec704c_method,basis_source\n", - ); - for a in &r.rows { - let cells = [ - csv_escape(a.tag.as_deref().unwrap_or("")), - csv_escape(&a.description), - csv_escape(a.serial.as_deref().unwrap_or("")), - a.acquired_date.to_string(), - a.in_service_date.to_string(), - money(a.cost), - money(a.salvage), - a.useful_life_months.to_string(), - money(a.accumulated_scheduled), - money(a.accumulated_posted), - money(a.book_value), - status(a), - a.disposed.map(|(_, p)| money(p)).unwrap_or_default(), - a.basis_rule - .map(|r| r.key()) - .unwrap_or_default() - .to_string(), - a.contributor_adjusted_basis.map(money).unwrap_or_default(), - a.fmv_at_contribution.map(money).unwrap_or_default(), - if a.basis_rule.is_some_and(|r| r.is_contribution()) { - a.fmv_recorded.to_string() - } else { - String::new() - }, - a.tax_basis_723.map(money).unwrap_or_default(), - a.depreciable_basis.map(money).unwrap_or_default(), - a.built_in_gain_704c.map(money).unwrap_or_default(), - // IRC §704(c)(1)(C): a built-in loss counts only for the - // contributor; blank for a purchase or no basis. - a.built_in_gain_704c - .map(|g| (g < Decimal::ZERO).to_string()) - .unwrap_or_default(), - a.sec704c_method - .map(|m| m.key()) - .unwrap_or_default() - .to_string(), - csv_escape(&source(a).unwrap_or_default()), - ]; - csv.push_str(&cells.join(",")); - csv.push('\n'); - } - csv.push_str(&format!( - "TOTAL,,,,,{},,,{},{},{},,,,,,,{},{},,,,", - money(r.total_cost), - money(r.total_accumulated_scheduled), - money(r.total_accumulated_posted), - money(r.total_book_value), - money(r.total_tax_basis_723), - money(r.total_depreciable_basis) - )); - Ok(csv) - } - - fn export_general_ledger(&self, format: &str, p: &ExportParams) -> Result { - let store = self.lock()?; - let ledger = store.ledger(); - let (start, end) = self.statement_range(p.start.as_deref(), p.end.as_deref())?; - let gl = ledger_core::general_ledger(ledger, start, end).map_err(|e| e.to_string())?; - if format == "json" { - return serde_json::to_string_pretty(&serde_json::json!({ - "start": gl.start.to_string(), "end": gl.end.to_string(), - "accounts": gl.accounts.iter().map(|a| serde_json::json!({ - "code": a.code, "name": a.name, - "beginning_balance": money(a.beginning_balance), - "postings": a.postings.iter().map(|p| serde_json::json!({ - "date": p.date.to_string(), "entry_id": p.entry_id, - "description": p.description, "split": p.split, - "debit": money(p.debit), "credit": money(p.credit), - "balance": money(p.balance), - })).collect::>(), - "total_debits": money(a.total_debits), - "total_credits": money(a.total_credits), - "ending_balance": money(a.ending_balance), - })).collect::>(), - })) - .map_err(|e| e.to_string()); - } - let mut csv = String::from("account,date,entry,description,split,debit,credit,balance\n"); - for a in &gl.accounts { - let account = csv_escape(&format!("{} {}", a.code, a.name)); - csv.push_str(&format!( - "{account},,,Beginning balance,,,,{}\n", - money(a.beginning_balance) - )); - for p in &a.postings { - csv.push_str(&format!( - "{account},{},{},{},{},{},{},{}\n", - p.date, - p.entry_id, - csv_escape(&p.description), - csv_escape(&p.split), - money(p.debit), - money(p.credit), - money(p.balance) - )); - } - csv.push_str(&format!( - "{account},,,Total,,{},{},{}\n", - money(a.total_debits), - money(a.total_credits), - money(a.ending_balance) - )); - } - Ok(csv) - } - - fn export_tax_summary(&self, format: &str, p: &ExportParams) -> Result { - let store = self.lock()?; - let ledger = store.ledger(); - let (start, end) = self.statement_range(p.start.as_deref(), p.end.as_deref())?; - let ts = ledger_core::tax_summary(ledger, start, end, self.settings().company.tax_form) - .map_err(|e| e.to_string())?; - let line = |l: &ledger_core::TaxSummaryLine| { - serde_json::json!({ - "category": l.category, "label": l.label, "accounts": l.accounts, - "book": money(l.book), "tax": money(l.tax), - }) - }; - if format == "json" { - return serde_json::to_string_pretty(&serde_json::json!({ - "start": ts.start.to_string(), "end": ts.end.to_string(), - "form": ts.form.map(|f| f.label()), - "income": ts.income.iter().map(line).collect::>(), - "cost_of_goods": ts.cost_of_goods.iter().map(line).collect::>(), - "deductions": ts.deductions.iter().map(line).collect::>(), - "separately_stated": ts.separately_stated.iter().map(line).collect::>(), - // Book amounts only: Form 4797 figures the tax gain. - "form_4797": ts.form_4797.iter().map(|l| serde_json::json!({ - "category": l.category, "label": l.label, "accounts": l.accounts, - "book": money(l.book), - })).collect::>(), - "uncategorized": ts.uncategorized.iter().map(|u| serde_json::json!({ - "account": u.account, "book": money(u.book), - })).collect::>(), - "book_net": money(ts.book_net()), - "tax_net": money(ts.tax_net()), - })) - .map_err(|e| e.to_string()); - } - let mut csv = String::from("section,category,label,accounts,book,tax\n"); - for (section, lines) in [ - ("income", &ts.income), - ("cost_of_goods", &ts.cost_of_goods), - ("deductions", &ts.deductions), - ("separately_stated", &ts.separately_stated), - ] { - for l in lines { - csv.push_str(&format!( - "{section},{},{},{},{},{}\n", - l.category, - csv_escape(&l.label), - csv_escape(&l.accounts.join("; ")), - money(l.book), - money(l.tax) - )); - } - } - for l in &ts.form_4797 { - csv.push_str(&format!( - "form_4797,{},{},{},{},\n", - l.category, - csv_escape(&l.label), - csv_escape(&l.accounts.join("; ")), - money(l.book) - )); - } - for u in &ts.uncategorized { - csv.push_str(&format!( - "uncategorized,,,{},{},\n", - csv_escape(&u.account), - money(u.book) - )); - } - Ok(csv) - } - - fn export_members_equity(&self, format: &str, p: &ExportParams) -> Result { - let store = self.lock()?; - let ledger = store.ledger(); - let (start, end) = self.statement_range(p.start.as_deref(), p.end.as_deref())?; - let me = ledger_core::members_equity(ledger, start, end, &self.member_capitals(ledger)?) - .map_err(|e| e.to_string())?; - let col = |c: &ledger_core::MemberEquityColumn| { - serde_json::json!({ - "name": c.name, "beginning": money(c.beginning), - "contributions": money(c.contributions), "net_income": money(c.net_income), - "distributions": money(c.distributions), "other": money(c.other), - "ending": money(c.ending), - }) - }; - if format == "json" { - return serde_json::to_string_pretty(&serde_json::json!({ - "start": me.start.to_string(), "end": me.end.to_string(), - "members": me.members.iter().map(col).collect::>(), - "total": col(&me.total), - })) - .map_err(|e| e.to_string()); - } - let mut csv = - String::from("member,beginning,contributions,net_income,distributions,other,ending\n"); - for c in me.members.iter().chain(std::iter::once(&me.total)) { - csv.push_str(&format!( - "{},{},{},{},{},{},{}\n", - csv_escape(&c.name), - money(c.beginning), - money(c.contributions), - money(c.net_income), - money(c.distributions), - money(c.other), - money(c.ending) - )); - } - Ok(csv) - } - - fn export_pnl( - &self, - format: &str, - start: Option<&str>, - end: Option<&str>, - ) -> Result { - let store = self.lock()?; - let pl = self.pnl_statement(store.ledger(), start, end, None)?; - if format == "json" { - return serde_json::to_string_pretty(&serde_json::json!({ - "start": pl.start.to_string(), "end": pl.end.to_string(), - "revenue_accounts": lines_out(&pl.revenue), - "expense_accounts": lines_out(&pl.expenses), - "revenue": money(pl.total_revenue), "expenses": money(pl.total_expenses), - "net_income": money(pl.net_income()), - })) - .map_err(|e| e.to_string()); - } - let mut csv = String::from("section,code,name,amount\n"); - for (section, lines) in [("revenue", &pl.revenue), ("expenses", &pl.expenses)] { - for l in lines { - csv.push_str(&format!( - "{section},{},{},{}\n", - csv_escape(&l.code), - csv_escape(&l.name), - money(l.amount) - )); - } - } - csv.push_str(&format!( - "total,,revenue,{}\ntotal,,expenses,{}\ntotal,,net_income,{}\n", - money(pl.total_revenue), - money(pl.total_expenses), - money(pl.net_income()) - )); - Ok(csv) - } - - fn export_balance_sheet(&self, format: &str, as_of: Option<&str>) -> Result { - let store = self.lock()?; - let bs = self.bs_statement(store.ledger(), as_of)?; - if format == "json" { - return serde_json::to_string_pretty(&serde_json::json!({ - "as_of": bs.as_of.to_string(), - "asset_accounts": lines_out(&bs.assets), - "liability_accounts": lines_out(&bs.liabilities), - "equity_accounts": lines_out(&bs.equity), - "assets": money(bs.total_assets), "liabilities": money(bs.total_liabilities), - "equity": money(bs.total_equity), "current_earnings": money(bs.current_earnings), - "balanced": bs.balanced(), - })) - .map_err(|e| e.to_string()); - } - let mut csv = String::from("section,code,name,amount\n"); - for (section, lines) in [ - ("assets", &bs.assets), - ("liabilities", &bs.liabilities), - ("equity", &bs.equity), - ] { - for l in lines { - csv.push_str(&format!( - "{section},{},{},{}\n", - csv_escape(&l.code), - csv_escape(&l.name), - money(l.amount) - )); - } - } - csv.push_str(&format!( - "equity,,current_earnings,{}\ntotal,,assets,{}\ntotal,,liabilities,{}\ntotal,,equity,{}\n", - money(bs.current_earnings), - money(bs.total_assets), - money(bs.total_liabilities), - money(bs.total_equity) - )); - Ok(csv) - } - - fn export_cash_flow(&self, format: &str, p: &ExportParams) -> Result { - let store = self.lock()?; - let ledger = store.ledger(); - let stmt = self.cash_flow_for( - ledger, - p.start.as_deref(), - p.end.as_deref(), - &p.cash_accounts, - &p.financing_accounts, - &p.investing_accounts, - )?; - if format == "json" { - // The tool's own answer, from the one function (they can't - // drift apart). - return serde_json::to_string_pretty(&cash_flow_out(&stmt)).map_err(|e| e.to_string()); - } - Ok(cash_flow_csv(&stmt)) - } -} - -/// `books.cash_flow`'s answer, and `books.export`'s cash flow JSON. -fn cash_flow_out(stmt: &ledger_core::CashFlowStatement) -> CashFlowOut { - use ledger_core::CashFlowSection as S; - let line = |l: &ledger_core::CashFlowLine| CashFlowLineOut { - entry: l.entry, - date: l.date.to_string(), - label: l.label.clone(), - kind: l.kind.clone(), - amount: money(l.amount), - on_behalf: (!l.on_behalf.is_zero()).then(|| money(l.on_behalf)), - accounts: l.accounts.clone(), - reverses: l.reverses, - class: l.class.map(|c| c.key().to_string()), - }; - let kinds = |sec: S| { - stmt.kinds(sec) - .into_iter() - .map(|k| CashFlowKindOut { - kind: k.kind, - class: k.class.map(|c| c.key().to_string()), - amount: money(k.amount), - entries: k.entries, - }) - .collect() - }; - let r = &stmt.operating_reconciliation; - CashFlowOut { - method: "direct".to_string(), - start: stmt.start.to_string(), - end: stmt.end.to_string(), - operating_by_class: kinds(S::Operating), - operating: stmt.operating.iter().map(line).collect(), - cash_from_operations: money(stmt.cash_from_operations), - investing_by_kind: kinds(S::Investing), - investing: stmt.investing.iter().map(line).collect(), - cash_from_investing: money(stmt.cash_from_investing), - financing_by_kind: kinds(S::Financing), - financing: stmt.financing.iter().map(line).collect(), - cash_from_financing: money(stmt.cash_from_financing), - net_change_in_cash: money(stmt.net_change_in_cash), - cash_beginning: money(stmt.cash_beginning), - cash_ending: money(stmt.cash_ending), - noncash_disclosures: stmt - .noncash_disclosures - .iter() - .map(|n| NoncashDisclosureOut { - entry: n.entry, - date: n.date.to_string(), - label: n.label.clone(), - amount: money(n.amount), - operating: money(n.operating), - investing: money(n.investing), - financing: money(n.financing), - exchanged: money(n.exchanged), - reverses: n.reverses, - }) - .collect(), - operating_reconciliation: OperatingReconciliationOut { - net_income: money(r.net_income), - lines: r - .lines - .iter() - .map(|l| ReconcilingLineOut { - kind: l.kind.key().to_string(), - label: l.label.clone(), - amount: money(l.amount), - account: l.account.clone(), - entries: l.entries.clone(), - }) - .collect(), - net_cash_from_operating: money(r.net_cash_from_operating), - }, - reconciles: CashFlowFootingOut { - sections_sum: money(stmt.reconciles.sections_sum), - net_change: money(stmt.reconciles.net_change), - difference: money(stmt.reconciles.difference), - }, - warnings: stmt - .warnings - .iter() - .map(|w| CashFlowWarningOut { - entry: w.entry, - kind: w.kind.key().to_string(), - message: w.message.clone(), - }) - .collect(), - } -} - -/// `books.export`'s cash flow CSV: -/// `section,kind,class,entry,date,label,amount,on_behalf`, every line of -/// every section (`on_behalf`: of its amount, what a member or lender -/// paid or received for the company, D-42), the reconciliation, the -/// noncash disclosures, the footing and the warnings (`warning`, its -/// kind, its entry and its message; no amount). Only a section's lines -/// have an `on_behalf`. -fn cash_flow_csv(stmt: &ledger_core::CashFlowStatement) -> String { - use ledger_core::CashFlowSection as S; - let mut csv = String::from("section,kind,class,entry,date,label,amount,on_behalf\n"); - let mut row = |section: &str, - kind: &str, - class: &str, - entry: Option, - date: Option, - label: &str, - amount: rust_decimal::Decimal, - on_behalf: Option| { - csv.push_str(&format!( - "{section},{},{class},{},{},{},{},{}\n", - csv_escape(kind), - entry.map_or(String::new(), |e| e.to_string()), - date.map_or(String::new(), |d| d.to_string()), - csv_escape(label), - money(amount), - on_behalf.map_or(String::new(), money) - )); - }; - for (sec, total) in [ - (S::Operating, "Net cash from operating activities"), - (S::Investing, "Net cash from investing activities"), - (S::Financing, "Net cash from financing activities"), - ] { - for l in stmt.lines(sec) { - let class = l.class.map_or("", |c| c.key()); - row( - sec.key(), - &l.kind, - class, - Some(l.entry), - Some(l.date), - &l.label, - l.amount, - Some(l.on_behalf), - ); - } - row(sec.key(), "", "", None, None, total, stmt.total(sec), None); - } - let r = &stmt.operating_reconciliation; - row( - "reconciliation", - "", - "", - None, - None, - "Net income (loss)", - r.net_income, - None, - ); - for l in &r.lines { - row( - "reconciliation", - l.kind.key(), - "", - None, - None, - &l.label, - l.amount, - None, - ); - } - row( - "reconciliation", - "", - "", - None, - None, - "Net cash from operating activities", - r.net_cash_from_operating, - None, - ); - for n in &stmt.noncash_disclosures { - row( - "noncash", - "", - "", - Some(n.entry), - Some(n.date), - &n.label, - n.amount, - None, - ); - } - let f = &stmt.reconciles; - for (label, amount) in [ - ("Net increase (decrease) in cash", stmt.net_change_in_cash), - ("Cash at beginning of period", stmt.cash_beginning), - ("Cash at end of period", stmt.cash_ending), - ("Sections sum", f.sections_sum), - ("Difference", f.difference), - ] { - row("footing", "", "", None, None, label, amount, None); - } - // The warnings, as the tool gives them: no amount (figures are - // unaffected). - for w in &stmt.warnings { - csv.push_str(&format!( - "warning,{},,{},,{},,\n", - w.kind.key(), - w.entry, - csv_escape(&w.message) - )); - } - csv -} +// The tool groups: one file each (plan/served.md 8.2 U0a). A unit adds a +// handler only to its own group's file; the shared helpers are in +// tools/helpers.rs, re-exported below. +#[path = "tools/assets.rs"] +mod assets; +#[path = "tools/attachments.rs"] +mod attachments; +#[path = "tools/chart.rs"] +mod chart; +#[path = "tools/documents.rs"] +mod documents; +#[path = "tools/entries.rs"] +mod entries; +#[path = "tools/helpers.rs"] +mod helpers; +#[path = "tools/parties.rs"] +mod parties; +#[path = "tools/payouts.rs"] +mod payouts; +#[path = "tools/payroll.rs"] +mod payroll; +#[path = "tools/periods.rs"] +mod periods; +#[path = "tools/reconciliation.rs"] +mod reconciliation; +#[path = "tools/reports.rs"] +mod reports_tools; +#[path = "tools/schedules.rs"] +mod schedules; +#[path = "tools/settings.rs"] +mod settings_tools; +#[path = "tools/tax.rs"] +mod tax; + +// The shared helpers are re-exported so every group file reaches them +// through `use super::*`, and no group file reaches into another. +pub(crate) use helpers::*; // --------------------------------------------------------------------------- // ServerHandler @@ -10593,21 +1811,25 @@ impl BooksServer { } #[cfg(test)] -mod tests { +#[path = "server_tests.rs"] +mod tests; + +#[cfg(test)] +pub(crate) mod test_support { use super::*; use std::str::FromStr; - fn d(y: i32, m: u32, day: u32) -> NaiveDate { + pub(crate) fn d(y: i32, m: u32, day: u32) -> NaiveDate { NaiveDate::from_ymd_opt(y, m, day).unwrap() } - fn usd(s: &str) -> Money { + pub(crate) fn usd(s: &str) -> Money { Money::new(Decimal::from_str(s).unwrap(), Currency::USD) } /// A test's books' folder (`.0`) in a data root of its own, under a /// home (`.1`) removed when the test ends, pass or panic. - struct TestDir(std::path::PathBuf, std::path::PathBuf); + pub(crate) struct TestDir(pub(crate) std::path::PathBuf, pub(crate) std::path::PathBuf); impl Drop for TestDir { fn drop(&mut self) { @@ -10615,7 +1837,7 @@ mod tests { } } - fn unit_dir(tag: &str) -> TestDir { + pub(crate) fn unit_dir(tag: &str) -> TestDir { let home = std::env::temp_dir().join(format!("ledger-mcp-unit-{}-{tag}", std::process::id())); let _ = std::fs::remove_dir_all(&home); @@ -10626,64 +1848,24 @@ mod tests { } /// New books in `dir`, which outlives them. - fn store(dir: &TestDir) -> Store { + pub(crate) fn store(dir: &TestDir) -> Store { Store::create(&dir.0, Currency::USD).unwrap() } /// A session's server for the books `store` holds, made as the books /// server makes every one (Served::open): driven here without a /// transport. - fn served(store: Store) -> BooksServer { + pub(crate) fn served(store: Store) -> BooksServer { BooksServer::new(&Served::open(store).expect("the books are served")) } - /// lines_settled: posted, or a draft with lines whose debits equal its - /// credits; a draft whose sides are past exact arithmetic (one an - /// earlier version journaled, rules.md B-28) is not settled. - #[test] - fn settled_lines_are_posted_or_balanced_drafts() { - let mut l = Ledger::new(Currency::USD); - let cash = l - .add_account("1000", "Cash", ledger_core::AccountType::Asset, None, false) - .unwrap(); - let rent = l - .add_account( - "6000", - "Rent", - ledger_core::AccountType::Expense, - None, - false, - ) - .unwrap(); - let mut draft = |lines: &[(u64, Side, Decimal)]| { - let e = l.create_entry(d(2026, 3, 1), "x"); - for (a, side, amount) in lines { - l.replay_add_line(e, *a, *side, Money::new(*amount, Currency::USD)) - .unwrap(); - } - e - }; - let ten = Decimal::TEN; - let empty = draft(&[]); - let balanced = draft(&[(rent, Side::Debit, ten), (cash, Side::Credit, ten)]); - let unbalanced = draft(&[(rent, Side::Debit, ten), (cash, Side::Credit, Decimal::ONE)]); - let huge = draft(&[ - (rent, Side::Debit, Decimal::MAX), - (rent, Side::Debit, Decimal::MAX), - (cash, Side::Credit, Decimal::MAX), - (cash, Side::Credit, Decimal::MAX), - ]); - let posted = draft(&[(rent, Side::Debit, ten), (cash, Side::Credit, ten)]); - l.post_entry(posted).unwrap(); - let settled = |e: u64| lines_settled(l.get_entry(e).unwrap()); - assert!(!settled(empty)); - assert!(settled(balanced)); - assert!(!settled(unbalanced)); - assert!(!settled(huge)); - assert!(settled(posted)); - } - - fn post(s: &mut Store, date: NaiveDate, dr: AccountId, cr: AccountId, amount: &str) -> u64 { + pub(crate) fn post( + s: &mut Store, + date: NaiveDate, + dr: AccountId, + cr: AccountId, + amount: &str, + ) -> u64 { let e = s.create_entry(date, "x").unwrap(); s.add_line(e, dr, Side::Debit, usd(amount)).unwrap(); s.add_line(e, cr, Side::Credit, usd(amount)).unwrap(); @@ -10693,7 +1875,7 @@ mod tests { /// `store` with `changes` journaled, as `books.set_setting` journals /// them: a session's server reads its settings only from there. - fn settled(mut store: Store, changes: &[(&str, serde_json::Value)]) -> Store { + pub(crate) fn settled(mut store: Store, changes: &[(&str, serde_json::Value)]) -> Store { let changes: Vec<_> = changes .iter() .map(|(key, value)| ledger_core::settings::Change { @@ -10714,7 +1896,7 @@ mod tests { } /// Two members, Ada (with a distributions account) and Bo. - fn members() -> (&'static str, serde_json::Value) { + pub(crate) fn members() -> (&'static str, serde_json::Value) { ( "company.members", serde_json::json!([ @@ -10724,1846 +1906,4 @@ mod tests { ]), ) } - - /// An upload's limit, 25 MiB exactly (plan 4.7); nothing comes inline - /// (D-46). - #[test] - fn the_file_limits_are_the_plans() { - assert_eq!(ledger_store::uploads::MAX_UPLOAD, 26_214_400); - // Plan 4.7: an upload lasts 15 minutes; 16 under way per token. - assert_eq!(ledger_store::uploads::UPLOAD_TTL, 900); - assert_eq!(ledger_store::uploads::MAX_LIVE, 16); - assert_eq!(ledger_store::uploads::MAX_NAME_BYTES, 255); - assert_eq!(crate::local::MAX_BODY, 1024 * 1024); - } - - /// A period is "YYYY-MM": a year of the calendar the books keep - /// (1-9999), month 1-12, nothing more; its last day is a date, never a - /// panic (P0 review round 3, S1: "999999-01" and "262142-12" once - /// stopped the server). - #[test] - fn periods_are_a_year_and_a_month() { - assert_eq!(parse_period_mcp("2026-01"), Ok((2026, 1))); - assert_eq!(parse_period_mcp("2026-12"), Ok((2026, 12))); - assert_eq!(parse_period_mcp("0001-01"), Ok((1, 1))); - assert_eq!(parse_period_mcp("9999-12"), Ok((9999, 12))); - for bad in [ - "2026-13", - "2026-00", - "2026-01-05", - "0-05", - "2026", - "x-01", - "2026-", - "", - "10000-01", - "999999-01", - "262142-12", - "262143-01", - "-5-01", - ] { - assert!(parse_period_mcp(bad).is_err(), "{bad:?}"); - } - assert_eq!(last_day_of_month(2028, 2), Ok(d(2028, 2, 29))); - assert_eq!(last_day_of_month(9999, 12), Ok(d(9999, 12, 31))); - assert_eq!(last_day_of_month(1, 1), Ok(d(1, 1, 31))); - for (y, m) in [(262142, 12), (999999, 1), (2026, 13), (2026, 0)] { - assert!(last_day_of_month(y, m).is_err(), "{y}-{m}"); - } - } - - /// Settings naming a code are found: cash/financing/investing roles, - /// close_into, and each member's capital and distributions accounts. - #[test] - fn config_mentions_finds_every_setting_naming_a_code() { - let books = unit_dir("mentions"); - let server = served(settled( - store(&books), - &[ - members(), - ("books.cash_accounts", serde_json::json!(["1000"])), - ("books.financing_accounts", serde_json::json!(["2100"])), - ("books.investing_accounts", serde_json::json!(["1400"])), - ], - )); - assert_eq!(server.config_mentions("1000"), ["books.cash_accounts"]); - assert_eq!(server.config_mentions("2100"), ["books.financing_accounts"]); - assert_eq!(server.config_mentions("1400"), ["books.investing_accounts"]); - assert_eq!( - server.config_mentions("3000"), - ["company.members Ada capital_account"] - ); - assert_eq!( - server.config_mentions("3001"), - ["company.members Bo capital_account"] - ); - assert_eq!( - server.config_mentions("3100"), - ["company.members Ada distribution_account"] - ); - assert!(server.config_mentions("6000").is_empty()); - let books = unit_dir("mentions-solo"); - let solo = served(settled( - store(&books), - &[("books.close_into", serde_json::json!("3000"))], - )); - assert_eq!(solo.config_mentions("3000"), ["books.close_into"]); - } - - /// Reports name each member with their accounts, in order. - #[test] - fn report_members_carry_names_and_accounts() { - let books = unit_dir("members"); - let server = served(settled(store(&books), &[members()])); - let m = server.report_members(); - assert_eq!(m.len(), 2); - assert_eq!( - (m[0].name.as_str(), m[0].capital_code.as_str()), - ("Ada", "3000") - ); - assert_eq!(m[0].distribution_code.as_deref(), Some("3100")); - assert_eq!( - (m[1].name.as_str(), m[1].capital_code.as_str()), - ("Bo", "3001") - ); - assert_eq!(m[1].distribution_code, None); - } - - /// The balance sheet's year earnings: net income for the fiscal year - /// to date, and the part not yet closed into equity (closing entries - /// included, so a closed month drops out). - #[test] - fn year_earnings_split_closed_from_unclosed() { - let books = unit_dir("earnings"); - let mut s = store(&books); - let cash = s - .add_account("1000", "Cash", AccountType::Asset, None, false) - .unwrap(); - let sales = s - .add_account("4000", "Sales", AccountType::Revenue, None, false) - .unwrap(); - let rent = s - .add_account("6000", "Rent", AccountType::Expense, None, false) - .unwrap(); - post(&mut s, d(2025, 12, 15), cash, sales, "999.00"); // last fiscal year - post(&mut s, d(2026, 1, 10), cash, sales, "1000.00"); - post(&mut s, d(2026, 1, 20), rent, cash, "300.00"); - post(&mut s, d(2026, 2, 5), cash, sales, "50.00"); - post(&mut s, d(2026, 2, 6), rent, cash, "20.00"); - let server = served(s); - let as_of = d(2026, 2, 28); - { - let store = server.lock().unwrap(); - let e = server.year_earnings(store.ledger(), as_of).unwrap(); - assert_eq!(e.net_income_ytd, Decimal::from_str("730.00").unwrap()); - assert_eq!(e.unclosed, Decimal::from_str("730.00").unwrap()); - } - { - let mut store = server.lock().unwrap(); - let jan = store - .create_period("2026-01", d(2026, 1, 1), d(2026, 1, 31)) - .unwrap(); - store.close_period(jan).unwrap(); - } - let store = server.lock().unwrap(); - let e = server.year_earnings(store.ledger(), as_of).unwrap(); - assert_eq!( - e.net_income_ytd, - Decimal::from_str("730.00").unwrap(), - "still the year's income" - ); - assert_eq!( - e.unclosed, - Decimal::from_str("30.00").unwrap(), - "January's 700 is closed" - ); - } - - /// Only a closed period moves where the unclosed part starts: an open - /// one dated before as_of doesn't, and a close that ended before the - /// fiscal year began leaves the year starting on its first day (last - /// December's income is never this year's). - #[test] - fn year_earnings_count_only_closes_and_never_before_the_year() { - let books = unit_dir("earnings-open"); - let mut s = store(&books); - let cash = s - .add_account("1000", "Cash", AccountType::Asset, None, false) - .unwrap(); - let sales = s - .add_account("4000", "Sales", AccountType::Revenue, None, false) - .unwrap(); - post(&mut s, d(2025, 12, 15), cash, sales, "999.00"); - post(&mut s, d(2026, 1, 10), cash, sales, "1000.00"); - s.create_period("2026-01", d(2026, 1, 1), d(2026, 1, 31)) - .unwrap(); - let server = served(s); - let as_of = d(2026, 2, 28); - let thousand = Decimal::from_str("1000.00").unwrap(); - { - let store = server.lock().unwrap(); - let e = server.year_earnings(store.ledger(), as_of).unwrap(); - assert_eq!(e.unclosed, thousand, "an open January closes nothing"); - } - { - let mut store = server.lock().unwrap(); - let to_nov = store - .create_period("2025-01..11", d(2025, 1, 1), d(2025, 11, 30)) - .unwrap(); - store.close_period(to_nov).unwrap(); - } - let store = server.lock().unwrap(); - let e = server.year_earnings(store.ledger(), as_of).unwrap(); - assert_eq!(e.net_income_ytd, thousand); - assert_eq!(e.unclosed, thousand, "December 2025 is not this year's"); - } - - /// Unlinking a receipt is refused only from a posted, non-reversal - /// entry the policy covers (crate::policy). - #[test] - fn receipts_are_kept_only_where_the_policy_needs_them() { - let books = unit_dir("unlink"); - let mut s = store(&books); - let cash = s - .add_account("1000", "Cash", AccountType::Asset, None, false) - .unwrap(); - let rent = s - .add_account("6000", "Rent", AccountType::Expense, None, false) - .unwrap(); - let draft = s.create_entry(d(2026, 3, 1), "draft").unwrap(); - s.add_line(draft, rent, Side::Debit, usd("10.00")).unwrap(); - s.add_line(draft, cash, Side::Credit, usd("10.00")).unwrap(); - let posted = post(&mut s, d(2026, 3, 2), rent, cash, "10.00"); - let reversal = s.reverse_entry(posted, d(2026, 3, 3), "undo").unwrap(); - let server = served(settled( - s, - &[("policy.evidence.threshold", serde_json::json!("0.00"))], - )); - let store = server.lock().unwrap(); - assert!( - server.ensure_receipt_not_required(&store, draft).is_ok(), - "a draft" - ); - assert!( - server.ensure_receipt_not_required(&store, reversal).is_ok(), - "a reversal" - ); - assert!( - server.ensure_receipt_not_required(&store, posted).is_err(), - "a posted entry" - ); - } - - /// Every tool says what it is and what it does to the books (plan - /// 4.9, "Annotations (P0)"): a title and readOnlyHint on all; a - /// mutating tool also says whether it is destructive. None asks the - /// client for an interaction of its own (owner decision 2026-10-16: - /// no added prompts). - #[test] - fn annotations_complete() { - // Every tool's hints, pinned (MCP's ToolAnnotations): R read-only; - // W changes the books, additively; D destructive (posts, or - // overwrites, unlinks, voids, purges what was there: a vault - // record, a link, a reconciliation); I idempotent (a repeat with - // the same arguments leaves the journal and the vault as they - // were: spec_vault's idempotent_tools_change_nothing_when_repeated - // calls each twice). export_cpa_package writes a file, so it is - // not read-only. - type Hints = (Option, Option, Option); - const R: Hints = (Some(true), None, None); - const W: Hints = (Some(false), Some(false), None); - const D: Hints = (Some(false), Some(true), None); - const WI: Hints = (Some(false), Some(false), Some(true)); - const DI: Hints = (Some(false), Some(true), Some(true)); - let table: [(&str, Hints); 122] = [ - ("books.handbook", R), - ("books.start", R), - ("books.set_setting", DI), - ("books.export_settings", R), - ("books.conventions", R), - ("books.set_convention", W), - ("books.retire_convention", D), - ("books.record_decision", W), - ("books.decisions", R), - ("books.raise_item", W), - ("books.resolve_item", W), - ("books.open_items", R), - ("books.draft_from_template", W), - ("books.approve_entry", W), - ("books.chart", R), - ("books.add_account", W), - ("books.trial_balance", R), - ("books.profit_and_loss", R), - ("books.balance_sheet", R), - ("books.cash_flow", R), - ("books.draft_entry", W), - ("books.add_line", W), - ("books.post_entry", D), - ("books.reverse_entry", D), - ("books.attach_receipt", W), - ("books.file_document", W), - ("books.link_receipt", W), - ("books.unlink_receipt", D), - ("books.withdraw_attachment", D), - ("books.link_attachment", W), - ("books.unlink_attachment", D), - ("books.attachments", R), - ("books.list_receipts", R), - ("books.get_receipt", R), - ("books.get_entry", R), - ("books.search_entries", R), - ("books.show", R), - ("books.verify", WI), - ("books.digest", R), - ("books.import_bank_csv", W), - ("books.create_schedule", W), - ("books.list_schedules", R), - ("books.run_schedules", DI), - ("books.create_recurring", W), - ("books.list_recurring", R), - ("books.run_recurring", DI), - ("books.record_payout", W), - ("books.list_vendors", R), - ("books.set_vendor_1099", WI), - ("books.set_entry_vendor", W), - ("books.vendor_1099", W), - ("books.add_instrument", W), - ("books.instruments", R), - ("books.set_entry_instrument", WI), - ("books.chart_template", R), - ("books.tax_categories", R), - ("books.renumber_account", D), - ("books.rename_account", D), - ("books.label_asset", W), - ("books.correct_asset", D), - ("books.set_asset_tax_basis", W), - ("books.asset_labels", R), - ("books.settings", R), - ("books.roles", R), - ("books.assign_role", D), - ("books.set_tax_category", W), - ("books.import_chart_csv", W), - ("books.begin_upload", W), - ("books.export", R), - ("books.register_asset", W), - ("books.asset_schedule", R), - ("books.list_assets", R), - ("books.run_depreciation", DI), - ("books.dispose_asset", D), - ("books.create_period", W), - ("books.close_period", D), - ("books.list_periods", R), - ("books.mark_cleared", W), - ("books.reconcile", D), - ("books.record_statement", W), - ("books.statements", R), - ("books.reconciliations", R), - ("books.void_statement", D), - ("books.void_reconciliation", D), - ("books.add_customer", W), - ("books.add_vendor", W), - ("books.add_party", W), - ("books.update_party", W), - ("books.set_party_roles", WI), - ("books.set_w9", D), - ("books.set_tin", D), - ("books.get_w9", W), - ("books.purge_tin", D), - ("books.set_w4", W), - ("books.parties", R), - ("books.party", R), - ("books.set_entry_party", W), - ("books.create_invoice", D), - ("books.create_bill", D), - ("books.receive_payment", D), - ("books.pay_bill", D), - ("books.void_document", D), - ("books.control_check", R), - ("books.documents", R), - ("books.document", R), - ("books.ar_aging", R), - ("books.ap_aging", R), - ("books.set_fx_rate", W), - ("books.run_payroll", D), - ("books.remit_payroll_taxes", D), - ("books.list_payroll_runs", R), - ("books.add_class", W), - ("books.list_classes", R), - ("books.add_project", W), - ("books.update_project", WI), - ("books.projects", R), - ("books.project", R), - ("books.profit_and_loss_by_class", R), - ("books.export_cpa_package", W), - ("books.access_log", R), - ("books.backup", R), - ("books.back_up_now", WI), - ]; - let tools = BooksServer::tool_router().list_all(); - assert_eq!(tools.len(), table.len()); - let mut names: Vec<&str> = table.iter().map(|(n, _)| *n).collect(); - names.sort_unstable(); - names.dedup(); - assert_eq!(names.len(), table.len(), "a tool is in the table twice"); - for t in &tools { - let name = &t.name; - assert!( - t.title.as_deref().is_some_and(|s| !s.trim().is_empty()), - "{name} has no title" - ); - let a = t - .annotations - .as_ref() - .unwrap_or_else(|| panic!("{name} has no annotations")); - let want = table - .iter() - .find(|(n, _)| n == name) - .unwrap_or_else(|| panic!("{name} is not in the table")) - .1; - assert_eq!( - (a.read_only_hint, a.destructive_hint, a.idempotent_hint), - want, - "{name}: (readOnlyHint, destructiveHint, idempotentHint)" - ); - let json = serde_json::to_string(t).unwrap().to_lowercase(); - for word in ["requiresuserinteraction", "requires_user_interaction"] { - assert!(!json.contains(word), "{name} asks for user interaction"); - } - // The books are a closed world: no tool reaches beyond them, - // but the one that pushes a backup to the owner's targets. - assert_eq!( - a.open_world_hint, - Some(*name == "books.back_up_now"), - "{name}: openWorldHint" - ); - } - // docs/mcp-interface.md names the 29 destructive tools. - assert_eq!(table.iter().filter(|(_, h)| h.1 == Some(true)).count(), 29); - } - - /// Every refusal an agent can be given whose text is fixed here, or - /// in the crates under the server: the error templates of every - /// variant (scanned from their sources, so a new variant is linted - /// too), representative values of those whose text is built, and the - /// server's own refusal constants. - fn agent_refusals() -> Vec<(String, String)> { - let mut out: Vec<(String, String)> = Vec::new(); - for (krate, src) in [ - ( - "ledger-core", - include_str!("../../ledger-core/src/error.rs"), - ), - ( - "ledger-store", - include_str!("../../ledger-store/src/error.rs"), - ), - ( - "ledger-vault", - include_str!("../../ledger-vault/src/lib.rs"), - ), - ( - "ledger-import bank", - include_str!("../../ledger-import/src/bank_csv.rs"), - ), - ( - "ledger-import chart", - include_str!("../../ledger-import/src/chart_csv.rs"), - ), - ( - "ledger-import iif", - include_str!("../../ledger-import/src/iif.rs"), - ), - ( - "ledger-import pdf", - include_str!("../../ledger-import/src/pdf.rs"), - ), - ( - "ledger-import xlsx", - include_str!("../../ledger-import/src/xlsx.rs"), - ), - ( - "ledger-store uploads", - include_str!("../../ledger-store/src/uploads.rs"), - ), - ] { - let mut rest = src; - let mut n = 0; - while let Some(at) = rest.find("#[error(") { - rest = &rest[at + 8..]; - let end = rest.find(")]").expect("an #[error] attribute ends"); - out.push((format!("{krate} error template"), rest[..end].to_string())); - rest = &rest[end..]; - n += 1; - } - assert!(n > 0, "{krate}: no error templates found"); - } - // The files and backup tools' refusals (and descriptions), and the - // backups' own words: their sentences. (What git itself says of a - // failed backup reaches the human as git said it: not ours.) - for (name, src) in [ - ("tools/files.rs", include_str!("tools/files.rs")), - ("tools/backup.rs", include_str!("tools/backup.rs")), - ("backup.rs", include_str!("backup.rs")), - ] { - let src = src.split("#[cfg(test)]").next().unwrap_or(src); - for line in src.lines() { - let line = line.trim_start(); - if line.starts_with("//") { - continue; - } - for (i, text) in line.split('"').enumerate() { - if i % 2 == 1 && text.len() > 30 && text.contains(' ') { - out.push((format!("{name} text"), text.to_string())); - } - } - } - } - let newer = |verified| ledger_store::StoreError::NewerJournal { - seq: 7, - event_type: "settings.set".into(), - need: "journal format 2".into(), - verified, - }; - out.push(("NewerJournal".into(), newer(true).to_string())); - out.push(("NewerJournal unverified".into(), newer(false).to_string())); - out.push(( - "FormatUpgradePending".into(), - ledger_store::StoreError::FormatUpgradePending { - need: "journal format 2".into(), - } - .to_string(), - )); - out.push(( - "RuleNotKnown".into(), - ledger_core::form1099::default_threshold(9999) - .unwrap_err() - .to_string(), - )); - for (what, text) in [ - ("STOPPED", STOPPED), - ("tin_files::TIN_FILE", tin_files::TIN_FILE), - ("tin_files::TIN_HELD", tin_files::TIN_HELD), - ("tin_files::TIN_PROBE", tin_files::TIN_PROBE), - ("tin_files::TIN_PRINTED", tin_files::TIN_PRINTED), - ] { - out.push((what.to_string(), text.to_string())); - } - // Refusals and notes built inline, where the books' own record is - // kept: every string literal of these sources outside their tests. - for (what, src) in [ - ("tools/kept.rs", include_str!("tools/kept.rs")), - ( - "ledger-config codec", - include_str!("../../ledger-config/src/codec.rs"), - ), - ( - "ledger-core settings", - include_str!("../../ledger-core/src/settings.rs"), - ), - ( - "ledger-core conventions", - include_str!("../../ledger-core/src/conventions.rs"), - ), - ( - "ledger-core ledger", - include_str!("../../ledger-core/src/ledger.rs"), - ), - ("settings.rs", include_str!("settings.rs")), - ] { - let code = src.split("#[cfg(test)]").next().unwrap_or(src); - let literals = string_literals(code); - assert!(!literals.is_empty(), "{what}: no literals found"); - out.extend(literals.into_iter().map(|l| (format!("{what} literal"), l))); - } - out - } - - /// The text of every plain string literal ("...") in Rust source, - /// skipping comments (a rough scanner: enough for the lint). - fn string_literals(src: &str) -> Vec { - let mut out = Vec::new(); - for line in src.lines() { - let line = line.trim_start(); - if line.starts_with("//") { - continue; - } - let mut chars = line.chars().peekable(); - let mut cur: Option = None; - while let Some(c) = chars.next() { - match (&mut cur, c) { - (None, '"') => cur = Some(String::new()), - (None, '/') if chars.peek() == Some(&'/') => break, - (Some(s), '\\') => { - if let Some(n) = chars.next() { - s.push('\\'); - s.push(n); - } - } - (Some(s), '"') => { - out.push(std::mem::take(s)); - cur = None; - } - (Some(s), c) => s.push(c), - (None, _) => {} - } - } - } - out - } - - /// No text an agent is given sends it to the books' files or to tools - /// outside these (plan I-12): no helper commands, home paths, data - /// dir names or flags, file names of the books, git, GitHub, pull - /// requests, repo docs, the old company's examples, or suggestions to - /// read files. Linted whole: every tool's JSON (title, description, - /// input and output schemas), every resource, the connect text, every - /// handbook section, and every refusal fixed in code - /// ([`agent_refusals`]). (No resource templates are advertised yet.) - /// What `tool_text_lint` finds in one text beyond its fixed list: a - /// helper command however it ends (`just` as a word before a - /// backtick, a line break, a tab or the end), and where the books are - /// ("beside the books", "books dir"). - fn helper_or_location(what: &str, text: &str) -> Vec { - let lower = text.to_lowercase(); - let mut found = Vec::new(); - for (at, _) in lower.match_indices("just") { - let prev = lower[..at].chars().next_back(); - let next = lower[at + 4..].chars().next(); - let word_start = !prev.is_some_and(|c| c.is_alphanumeric() || c == '_'); - if word_start && matches!(next, None | Some('`' | '\n' | '\r' | '\t')) { - found.push(format!("{what} names a helper command (`just`)")); - } - } - if lower.contains("beside the books") { - found.push(format!( - "{what} says where the books are: \"beside the books\"" - )); - } - if lower.contains("books dir") { - found.push(format!("{what} says where the books are: \"books dir\"")); - } - found - } - - /// The lint's own checks catch what they name, and pass ordinary words. - #[test] - fn tool_text_lint_catches_helpers_and_locations() { - for (text, n) in [ - ("run `just`", 1), - ("run just\nthen", 1), - ("run just\tgui", 1), - ("run just", 1), - ("just`gui`", 1), - ("Just\n", 1), - ("adjust`", 0), - ("just after posting", 0), - ("just, then", 0), - ("justly", 0), - ("the file beside the books", 1), - ("in the books dir", 1), - ("a path outside the books dir", 1), - ("the books directory", 1), - ("bookshelf", 0), - ] { - assert_eq!(helper_or_location("text", text).len(), n, "{text:?}"); - } - assert_eq!( - helper_or_location("tool books.get_w9", "outside the books dir").len(), - 1 - ); - assert_eq!( - helper_or_location("tool books.chart", "outside the books dir").len(), - 1 - ); - } - - /// No tool takes a path on the server's computer (plan 4.7, D-28, - /// D-29, D-46): files come in by upload, and go out as exports. - #[test] - fn no_tool_takes_a_path() { - fn names(v: &serde_json::Value, out: &mut Vec) { - match v { - serde_json::Value::Object(map) => { - if let Some(serde_json::Value::Object(props)) = map.get("properties") { - out.extend(props.keys().cloned()); - } - map.values().for_each(|v| names(v, out)); - } - serde_json::Value::Array(items) => items.iter().for_each(|v| names(v, out)), - _ => {} - } - } - let mut found = Vec::new(); - for t in BooksServer::tool_router().list_all() { - let mut props = Vec::new(); - names( - &serde_json::Value::Object((*t.input_schema).clone()), - &mut props, - ); - for p in props { - let lower = p.to_ascii_lowercase(); - // Names that hold a place to read or write: whole words, - // so `link_to`, `*_file_name`, `tin_on_file` and `date_to` - // pass. - let words: Vec<&str> = lower.split('_').collect(); - let pathlike = lower.starts_with("save") - || lower.contains("path") - || [ - "dir", - "folder", - "root", - "dest", - "destination", - "output", - "location", - "local", - ] - .iter() - .any(|w| words.contains(w)) - || (words.last() == Some(&"file") && !lower.ends_with("on_file")) - || lower == "to" - || lower == "from" - || lower == "source"; - if pathlike { - found.push(format!("{} {p}", t.name)); - } - } - } - found.sort(); - found.dedup(); - assert!(found.is_empty(), "path-like parameters: {found:?}"); - } - - #[test] - fn tool_text_lint() { - const NEVER: &[&str] = &[ - "just ", - "~/", - "/home", - ".config/wanderbooks", - "books-data/", - "data dir", - "--data-dir", - "--import-root", - "vault.key", - "journal.jsonl", - ".jsonl", - "sqlite", - ".gitignore", - "github", - "pull request", - "docs/", - ".md", - "read the journal", - "the journal is the evidence", - "read the file", - "open the file", - "cat ", - // Gone in P2 slice 5: files come by upload. - "import_local_file", - "w9_file_path", - "import folder", - "import root", - "put_url", - "human_url", - "fl-0001", - // Gone with D-46: a file never passes through the agent. - "content_base64", - "csv_text", - "base64", - "256 kib", - ]; - // Whole words only: "digit" and "committee" are fine. - const NEVER_WORDS: &[&str] = &["git", "commit"]; - let mut texts: Vec<(String, String)> = Vec::new(); - for t in BooksServer::tool_router().list_all() { - texts.push(( - format!("tool {}", t.name), - serde_json::to_string(&t).unwrap(), - )); - } - for r in resource_list() { - texts.push(( - format!("resource {}", r.uri), - serde_json::to_string(&r).unwrap(), - )); - } - texts.extend(agent_refusals()); - for threshold in ["0.00", "25.00"] { - let settings = Settings { - policy: ReceiptPolicy { - require_receipts_above: threshold.into(), - ..ReceiptPolicy::default() - }, - ..Settings::default() - }; - texts.push(( - "connect text".into(), - handbook::connect_text(&settings, "USD"), - )); - for (id, _) in handbook::SECTIONS { - for declared in [true, false] { - texts.push(( - format!("handbook {id}"), - handbook::section(id, &settings, "USD", declared).unwrap(), - )); - } - } - } - // Every tool the text names is one there is. - let tools: std::collections::HashSet = BooksServer::tool_router() - .list_all() - .into_iter() - .map(|t| t.name.to_string()) - .collect(); - let mut unknown: Vec = Vec::new(); - // Settings keys (`books.cash_accounts`) are named like tools: a - // name that is one is a setting, not a tool. - let keys: std::collections::HashSet<&str> = - ledger_core::settings::KEYS.iter().map(|k| k.name).collect(); - for (what, text) in texts - .iter() - .filter(|(w, _)| !w.contains("settings literal")) - { - for (i, _) in text.match_indices("books.") { - let name: String = text[i..] - .chars() - .take_while(|c| { - c.is_ascii_lowercase() || c.is_ascii_digit() || *c == '_' || *c == '.' - }) - .collect(); - let name = name.trim_end_matches('.'); - // "the books." ends a sentence. - if name == "books" { - continue; - } - let before = text[..i].chars().next_back(); - if before.is_some_and(|c| c.is_alphanumeric() || c == '_' || c == '/') { - continue; - } - if !tools.contains(name) && !keys.contains(name) { - unknown.push(format!("{what}: {name}")); - } - } - } - unknown.sort(); - unknown.dedup(); - assert!( - unknown.is_empty(), - "text names tools there aren't: {unknown:?}" - ); - let words = |text: &str| { - text.split(|c: char| !c.is_alphanumeric() && c != '_') - .map(str::to_string) - .collect::>() - }; - let mut found: Vec = Vec::new(); - for (what, text) in &texts { - // A handbook resource's own address is not a file name. - let lower = text.to_lowercase().replace("books://handbook", ""); - for bad in NEVER { - if let Some(at) = lower.find(bad) { - let from = lower[..at] - .char_indices() - .rev() - .nth(40) - .map_or(0, |(i, _)| i); - let near: String = lower[from..].chars().take(100).collect(); - found.push(format!("{what} contains {bad:?}: ...{near}...")); - } - } - let w = words(&lower); - for bad in NEVER_WORDS { - if w.iter().any(|x| x == bad) { - found.push(format!("{what} says {bad:?}")); - } - } - } - for (what, text) in &texts { - found.extend(helper_or_location(what, text)); - } - assert!(found.is_empty(), "{}", found.join("\n")); - // The settings file may be named in schemas (its fields are edited - // there until settings move into the books, P1), but no tool's - // title or description, no refusal, the connect text and no - // handbook section names it: it is the file books.settings names. - let mut prose: Vec<(String, String)> = BooksServer::tool_router() - .list_all() - .iter() - .map(|t| { - ( - format!("tool {}", t.name), - format!( - "{} {}", - t.title.as_deref().unwrap_or_default(), - t.description.as_deref().unwrap_or_default() - ), - ) - }) - .collect(); - prose.extend( - texts - .into_iter() - .filter(|(what, _)| !what.starts_with("tool ")), - ); - for (what, text) in &prose { - assert!( - !text.to_lowercase().contains("wanderbooks.toml"), - "{what} names the settings file: {text}" - ); - } - } - - /// Tool descriptions are at most 700 characters (plan I-12). The - /// ones over it today are listed, each to be shortened in P5 when its - /// text moves into the handbook; no new one may join them, and one - /// that shrinks under the cap must leave the list. - #[test] - fn tool_descriptions_fit_700_characters() { - // Shorten in P5. - const OVER: &[&str] = &[ - "books.export", - "books.export_cpa_package", - "books.import_bank_csv", - "books.reconcile", - "books.set_entry_party", - "books.set_w9", - "books.show", - "books.vendor_1099", - ]; - let mut over: Vec = BooksServer::tool_router() - .list_all() - .iter() - .filter(|t| t.description.as_deref().unwrap_or_default().chars().count() > 700) - .map(|t| t.name.to_string()) - .collect(); - over.sort(); - assert_eq!(over, OVER, "tool descriptions over 700 characters"); - } - - /// The tools docs/mcp-interface.md documents: a line (or a bullet) - /// that starts with `` `books.NAME` `` or `` `books.NAME { `` , and - /// any `` `books.NAME { `` signature within a line. - fn documented_tools(doc: &str) -> std::collections::BTreeSet { - let name_at = |s: &str| -> Option<(String, char)> { - let rest = s.strip_prefix("`books.")?; - let name: String = rest - .chars() - .take_while(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || *c == '_') - .collect(); - let next = rest[name.len()..].chars().next()?; - (!name.is_empty()).then(|| (format!("books.{name}"), next)) - }; - let mut out = std::collections::BTreeSet::new(); - for line in doc.lines() { - let t = line.trim_start(); - let t = t.strip_prefix("- ").unwrap_or(t); - if let Some((name, next)) = name_at(t) - && (next == '`' || next == ' ') - { - out.insert(name); - } - for (i, _) in line.match_indices("`books.") { - if let Some((name, ' ')) = name_at(&line[i..]) - && line[i + 1 + name.len()..].starts_with(" {") - { - out.insert(name); - } - } - } - out - } - - /// Every tool the server has is documented in docs/mcp-interface.md, - /// and every tool the doc documents is one the server has. - #[test] - fn every_tool_is_documented_and_every_documented_tool_exists() { - let doc = include_str!("../../../docs/mcp-interface.md"); - let documented = documented_tools(doc); - let tools: std::collections::BTreeSet = BooksServer::tool_router() - .list_all() - .iter() - .map(|t| t.name.to_string()) - .collect(); - let undocumented: Vec<&String> = tools.difference(&documented).collect(); - let unknown: Vec<&String> = documented.difference(&tools).collect(); - assert!( - undocumented.is_empty(), - "tools docs/mcp-interface.md doesn't document: {undocumented:?}" - ); - assert!( - unknown.is_empty(), - "docs/mcp-interface.md documents tools there aren't: {unknown:?}" - ); - // The reader catches what it should, and only that. - let sample = "- `books.chart` → x\n `books.decisions { a? }`; \ - `books.open_items { b? }`.\nsee `books.cash_accounts`, \ - `books.show`."; - let got: Vec = documented_tools(sample).into_iter().collect(); - assert_eq!(got, ["books.chart", "books.decisions", "books.open_items"]); - } - - /// rules.md B-30 (defect 16): every report tool says what its omitted - /// dates mean in the one sentence the resolver's kind gives - /// (PeriodKind::describe), and each date parameter in the schema's - /// own words (START_DOC, END_DOC, AS_OF_DOC, YEAR_DOC); the two tools - /// over the description budget (books.export, books.show) and the CPA - /// package say it in their parameters. No tool or schema says "all - /// time", "beginning of time" or "first entry" any more. The resources - /// read with dates omitted say which period they cover. - #[test] - fn period_tools_describe_the_one_default() { - use ledger_core::calendar::{AS_OF_DOC, END_DOC, PeriodKind, START_DOC, YEAR_DOC}; - let tools = BooksServer::tool_router().list_all(); - let tool = |name: &str| { - tools - .iter() - .find(|t| t.name == name) - .unwrap_or_else(|| panic!("no tool {name}")) - }; - let field = |name: &str, f: &str| -> String { - tool(name).input_schema["properties"][f]["description"] - .as_str() - .unwrap_or_else(|| panic!("{name}.{f} has no description")) - .to_string() - }; - for (name, kind, fields) in [ - ( - "books.profit_and_loss", - PeriodKind::Statement, - &[("start", START_DOC), ("end", END_DOC)][..], - ), - ( - "books.profit_and_loss_by_class", - PeriodKind::Statement, - &[("start", START_DOC), ("end", END_DOC)][..], - ), - ( - "books.project", - PeriodKind::Statement, - &[("start", START_DOC), ("end", END_DOC)][..], - ), - ( - "books.cash_flow", - PeriodKind::Statement, - &[("start", START_DOC), ("end", END_DOC)][..], - ), - ( - "books.trial_balance", - PeriodKind::AsOf, - &[("as_of", AS_OF_DOC)][..], - ), - ( - "books.balance_sheet", - PeriodKind::AsOf, - &[("as_of", AS_OF_DOC)][..], - ), - ( - "books.control_check", - PeriodKind::AsOf, - &[("as_of", AS_OF_DOC)][..], - ), - ( - "books.documents", - PeriodKind::AsOf, - &[("as_of", AS_OF_DOC)][..], - ), - ( - "books.document", - PeriodKind::AsOf, - &[("as_of", AS_OF_DOC)][..], - ), - ( - "books.ar_aging", - PeriodKind::AsOf, - &[("as_of", AS_OF_DOC)][..], - ), - ( - "books.ap_aging", - PeriodKind::AsOf, - &[("as_of", AS_OF_DOC)][..], - ), - ( - "books.parties", - PeriodKind::CalendarYear, - &[("year", YEAR_DOC)][..], - ), - ( - "books.party", - PeriodKind::CalendarYear, - &[("year", YEAR_DOC)][..], - ), - ] { - let d = tool(name).description.as_deref().unwrap_or_default(); - assert!(d.ends_with(kind.describe()), "{name}: {d}"); - for (f, doc) in fields { - assert_eq!(field(name, f), *doc, "{name}.{f}"); - } - } - // Over the budget, or the package: in the parameters' words. - for (name, f, doc) in [ - ("books.export", "start", START_DOC), - ("books.export", "end", END_DOC), - ("books.export", "as_of", AS_OF_DOC), - ("books.show", "start", START_DOC), - ("books.show", "end", END_DOC), - ("books.export_cpa_package", "as_of", AS_OF_DOC), - ] { - assert!( - field(name, f).ends_with(doc), - "{name}.{f}: {}", - field(name, f) - ); - } - assert!( - tool("books.export") - .description - .as_deref() - .unwrap() - .contains("omitted: the fiscal year to date") - ); - // Nothing left of the old defaults, anywhere. - for t in tools.iter() { - let text = format!( - "{} {}", - t.description.as_deref().unwrap_or_default(), - serde_json::Value::Object((*t.input_schema).clone()) - ) - .to_lowercase(); - for stale in [ - "all time", - "beginning of time", - "first entry", - "first posted entry", - ] { - assert!(!text.contains(stale), "{} says {stale:?}", t.name); - } - } - let resources = resource_list(); - let resource = |uri: &str| { - resources - .iter() - .find(|r| r.uri == uri) - .and_then(|r| r.description.clone()) - .unwrap() - }; - assert!(resource("books://reports/profit-and-loss").contains("fiscal year to date")); - assert!(resource("books://reports/trial-balance").contains("as of today")); - assert!(resource("books://reports/balance-sheet").contains("as of today")); - } - - /// books.show (defect 15): how the viewer reads each report's dates, - /// and the options a report ignores, refused (one match, so each arm - /// is pinned). - #[test] - fn show_reads_each_reports_dates_as_the_viewer_does() { - for (report, columns, compare, want) in [ - ("profit_and_loss", None, None, Ok(ShownDates::Period)), - ("cash_flow", Some("month"), None, Ok(ShownDates::Period)), - ("members_equity", None, None, Ok(ShownDates::Period)), - ("general_ledger", None, None, Ok(ShownDates::Period)), - ( - "tax_summary", - None, - Some("prior_year"), - Ok(ShownDates::Period), - ), - ("trial_balance", None, None, Ok(ShownDates::AsOf)), - ("ar_aging", None, None, Ok(ShownDates::AsOf)), - ("ap_aging", None, None, Ok(ShownDates::AsOf)), - ("fixed_asset_register", None, None, Ok(ShownDates::AsOf)), - ("vendor_1099", None, None, Ok(ShownDates::Year)), - ("balance_sheet", None, None, Ok(ShownDates::AsOf)), - ("balance_sheet", Some("total"), None, Ok(ShownDates::AsOf)), - ( - "balance_sheet", - None, - Some("prior_year"), - Ok(ShownDates::AsOf), - ), - ( - "balance_sheet", - None, - Some("prior_period"), - Ok(ShownDates::Period), - ), - ( - "balance_sheet", - Some("quarter"), - None, - Ok(ShownDates::Period), - ), - ("trial_balance", Some("total"), None, Err("columns")), - ("tax_summary", Some("month"), None, Err("columns")), - ("vendor_1099", None, Some("prior_year"), Err("compare")), - ("general_ledger", None, Some("prior_period"), Err("compare")), - ( - "profit_and_loss", - Some("year"), - Some("prior_year"), - Err("split"), - ), - ] { - let got = show_report_dates(report, columns, compare); - match want { - Ok(dates) => assert_eq!(got, Ok(dates), "{report} {columns:?} {compare:?}"), - Err(word) => { - let e = got.unwrap_err(); - assert!(e.contains(word), "{report} {columns:?} {compare:?}: {e}"); - } - } - } - } - - /// How many masked TINs a text holds. - fn data_masked(text: &str) -> usize { - text.matches("•••-••-••••").count() + text.matches("•••••••••").count() - } - - /// redact_error (B-26): every full TIN in an error's message and data - /// is masked, wherever the data holds it (a string, in an array, in a - /// nested object), and what holds none is left as it was. - #[test] - fn redact_error_masks_every_tin_in_the_message_and_data() { - let tin = "291-12-2846"; - let e = redact_error(rmcp::ErrorData::resource_not_found( - format!("books://receipts/{tin}"), - Some(serde_json::json!({ - "reason": format!("bad receipt resource URI: {tin}"), - "list": ["plain", tin, ["deeper 291122846"]], - "nested": {"uri": tin, "n": 5, "fine": "no tin here 2026-10-16"}, - })), - )); - let text = format!("{} {}", e.message, e.data.as_ref().unwrap()); - assert!(!text.contains(tin) && !text.contains("291122846"), "{text}"); - assert_eq!(data_masked(&text), 5, "every TIN masked: {text}"); - let data = e.data.unwrap(); - assert_eq!(data["list"][0], "plain"); - assert_eq!(data["nested"]["n"], 5); - assert_eq!(data["nested"]["fine"], "no tin here 2026-10-16"); - } - - /// Fail closed (SharedStore INVARIANTS 1-2): a request that panics, - /// with the store held or not, stops the server, and every later - /// request, tool or lock, is refused with the plain STOPPED message, - /// never the panic's text (which could carry anything, a TIN - /// included). - #[test] - fn a_request_that_panics_stops_the_server() { - let rt = tokio::runtime::Builder::new_multi_thread() - .enable_all() - .build() - .unwrap(); - type Body = fn(BooksServer) -> Result; - async fn run( - server: &BooksServer, - body: Body, - ) -> Result, rmcp::ErrorData> { - let s = server.clone(); - run_blocking(&server.store, move || Box::pin(async move { body(s) })).await - } - let bodies: [(&str, Body); 2] = [ - ("holding the store", |s| { - let _store = s.lock()?; - panic!("mid-mutation 123-45-6789"); - }), - ("without the store", |_| { - panic!("before the lock 123-45-6789") - }), - ]; - for (how, body) in bodies { - let books = unit_dir("stops"); - let parts = Served::open(store(&books)).unwrap(); - let server = BooksServer::new(&parts); - rt.block_on(async { - let seq: Body = |s| s.lock().map(|st| st.journal_seq()); - // New books: store.created, then their journal format. - assert_eq!(run(&server, seq).await.unwrap(), Ok(2), "{how}"); - let died = run(&server, body).await.unwrap_err(); - assert_eq!(died.message, STOPPED, "{how}"); - // Every later request is refused, before it runs. - let after = run(&server, seq).await.unwrap_err(); - assert_eq!(after.message, STOPPED, "{how}"); - // So is any other session with the books. - let other = BooksServer::new(&parts); - assert_eq!(run(&other, seq).await.unwrap_err().message, STOPPED); - }); - // And the lock itself, from any thread. - let s = server.clone(); - let locked = std::thread::spawn(move || s.lock().map(|_| ())) - .join() - .unwrap(); - assert_eq!(locked, Err(STOPPED.to_string()), "{how}"); - } - - // A guard dropped by a panic on a thread of its own stops it too. - let books = unit_dir("stops-thread"); - let server = served(store(&books)); - let s = server.clone(); - let died = std::thread::spawn(move || { - let _store = s.lock().unwrap(); - panic!("mid-mutation"); - }) - .join(); - assert!(died.is_err()); - assert_eq!(server.lock().map(|_| ()), Err(STOPPED.to_string())); - } - - /// run_blocking drives a request to completion even when it waits: a - /// future that is pending until another thread wakes it completes, - /// with its own result (the waker unparks the request's thread). - #[test] - fn a_request_that_waits_is_woken_and_completes() { - /// Pending on its first poll, woken from another thread shortly - /// after; ready on the next. - struct WakeLater(bool); - impl std::future::Future for WakeLater { - type Output = u64; - fn poll( - mut self: std::pin::Pin<&mut Self>, - cx: &mut std::task::Context<'_>, - ) -> std::task::Poll { - if self.0 { - return std::task::Poll::Ready(7); - } - self.0 = true; - let waker = cx.waker().clone(); - std::thread::spawn(move || { - std::thread::sleep(std::time::Duration::from_millis(20)); - waker.wake(); - }); - std::task::Poll::Pending - } - } - let rt = tokio::runtime::Builder::new_multi_thread() - .enable_all() - .build() - .unwrap(); - let books = unit_dir("wakes"); - let shared = SharedStore::new(store(&books)); - let got = rt.block_on(async { - tokio::time::timeout( - std::time::Duration::from_secs(10), - run_blocking(&shared, || Box::pin(WakeLater(false))), - ) - .await - }); - // A request never woken would hold its thread: don't wait for it. - rt.shutdown_background(); - assert_eq!(got.ok().and_then(Result::ok), Some(7), "never woken"); - } - - /// A client that connects after the settings changed is told the rule - /// now in force (plan P0): the connect text is composed from the - /// books' settings as they are at initialize, not as they were when - /// the server started. - #[test] - fn a_new_session_is_told_the_settings_in_force() { - let books = unit_dir("info"); - let server = served(store(&books)); - let told = |s: &BooksServer| rmcp::ServerHandler::get_info(s).instructions.unwrap(); - let before = told(&server); - assert!(before.contains("more than 25.00 USD in debits"), "{before}"); - { - let mut store = server.lock().unwrap(); - store - .change_settings( - &[ledger_core::settings::Change { - key: "policy.evidence.threshold".into(), - value: Some(serde_json::json!("0.00")), - }], - &ledger_core::settings::Source { - decided_by: "Test Owner".into(), - decided_on: d(2026, 10, 23), - citation: None, - note: None, - }, - ) - .unwrap(); - } - let after = told(&server); - assert!( - after.contains("every entry needs at least one receipt"), - "{after}" - ); - assert!(!after.contains("25.00"), "{after}"); - } - - /// A bare books://digest, or one with an empty since=, covers this - /// month so far: since is the 1st of today's month, on any day of it - /// (not today, which only agrees on the 1st). A given since is kept. - #[test] - fn a_bare_digest_covers_this_month_so_far() { - for (day, want) in [(16, "2026-10-01"), (1, "2026-10-01"), (31, "2026-10-01")] { - let t = d(2026, 10, day); - for q in ["", "since=", "since=%20", "x=1", "x=1&since="] { - let q = q.replace("%20", " "); - assert_eq!(digest_since(&q, t), want, "{q:?} on {t}"); - } - } - assert_eq!( - digest_since("since=2026-01-05", d(2026, 10, 16)), - "2026-01-05" - ); - assert_eq!( - digest_since("x=1&since=2026-01-05", d(2026, 10, 16)), - "2026-01-05" - ); - assert_eq!(digest_since("", d(2027, 1, 9)), "2027-01-01"); - } - - // Calls through the access door (access.rs), driven straight into the - // handler with a context the HTTP door would have left: its Caller, - // or none. - - /// A peer to make request contexts with: a client and a server over - /// an in-memory pipe, kept alive while it is. - struct Session { - peer: rmcp::service::Peer, - _server: rmcp::service::RunningService, - _client: rmcp::service::RunningService, - _books: TestDir, - } - - async fn session(tag: &str) -> Session { - use rmcp::ServiceExt; - let books = unit_dir(&format!("{tag}-peer")); - let server = served(store(&books)); - let (a, b) = tokio::io::duplex(1 << 16); - let served = tokio::spawn(async move { server.serve(a).await }); - let client = ().serve(b).await.unwrap(); - let server = served.await.unwrap().unwrap(); - Session { - peer: server.peer().clone(), - _server: server, - _client: client, - _books: books, - } - } - - /// A request's context: from a token with `scopes` (the door's - /// Caller), or with none. - fn context( - peer: &rmcp::service::Peer, - scopes: Option<&[ledger_store::auth::Scope]>, - ) -> RequestContext { - let mut cx = RequestContext::new(rmcp::model::NumberOrString::Number(7), peer.clone()); - if let Some(scopes) = scopes { - let token = ledger_store::auth::Token { - id: "tok_0123456789ab".into(), - book: "acme".into(), - name: "test".into(), - scopes: scopes.iter().copied().collect(), - created: "2026-10-20T00:00:00Z".into(), - sha256: String::new(), - }; - let (mut parts, ()) = axum::http::Request::new(()).into_parts(); - parts.extensions.insert(crate::access::Caller { token }); - cx.extensions.insert(parts); - } - cx - } - - const ALL: &[ledger_store::auth::Scope] = &[ - ledger_store::auth::Scope::Read, - ledger_store::auth::Scope::Write, - ledger_store::auth::Scope::Post, - ledger_store::auth::Scope::Admin, - ]; - - /// Books as the books server serves them: calls are checked against - /// their caller's token and recorded in the access log beside them. - fn logged(tag: &str) -> (BooksServer, TestDir) { - let books = unit_dir(tag); - let server = served(store(&books)); - (server, books) - } - - /// Books whose access log can't be written: its file is a FIFO no one - /// reads, so every record fails (as on a full disk). - fn unwritable(tag: &str) -> (BooksServer, TestDir) { - let books = unit_dir(tag); - let store = store(&books); - let fifo = books.0.join(ledger_store::access::ACCESS_FILE); - let made = std::process::Command::new("mkfifo") - .arg(&fifo) - .status() - .unwrap(); - assert!(made.success()); - let path = fifo.clone(); - let other_end = std::thread::spawn(move || { - // The log reads the file whole as it opens (empty: a writer - // that goes at once), then opens it to append (a reader that - // goes at once): nothing reads what is written after. - drop(std::fs::OpenOptions::new().write(true).open(&path).unwrap()); - drop(std::fs::File::open(&path).unwrap()); - }); - let server = served(store); - other_end.join().unwrap(); - (server, books) - } - - /// The access log's records: (subject, outcome, whether it carries - /// the arguments' digest). - fn records(books: &TestDir) -> Vec<(String, ledger_store::access::Outcome, bool)> { - ledger_store::access::read(&books.0) - .unwrap() - .0 - .into_iter() - .map(|r| { - ( - r.access.subject, - r.access.result, - r.access.args_digest.is_some(), - ) - }) - .collect() - } - - fn refused(code: &str) -> ledger_store::access::Outcome { - ledger_store::access::Outcome::Refused(code.into()) - } - - fn tool_call(name: &str, args: serde_json::Value) -> rmcp::model::CallToolRequestParams { - rmcp::model::CallToolRequestParams::new(name.to_string()) - .with_arguments(args.as_object().cloned().unwrap_or_default()) - } - - /// A tool's answer: Ok((is it a refusal, its text)), or the protocol - /// error. - fn answer( - out: Result, - ) -> Result<(bool, String), rmcp::ErrorData> { - match out? { - rmcp::model::CallToolResponse::Complete(r) => Ok(( - r.is_error == Some(true), - r.content - .iter() - .find_map(|c| c.as_text().map(|t| t.text.clone())) - .unwrap_or_default(), - )), - other => panic!("not a complete answer: {other:?}"), - } - } - - fn rt() -> tokio::runtime::Runtime { - tokio::runtime::Builder::new_multi_thread() - .enable_all() - .build() - .unwrap() - } - - /// access.rs INVARIANTS 1-3: a call that reaches the books without a - /// Caller, or names a tool no table knows, is refused unrun (fail - /// closed), told why in words, and recorded so. - #[test] - fn a_call_without_a_caller_or_to_no_tool_is_refused_unrun_and_recorded() { - rt().block_on(async { - let s = session("door").await; - let (server, books) = logged("door"); - let add = || { - tool_call( - "books.add_account", - serde_json::json!({"code": "1000", "name": "Cash", "account_type": "asset"}), - ) - }; - let (refusal, text) = - answer(server.call_tool(add(), context(&s.peer, None)).await).unwrap(); - assert!(refusal, "{text}"); - assert!(text.contains("without a token"), "{text}"); - // An unknown tool is a protocol error (mcp-tools.md), never run. - let unknown = tool_call("books.no_such_tool", serde_json::json!({})); - let err = server - .call_tool(unknown, context(&s.peer, Some(ALL))) - .await - .unwrap_err(); - assert_eq!(err.code, rmcp::model::ErrorCode::INVALID_PARAMS, "{err:?}"); - assert!(err.message.contains("there is no tool"), "{err:?}"); - // Neither ran: the account isn't there. - let chart = tool_call("books.chart", serde_json::json!({})); - let (refusal, text) = - answer(server.call_tool(chart, context(&s.peer, Some(ALL))).await).unwrap(); - assert!(!refusal, "{text}"); - assert!(!text.contains("Cash"), "{text}"); - assert_eq!( - records(&books), - vec![ - ("books.add_account".into(), refused("unauthorized"), true), - ("books.no_such_tool".into(), refused("unknown_tool"), true), - ( - "books.chart".into(), - ledger_store::access::Outcome::Ok, - true - ), - ] - ); - }); - } - - /// SharedStore INVARIANT 3: once the books are closing a call or a - /// read isn't run, and says so with the not-run code (a JSON-RPC - /// server error, which the agents' bridge sends again once the books - /// are back); each is recorded as refused. - #[test] - fn a_call_while_the_books_close_is_refused_as_not_run() { - rt().block_on(async { - let s = session("closing").await; - let (server, books) = logged("closing"); - server.store.close().await; - let chart = tool_call("books.chart", serde_json::json!({})); - let e = server - .call_tool(chart, context(&s.peer, Some(ALL))) - .await - .unwrap_err(); - assert_eq!(e.code.0, NOT_RUN_CLOSING_CODE); - assert_eq!(e.message, NOT_RUN_CLOSING); - // JSON-RPC 2.0 5.1: -32000 to -32099 is for the server's own - // errors. - assert!((-32099..=-32000).contains(&NOT_RUN_CLOSING_CODE)); - assert!((-32099..=-32000).contains(&NOT_RUN_STOPPED_CODE)); - let read = ReadResourceRequestParams::new("books://chart"); - let e = server - .read_resource(read, context(&s.peer, Some(ALL))) - .await - .unwrap_err(); - assert_eq!(e.code.0, NOT_RUN_CLOSING_CODE); - assert_eq!( - records(&books), - vec![ - ("books.chart".into(), refused("closing"), true), - ("books://chart".into(), refused("closing"), false), - ] - ); - }); - } - - /// Reading a resource is reading the books (access.rs): only a token - /// with books:read reads one, and every read is recorded with what - /// came of it. - #[test] - fn a_resource_is_read_only_with_books_read_and_each_read_is_recorded() { - use ledger_store::access::Outcome; - rt().block_on(async { - let s = session("resources").await; - let (server, books) = logged("resources"); - let read = |uri: &str| ReadResourceRequestParams::new(uri); - assert!( - server - .read_resource(read("books://chart"), context(&s.peer, Some(ALL))) - .await - .is_ok() - ); - let writer = [ledger_store::auth::Scope::Write]; - let e = server - .read_resource(read("books://chart"), context(&s.peer, Some(&writer[..]))) - .await - .unwrap_err(); - assert_eq!(e.message, crate::access::INSUFFICIENT_SCOPE); - let e = server - .read_resource(read("books://chart"), context(&s.peer, None)) - .await - .unwrap_err(); - assert_eq!(e.message, crate::access::UNAUTHORIZED); - // One that ran and failed is an error, not a refusal. - assert!( - server - .read_resource(read("books://nope"), context(&s.peer, Some(ALL))) - .await - .is_err() - ); - let outcomes: Vec<_> = records(&books).into_iter().map(|r| r.1).collect(); - assert_eq!( - outcomes, - vec![ - Outcome::Ok, - refused("insufficient_scope"), - refused("unauthorized"), - Outcome::Error, - ] - ); - }); - } - - /// SharedStore INVARIANTS 2-3 at the last moment: a call that passed - /// the checks, then found the books closing (or the server stopped) - /// as it went to run, isn't run either, and is answered and recorded - /// as one refused at the start would be. The settings are held so - /// the call waits between its checks and its run. - #[test] - fn a_call_the_books_close_or_stop_under_is_refused_as_not_run() { - let rt = rt(); - for (how, stops) in [("closes", false), ("stops", true)] { - let (server, books) = logged(&format!("late-{how}")); - // The settings, held on a thread of their own until released. - let (locked, is_locked) = std::sync::mpsc::channel(); - let (release, released) = std::sync::mpsc::channel::<()>(); - let settings = server.settings.clone(); - let holder = std::thread::spawn(move || { - let _held = settings.lock().unwrap(); - locked.send(()).unwrap(); - let _ = released.recv(); - }); - is_locked.recv().unwrap(); - let answered = rt.block_on(async { - let s = session(&format!("late-{how}")).await; - let (started, began) = tokio::sync::oneshot::channel(); - let calling = server.clone(); - let cx = context(&s.peer, Some(ALL)); - let call = tokio::spawn(async move { - let _ = started.send(()); - let chart = tool_call("books.chart", serde_json::json!({})); - calling.call_tool(chart, cx).await - }); - began.await.unwrap(); - // Past its checks (no I/O between): waiting for the settings. - tokio::time::sleep(std::time::Duration::from_millis(300)).await; - if stops { - server.store.stop(); - } else { - server - .store - .closing - .store(true, std::sync::atomic::Ordering::SeqCst); - } - release.send(()).unwrap(); - call.await.unwrap() - }); - holder.join().unwrap(); - if stops { - assert_eq!(answer(answered).unwrap(), (true, STOPPED.to_string())); - // Refused after its arguments were taken: their digest too. - assert_eq!( - records(&books), - vec![("books.chart".into(), refused("stopped"), true)] - ); - } else { - let e = answered.unwrap_err(); - assert_eq!(e.code.0, NOT_RUN_CLOSING_CODE, "{e:?}"); - // Its arguments went with the request: no digest. - assert_eq!( - records(&books), - vec![("books.chart".into(), refused("closing"), false)] - ); - } - } - } - - /// access.rs INVARIANT 3: a record that can't be written stops the - /// books, and the answer says so; a call or read refused as not run - /// (closing) keeps its not-run code, so the bridge still sends it - /// again; anything else is an error in words. - #[test] - fn an_access_record_that_cant_be_written_stops_the_books_and_says_so() { - rt().block_on(async { - let s = session("unrecorded").await; - // A call refused as the books close. - let (server, _books) = unwritable("unrecorded-call"); - server.store.close().await; - let chart = tool_call("books.chart", serde_json::json!({})); - let e = server - .call_tool(chart, context(&s.peer, Some(ALL))) - .await - .unwrap_err(); - assert_eq!(e.code.0, NOT_RUN_CLOSING_CODE); - assert!(e.message.contains(crate::access::NOT_RECORDED), "{e:?}"); - assert!(server.store.stopped()); - // Any other answer refused unrun becomes the words alone. - let (server, _books) = unwritable("unrecorded-refusal"); - let call = crate::access::Call::new( - &server.access, - &context(&s.peer, Some(ALL)), - "tool", - "books.chart", - ); - let out = server - .refuse( - &call, - crate::access::STOPPED, - Err(rmcp::ErrorData::internal_error("not run", None)), - ) - .await; - assert_eq!( - answer(out).unwrap(), - (true, crate::access::NOT_RECORDED.to_string()) - ); - assert!(server.store.stopped()); - // A read refused as the books close. - let (server, _books) = unwritable("unrecorded-closing-read"); - server.store.close().await; - let e = server - .read_resource( - ReadResourceRequestParams::new("books://chart"), - context(&s.peer, Some(ALL)), - ) - .await - .unwrap_err(); - assert_eq!(e.code.0, NOT_RUN_CLOSING_CODE); - assert!(e.message.contains(crate::access::NOT_RECORDED), "{e:?}"); - assert!(server.store.stopped()); - // A read that ran and failed. - let (server, _books) = unwritable("unrecorded-read"); - let e = server - .read_resource( - ReadResourceRequestParams::new("books://nope"), - context(&s.peer, Some(ALL)), - ) - .await - .unwrap_err(); - assert_eq!(e.code, rmcp::model::ErrorCode::INTERNAL_ERROR); - assert_eq!(e.message, crate::access::NOT_RECORDED); - assert!(server.store.stopped()); - }); - } - - /// A request whose blocking work the runtime never ran (it was - /// shutting down) is answered as not run, in the words a stopped - /// server uses, and stops nothing: only a request that panicked - /// stops the books (run_blocking). - #[test] - fn a_request_the_runtime_never_ran_is_answered_not_run_and_stops_nothing() { - let dead = rt(); - let handle = dead.handle().clone(); - drop(dead); - rt().block_on(async { - let s = session("never-ran").await; - let (server, _books) = logged("never-ran"); - let chart = tool_call("books.chart", serde_json::json!({})); - let mut call = std::pin::pin!(server.call_tool(chart, context(&s.peer, Some(ALL)))); - // Its blocking work goes to the runtime that shut down. - let out = std::future::poll_fn(|cx| { - let _in = handle.enter(); - call.as_mut().poll(cx) - }) - .await; - assert_eq!(answer(out).unwrap(), (true, STOPPED.to_string())); - assert!(!server.store.stopped()); - }); - } - - /// A tool list differs by caller (scopes.rs): a client on 2026-07-28 - /// or later is told to cache it for this caller alone, and not to keep - /// it; an older one, which knows neither hint, is told neither. - #[test] - fn a_tool_list_is_cached_only_for_its_caller() { - rt().block_on(async { - let s = session("hints").await; - let books = unit_dir("hints"); - let server = served(store(&books)); - for (version, hinted) in [ - (rmcp::model::ProtocolVersion::V_2026_07_28, true), - (rmcp::model::ProtocolVersion::V_2025_11_25, false), - ] { - let mut cx = context(&s.peer, Some(ALL)); - cx.meta.set_protocol_version(version.clone()); - let list = server.list_tools(None, cx).await.unwrap(); - assert!(!list.tools.is_empty()); - assert_eq!(list.ttl_ms, hinted.then_some(0), "{version:?}"); - assert_eq!( - list.cache_scope, - hinted.then_some(rmcp::model::CacheScope::Private), - "{version:?}" - ); - } - }); - } } diff --git a/crates/ledger-mcp/src/server_tests.rs b/crates/ledger-mcp/src/server_tests.rs new file mode 100644 index 0000000..2f12cfb --- /dev/null +++ b/crates/ledger-mcp/src/server_tests.rs @@ -0,0 +1,1510 @@ +//! The server's tests: the whole tool surface (`annotations_complete`, +//! the text and schema checks, the document/description budgets), the +//! access door, the request machinery and the resources. They sit beside +//! `server.rs` (a `#[path]` module of it) so `server.rs` stays under its +//! 3,000-line bar (plan/served.md 8.2 U0a); a tool group's own tests live +//! in its group file, and the fixtures both use are in +//! `server::test_support`. + +use super::*; +use crate::server::test_support::*; + +/// An upload's limit, 25 MiB exactly (plan 4.7); nothing comes inline +/// (D-46). +#[test] +fn the_file_limits_are_the_plans() { + assert_eq!(ledger_store::uploads::MAX_UPLOAD, 26_214_400); + // Plan 4.7: an upload lasts 15 minutes; 16 under way per token. + assert_eq!(ledger_store::uploads::UPLOAD_TTL, 900); + assert_eq!(ledger_store::uploads::MAX_LIVE, 16); + assert_eq!(ledger_store::uploads::MAX_NAME_BYTES, 255); + assert_eq!(crate::local::MAX_BODY, 1024 * 1024); +} + +/// Every tool says what it is and what it does to the books (plan +/// 4.9, "Annotations (P0)"): a title and readOnlyHint on all; a +/// mutating tool also says whether it is destructive. None asks the +/// client for an interaction of its own (owner decision 2026-10-16: +/// no added prompts). +#[test] +fn annotations_complete() { + // Every tool's hints, pinned (MCP's ToolAnnotations): R read-only; + // W changes the books, additively; D destructive (posts, or + // overwrites, unlinks, voids, purges what was there: a vault + // record, a link, a reconciliation); I idempotent (a repeat with + // the same arguments leaves the journal and the vault as they + // were: spec_vault's idempotent_tools_change_nothing_when_repeated + // calls each twice). export_cpa_package writes a file, so it is + // not read-only. + type Hints = (Option, Option, Option); + const R: Hints = (Some(true), None, None); + const W: Hints = (Some(false), Some(false), None); + const D: Hints = (Some(false), Some(true), None); + const WI: Hints = (Some(false), Some(false), Some(true)); + const DI: Hints = (Some(false), Some(true), Some(true)); + let table: [(&str, Hints); 122] = [ + ("books.handbook", R), + ("books.start", R), + ("books.set_setting", DI), + ("books.export_settings", R), + ("books.conventions", R), + ("books.set_convention", W), + ("books.retire_convention", D), + ("books.record_decision", W), + ("books.decisions", R), + ("books.raise_item", W), + ("books.resolve_item", W), + ("books.open_items", R), + ("books.draft_from_template", W), + ("books.approve_entry", W), + ("books.chart", R), + ("books.add_account", W), + ("books.trial_balance", R), + ("books.profit_and_loss", R), + ("books.balance_sheet", R), + ("books.cash_flow", R), + ("books.draft_entry", W), + ("books.add_line", W), + ("books.post_entry", D), + ("books.reverse_entry", D), + ("books.attach_receipt", W), + ("books.file_document", W), + ("books.link_receipt", W), + ("books.unlink_receipt", D), + ("books.withdraw_attachment", D), + ("books.link_attachment", W), + ("books.unlink_attachment", D), + ("books.attachments", R), + ("books.list_receipts", R), + ("books.get_receipt", R), + ("books.get_entry", R), + ("books.search_entries", R), + ("books.show", R), + ("books.verify", WI), + ("books.digest", R), + ("books.import_bank_csv", W), + ("books.create_schedule", W), + ("books.list_schedules", R), + ("books.run_schedules", DI), + ("books.create_recurring", W), + ("books.list_recurring", R), + ("books.run_recurring", DI), + ("books.record_payout", W), + ("books.list_vendors", R), + ("books.set_vendor_1099", WI), + ("books.set_entry_vendor", W), + ("books.vendor_1099", W), + ("books.add_instrument", W), + ("books.instruments", R), + ("books.set_entry_instrument", WI), + ("books.chart_template", R), + ("books.tax_categories", R), + ("books.renumber_account", D), + ("books.rename_account", D), + ("books.label_asset", W), + ("books.correct_asset", D), + ("books.set_asset_tax_basis", W), + ("books.asset_labels", R), + ("books.settings", R), + ("books.roles", R), + ("books.assign_role", D), + ("books.set_tax_category", W), + ("books.import_chart_csv", W), + ("books.begin_upload", W), + ("books.export", R), + ("books.register_asset", W), + ("books.asset_schedule", R), + ("books.list_assets", R), + ("books.run_depreciation", DI), + ("books.dispose_asset", D), + ("books.create_period", W), + ("books.close_period", D), + ("books.list_periods", R), + ("books.mark_cleared", W), + ("books.reconcile", D), + ("books.record_statement", W), + ("books.statements", R), + ("books.reconciliations", R), + ("books.void_statement", D), + ("books.void_reconciliation", D), + ("books.add_customer", W), + ("books.add_vendor", W), + ("books.add_party", W), + ("books.update_party", W), + ("books.set_party_roles", WI), + ("books.set_w9", D), + ("books.set_tin", D), + ("books.get_w9", W), + ("books.purge_tin", D), + ("books.set_w4", W), + ("books.parties", R), + ("books.party", R), + ("books.set_entry_party", W), + ("books.create_invoice", D), + ("books.create_bill", D), + ("books.receive_payment", D), + ("books.pay_bill", D), + ("books.void_document", D), + ("books.control_check", R), + ("books.documents", R), + ("books.document", R), + ("books.ar_aging", R), + ("books.ap_aging", R), + ("books.set_fx_rate", W), + ("books.run_payroll", D), + ("books.remit_payroll_taxes", D), + ("books.list_payroll_runs", R), + ("books.add_class", W), + ("books.list_classes", R), + ("books.add_project", W), + ("books.update_project", WI), + ("books.projects", R), + ("books.project", R), + ("books.profit_and_loss_by_class", R), + ("books.export_cpa_package", W), + ("books.access_log", R), + ("books.backup", R), + ("books.back_up_now", WI), + ]; + let tools = BooksServer::tool_router().list_all(); + assert_eq!(tools.len(), table.len()); + let mut names: Vec<&str> = table.iter().map(|(n, _)| *n).collect(); + names.sort_unstable(); + names.dedup(); + assert_eq!(names.len(), table.len(), "a tool is in the table twice"); + for t in &tools { + let name = &t.name; + assert!( + t.title.as_deref().is_some_and(|s| !s.trim().is_empty()), + "{name} has no title" + ); + let a = t + .annotations + .as_ref() + .unwrap_or_else(|| panic!("{name} has no annotations")); + let want = table + .iter() + .find(|(n, _)| n == name) + .unwrap_or_else(|| panic!("{name} is not in the table")) + .1; + assert_eq!( + (a.read_only_hint, a.destructive_hint, a.idempotent_hint), + want, + "{name}: (readOnlyHint, destructiveHint, idempotentHint)" + ); + let json = serde_json::to_string(t).unwrap().to_lowercase(); + for word in ["requiresuserinteraction", "requires_user_interaction"] { + assert!(!json.contains(word), "{name} asks for user interaction"); + } + // The books are a closed world: no tool reaches beyond them, + // but the one that pushes a backup to the owner's targets. + assert_eq!( + a.open_world_hint, + Some(*name == "books.back_up_now"), + "{name}: openWorldHint" + ); + } + // docs/mcp-interface.md names the 29 destructive tools. + assert_eq!(table.iter().filter(|(_, h)| h.1 == Some(true)).count(), 29); +} + +/// Every refusal an agent can be given whose text is fixed here, or +/// in the crates under the server: the error templates of every +/// variant (scanned from their sources, so a new variant is linted +/// too), representative values of those whose text is built, and the +/// server's own refusal constants. +fn agent_refusals() -> Vec<(String, String)> { + let mut out: Vec<(String, String)> = Vec::new(); + for (krate, src) in [ + ( + "ledger-core", + include_str!("../../ledger-core/src/error.rs"), + ), + ( + "ledger-store", + include_str!("../../ledger-store/src/error.rs"), + ), + ( + "ledger-vault", + include_str!("../../ledger-vault/src/lib.rs"), + ), + ( + "ledger-import bank", + include_str!("../../ledger-import/src/bank_csv.rs"), + ), + ( + "ledger-import chart", + include_str!("../../ledger-import/src/chart_csv.rs"), + ), + ( + "ledger-import iif", + include_str!("../../ledger-import/src/iif.rs"), + ), + ( + "ledger-import pdf", + include_str!("../../ledger-import/src/pdf.rs"), + ), + ( + "ledger-import xlsx", + include_str!("../../ledger-import/src/xlsx.rs"), + ), + ( + "ledger-store uploads", + include_str!("../../ledger-store/src/uploads.rs"), + ), + ] { + let mut rest = src; + let mut n = 0; + while let Some(at) = rest.find("#[error(") { + rest = &rest[at + 8..]; + let end = rest.find(")]").expect("an #[error] attribute ends"); + out.push((format!("{krate} error template"), rest[..end].to_string())); + rest = &rest[end..]; + n += 1; + } + assert!(n > 0, "{krate}: no error templates found"); + } + // The files and backup tools' refusals (and descriptions), and the + // backups' own words: their sentences. (What git itself says of a + // failed backup reaches the human as git said it: not ours.) + for (name, src) in [ + ("tools/files.rs", include_str!("tools/files.rs")), + ("tools/backup.rs", include_str!("tools/backup.rs")), + ("backup.rs", include_str!("backup.rs")), + ] { + let src = src.split("#[cfg(test)]").next().unwrap_or(src); + for line in src.lines() { + let line = line.trim_start(); + if line.starts_with("//") { + continue; + } + for (i, text) in line.split('"').enumerate() { + if i % 2 == 1 && text.len() > 30 && text.contains(' ') { + out.push((format!("{name} text"), text.to_string())); + } + } + } + } + let newer = |verified| ledger_store::StoreError::NewerJournal { + seq: 7, + event_type: "settings.set".into(), + need: "journal format 2".into(), + verified, + }; + out.push(("NewerJournal".into(), newer(true).to_string())); + out.push(("NewerJournal unverified".into(), newer(false).to_string())); + out.push(( + "FormatUpgradePending".into(), + ledger_store::StoreError::FormatUpgradePending { + need: "journal format 2".into(), + } + .to_string(), + )); + out.push(( + "RuleNotKnown".into(), + ledger_core::form1099::default_threshold(9999) + .unwrap_err() + .to_string(), + )); + for (what, text) in [ + ("STOPPED", STOPPED), + ("tin_files::TIN_FILE", tin_files::TIN_FILE), + ("tin_files::TIN_HELD", tin_files::TIN_HELD), + ("tin_files::TIN_PROBE", tin_files::TIN_PROBE), + ("tin_files::TIN_PRINTED", tin_files::TIN_PRINTED), + ] { + out.push((what.to_string(), text.to_string())); + } + // Refusals and notes built inline, where the books' own record is + // kept: every string literal of these sources outside their tests. + for (what, src) in [ + ("tools/kept.rs", include_str!("tools/kept.rs")), + ( + "ledger-config codec", + include_str!("../../ledger-config/src/codec.rs"), + ), + ( + "ledger-core settings", + include_str!("../../ledger-core/src/settings.rs"), + ), + ( + "ledger-core conventions", + include_str!("../../ledger-core/src/conventions.rs"), + ), + ( + "ledger-core ledger", + include_str!("../../ledger-core/src/ledger.rs"), + ), + ("settings.rs", include_str!("settings.rs")), + ] { + let code = src.split("#[cfg(test)]").next().unwrap_or(src); + let literals = string_literals(code); + assert!(!literals.is_empty(), "{what}: no literals found"); + out.extend(literals.into_iter().map(|l| (format!("{what} literal"), l))); + } + out +} + +/// The text of every plain string literal ("...") in Rust source, +/// skipping comments (a rough scanner: enough for the lint). +fn string_literals(src: &str) -> Vec { + let mut out = Vec::new(); + for line in src.lines() { + let line = line.trim_start(); + if line.starts_with("//") { + continue; + } + let mut chars = line.chars().peekable(); + let mut cur: Option = None; + while let Some(c) = chars.next() { + match (&mut cur, c) { + (None, '"') => cur = Some(String::new()), + (None, '/') if chars.peek() == Some(&'/') => break, + (Some(s), '\\') => { + if let Some(n) = chars.next() { + s.push('\\'); + s.push(n); + } + } + (Some(s), '"') => { + out.push(std::mem::take(s)); + cur = None; + } + (Some(s), c) => s.push(c), + (None, _) => {} + } + } + } + out +} + +/// No text an agent is given sends it to the books' files or to tools +/// outside these (plan I-12): no helper commands, home paths, data +/// dir names or flags, file names of the books, git, GitHub, pull +/// requests, repo docs, the old company's examples, or suggestions to +/// read files. Linted whole: every tool's JSON (title, description, +/// input and output schemas), every resource, the connect text, every +/// handbook section, and every refusal fixed in code +/// ([`agent_refusals`]). (No resource templates are advertised yet.) +/// What `tool_text_lint` finds in one text beyond its fixed list: a +/// helper command however it ends (`just` as a word before a +/// backtick, a line break, a tab or the end), and where the books are +/// ("beside the books", "books dir"). +fn helper_or_location(what: &str, text: &str) -> Vec { + let lower = text.to_lowercase(); + let mut found = Vec::new(); + for (at, _) in lower.match_indices("just") { + let prev = lower[..at].chars().next_back(); + let next = lower[at + 4..].chars().next(); + let word_start = !prev.is_some_and(|c| c.is_alphanumeric() || c == '_'); + if word_start && matches!(next, None | Some('`' | '\n' | '\r' | '\t')) { + found.push(format!("{what} names a helper command (`just`)")); + } + } + if lower.contains("beside the books") { + found.push(format!( + "{what} says where the books are: \"beside the books\"" + )); + } + if lower.contains("books dir") { + found.push(format!("{what} says where the books are: \"books dir\"")); + } + found +} + +/// The lint's own checks catch what they name, and pass ordinary words. +#[test] +fn tool_text_lint_catches_helpers_and_locations() { + for (text, n) in [ + ("run `just`", 1), + ("run just\nthen", 1), + ("run just\tgui", 1), + ("run just", 1), + ("just`gui`", 1), + ("Just\n", 1), + ("adjust`", 0), + ("just after posting", 0), + ("just, then", 0), + ("justly", 0), + ("the file beside the books", 1), + ("in the books dir", 1), + ("a path outside the books dir", 1), + ("the books directory", 1), + ("bookshelf", 0), + ] { + assert_eq!(helper_or_location("text", text).len(), n, "{text:?}"); + } + assert_eq!( + helper_or_location("tool books.get_w9", "outside the books dir").len(), + 1 + ); + assert_eq!( + helper_or_location("tool books.chart", "outside the books dir").len(), + 1 + ); +} + +/// No tool takes a path on the server's computer (plan 4.7, D-28, +/// D-29, D-46): files come in by upload, and go out as exports. +#[test] +fn no_tool_takes_a_path() { + fn names(v: &serde_json::Value, out: &mut Vec) { + match v { + serde_json::Value::Object(map) => { + if let Some(serde_json::Value::Object(props)) = map.get("properties") { + out.extend(props.keys().cloned()); + } + map.values().for_each(|v| names(v, out)); + } + serde_json::Value::Array(items) => items.iter().for_each(|v| names(v, out)), + _ => {} + } + } + let mut found = Vec::new(); + for t in BooksServer::tool_router().list_all() { + let mut props = Vec::new(); + names( + &serde_json::Value::Object((*t.input_schema).clone()), + &mut props, + ); + for p in props { + let lower = p.to_ascii_lowercase(); + // Names that hold a place to read or write: whole words, + // so `link_to`, `*_file_name`, `tin_on_file` and `date_to` + // pass. + let words: Vec<&str> = lower.split('_').collect(); + let pathlike = lower.starts_with("save") + || lower.contains("path") + || [ + "dir", + "folder", + "root", + "dest", + "destination", + "output", + "location", + "local", + ] + .iter() + .any(|w| words.contains(w)) + || (words.last() == Some(&"file") && !lower.ends_with("on_file")) + || lower == "to" + || lower == "from" + || lower == "source"; + if pathlike { + found.push(format!("{} {p}", t.name)); + } + } + } + found.sort(); + found.dedup(); + assert!(found.is_empty(), "path-like parameters: {found:?}"); +} + +#[test] +fn tool_text_lint() { + const NEVER: &[&str] = &[ + "just ", + "~/", + "/home", + ".config/wanderbooks", + "books-data/", + "data dir", + "--data-dir", + "--import-root", + "vault.key", + "journal.jsonl", + ".jsonl", + "sqlite", + ".gitignore", + "github", + "pull request", + "docs/", + ".md", + "read the journal", + "the journal is the evidence", + "read the file", + "open the file", + "cat ", + // Gone in P2 slice 5: files come by upload. + "import_local_file", + "w9_file_path", + "import folder", + "import root", + "put_url", + "human_url", + "fl-0001", + // Gone with D-46: a file never passes through the agent. + "content_base64", + "csv_text", + "base64", + "256 kib", + ]; + // Whole words only: "digit" and "committee" are fine. + const NEVER_WORDS: &[&str] = &["git", "commit"]; + let mut texts: Vec<(String, String)> = Vec::new(); + for t in BooksServer::tool_router().list_all() { + texts.push(( + format!("tool {}", t.name), + serde_json::to_string(&t).unwrap(), + )); + } + for r in resource_list() { + texts.push(( + format!("resource {}", r.uri), + serde_json::to_string(&r).unwrap(), + )); + } + texts.extend(agent_refusals()); + for threshold in ["0.00", "25.00"] { + let settings = Settings { + policy: ReceiptPolicy { + require_receipts_above: threshold.into(), + ..ReceiptPolicy::default() + }, + ..Settings::default() + }; + texts.push(( + "connect text".into(), + handbook::connect_text(&settings, "USD"), + )); + for (id, _) in handbook::SECTIONS { + for declared in [true, false] { + texts.push(( + format!("handbook {id}"), + handbook::section(id, &settings, "USD", declared).unwrap(), + )); + } + } + } + // Every tool the text names is one there is. + let tools: std::collections::HashSet = BooksServer::tool_router() + .list_all() + .into_iter() + .map(|t| t.name.to_string()) + .collect(); + let mut unknown: Vec = Vec::new(); + // Settings keys (`books.cash_accounts`) are named like tools: a + // name that is one is a setting, not a tool. + let keys: std::collections::HashSet<&str> = + ledger_core::settings::KEYS.iter().map(|k| k.name).collect(); + for (what, text) in texts + .iter() + .filter(|(w, _)| !w.contains("settings literal")) + { + for (i, _) in text.match_indices("books.") { + let name: String = text[i..] + .chars() + .take_while(|c| { + c.is_ascii_lowercase() || c.is_ascii_digit() || *c == '_' || *c == '.' + }) + .collect(); + let name = name.trim_end_matches('.'); + // "the books." ends a sentence. + if name == "books" { + continue; + } + let before = text[..i].chars().next_back(); + if before.is_some_and(|c| c.is_alphanumeric() || c == '_' || c == '/') { + continue; + } + if !tools.contains(name) && !keys.contains(name) { + unknown.push(format!("{what}: {name}")); + } + } + } + unknown.sort(); + unknown.dedup(); + assert!( + unknown.is_empty(), + "text names tools there aren't: {unknown:?}" + ); + let words = |text: &str| { + text.split(|c: char| !c.is_alphanumeric() && c != '_') + .map(str::to_string) + .collect::>() + }; + let mut found: Vec = Vec::new(); + for (what, text) in &texts { + // A handbook resource's own address is not a file name. + let lower = text.to_lowercase().replace("books://handbook", ""); + for bad in NEVER { + if let Some(at) = lower.find(bad) { + let from = lower[..at] + .char_indices() + .rev() + .nth(40) + .map_or(0, |(i, _)| i); + let near: String = lower[from..].chars().take(100).collect(); + found.push(format!("{what} contains {bad:?}: ...{near}...")); + } + } + let w = words(&lower); + for bad in NEVER_WORDS { + if w.iter().any(|x| x == bad) { + found.push(format!("{what} says {bad:?}")); + } + } + } + for (what, text) in &texts { + found.extend(helper_or_location(what, text)); + } + assert!(found.is_empty(), "{}", found.join("\n")); + // The settings file may be named in schemas (its fields are edited + // there until settings move into the books, P1), but no tool's + // title or description, no refusal, the connect text and no + // handbook section names it: it is the file books.settings names. + let mut prose: Vec<(String, String)> = BooksServer::tool_router() + .list_all() + .iter() + .map(|t| { + ( + format!("tool {}", t.name), + format!( + "{} {}", + t.title.as_deref().unwrap_or_default(), + t.description.as_deref().unwrap_or_default() + ), + ) + }) + .collect(); + prose.extend( + texts + .into_iter() + .filter(|(what, _)| !what.starts_with("tool ")), + ); + for (what, text) in &prose { + assert!( + !text.to_lowercase().contains("wanderbooks.toml"), + "{what} names the settings file: {text}" + ); + } +} + +/// Tool descriptions are at most 700 characters (plan I-12). The +/// ones over it today are listed, each to be shortened in P5 when its +/// text moves into the handbook; no new one may join them, and one +/// that shrinks under the cap must leave the list. +#[test] +fn tool_descriptions_fit_700_characters() { + // Shorten in P5. + const OVER: &[&str] = &[ + "books.export", + "books.export_cpa_package", + "books.import_bank_csv", + "books.reconcile", + "books.set_entry_party", + "books.set_w9", + "books.show", + "books.vendor_1099", + ]; + let mut over: Vec = BooksServer::tool_router() + .list_all() + .iter() + .filter(|t| t.description.as_deref().unwrap_or_default().chars().count() > 700) + .map(|t| t.name.to_string()) + .collect(); + over.sort(); + assert_eq!(over, OVER, "tool descriptions over 700 characters"); +} + +/// The tools docs/mcp-interface.md documents: a line (or a bullet) +/// that starts with `` `books.NAME` `` or `` `books.NAME { `` , and +/// any `` `books.NAME { `` signature within a line. +fn documented_tools(doc: &str) -> std::collections::BTreeSet { + let name_at = |s: &str| -> Option<(String, char)> { + let rest = s.strip_prefix("`books.")?; + let name: String = rest + .chars() + .take_while(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || *c == '_') + .collect(); + let next = rest[name.len()..].chars().next()?; + (!name.is_empty()).then(|| (format!("books.{name}"), next)) + }; + let mut out = std::collections::BTreeSet::new(); + for line in doc.lines() { + let t = line.trim_start(); + let t = t.strip_prefix("- ").unwrap_or(t); + if let Some((name, next)) = name_at(t) + && (next == '`' || next == ' ') + { + out.insert(name); + } + for (i, _) in line.match_indices("`books.") { + if let Some((name, ' ')) = name_at(&line[i..]) + && line[i + 1 + name.len()..].starts_with(" {") + { + out.insert(name); + } + } + } + out +} + +/// Every tool the server has is documented in docs/mcp-interface.md, +/// and every tool the doc documents is one the server has. +#[test] +fn every_tool_is_documented_and_every_documented_tool_exists() { + let doc = include_str!("../../../docs/mcp-interface.md"); + let documented = documented_tools(doc); + let tools: std::collections::BTreeSet = BooksServer::tool_router() + .list_all() + .iter() + .map(|t| t.name.to_string()) + .collect(); + let undocumented: Vec<&String> = tools.difference(&documented).collect(); + let unknown: Vec<&String> = documented.difference(&tools).collect(); + assert!( + undocumented.is_empty(), + "tools docs/mcp-interface.md doesn't document: {undocumented:?}" + ); + assert!( + unknown.is_empty(), + "docs/mcp-interface.md documents tools there aren't: {unknown:?}" + ); + // The reader catches what it should, and only that. + let sample = "- `books.chart` → x\n `books.decisions { a? }`; \ + `books.open_items { b? }`.\nsee `books.cash_accounts`, \ + `books.show`."; + let got: Vec = documented_tools(sample).into_iter().collect(); + assert_eq!(got, ["books.chart", "books.decisions", "books.open_items"]); +} + +/// rules.md B-30 (defect 16): every report tool says what its omitted +/// dates mean in the one sentence the resolver's kind gives +/// (PeriodKind::describe), and each date parameter in the schema's +/// own words (START_DOC, END_DOC, AS_OF_DOC, YEAR_DOC); the two tools +/// over the description budget (books.export, books.show) and the CPA +/// package say it in their parameters. No tool or schema says "all +/// time", "beginning of time" or "first entry" any more. The resources +/// read with dates omitted say which period they cover. +#[test] +fn period_tools_describe_the_one_default() { + use ledger_core::calendar::{AS_OF_DOC, END_DOC, PeriodKind, START_DOC, YEAR_DOC}; + let tools = BooksServer::tool_router().list_all(); + let tool = |name: &str| { + tools + .iter() + .find(|t| t.name == name) + .unwrap_or_else(|| panic!("no tool {name}")) + }; + let field = |name: &str, f: &str| -> String { + tool(name).input_schema["properties"][f]["description"] + .as_str() + .unwrap_or_else(|| panic!("{name}.{f} has no description")) + .to_string() + }; + for (name, kind, fields) in [ + ( + "books.profit_and_loss", + PeriodKind::Statement, + &[("start", START_DOC), ("end", END_DOC)][..], + ), + ( + "books.profit_and_loss_by_class", + PeriodKind::Statement, + &[("start", START_DOC), ("end", END_DOC)][..], + ), + ( + "books.project", + PeriodKind::Statement, + &[("start", START_DOC), ("end", END_DOC)][..], + ), + ( + "books.cash_flow", + PeriodKind::Statement, + &[("start", START_DOC), ("end", END_DOC)][..], + ), + ( + "books.trial_balance", + PeriodKind::AsOf, + &[("as_of", AS_OF_DOC)][..], + ), + ( + "books.balance_sheet", + PeriodKind::AsOf, + &[("as_of", AS_OF_DOC)][..], + ), + ( + "books.control_check", + PeriodKind::AsOf, + &[("as_of", AS_OF_DOC)][..], + ), + ( + "books.documents", + PeriodKind::AsOf, + &[("as_of", AS_OF_DOC)][..], + ), + ( + "books.document", + PeriodKind::AsOf, + &[("as_of", AS_OF_DOC)][..], + ), + ( + "books.ar_aging", + PeriodKind::AsOf, + &[("as_of", AS_OF_DOC)][..], + ), + ( + "books.ap_aging", + PeriodKind::AsOf, + &[("as_of", AS_OF_DOC)][..], + ), + ( + "books.parties", + PeriodKind::CalendarYear, + &[("year", YEAR_DOC)][..], + ), + ( + "books.party", + PeriodKind::CalendarYear, + &[("year", YEAR_DOC)][..], + ), + ] { + let d = tool(name).description.as_deref().unwrap_or_default(); + assert!(d.ends_with(kind.describe()), "{name}: {d}"); + for (f, doc) in fields { + assert_eq!(field(name, f), *doc, "{name}.{f}"); + } + } + // Over the budget, or the package: in the parameters' words. + for (name, f, doc) in [ + ("books.export", "start", START_DOC), + ("books.export", "end", END_DOC), + ("books.export", "as_of", AS_OF_DOC), + ("books.show", "start", START_DOC), + ("books.show", "end", END_DOC), + ("books.export_cpa_package", "as_of", AS_OF_DOC), + ] { + assert!( + field(name, f).ends_with(doc), + "{name}.{f}: {}", + field(name, f) + ); + } + assert!( + tool("books.export") + .description + .as_deref() + .unwrap() + .contains("omitted: the fiscal year to date") + ); + // Nothing left of the old defaults, anywhere. + for t in tools.iter() { + let text = format!( + "{} {}", + t.description.as_deref().unwrap_or_default(), + serde_json::Value::Object((*t.input_schema).clone()) + ) + .to_lowercase(); + for stale in [ + "all time", + "beginning of time", + "first entry", + "first posted entry", + ] { + assert!(!text.contains(stale), "{} says {stale:?}", t.name); + } + } + let resources = resource_list(); + let resource = |uri: &str| { + resources + .iter() + .find(|r| r.uri == uri) + .and_then(|r| r.description.clone()) + .unwrap() + }; + assert!(resource("books://reports/profit-and-loss").contains("fiscal year to date")); + assert!(resource("books://reports/trial-balance").contains("as of today")); + assert!(resource("books://reports/balance-sheet").contains("as of today")); +} + +/// How many masked TINs a text holds. +fn data_masked(text: &str) -> usize { + text.matches("•••-••-••••").count() + text.matches("•••••••••").count() +} + +/// redact_error (B-26): every full TIN in an error's message and data +/// is masked, wherever the data holds it (a string, in an array, in a +/// nested object), and what holds none is left as it was. +#[test] +fn redact_error_masks_every_tin_in_the_message_and_data() { + let tin = "291-12-2846"; + let e = redact_error(rmcp::ErrorData::resource_not_found( + format!("books://receipts/{tin}"), + Some(serde_json::json!({ + "reason": format!("bad receipt resource URI: {tin}"), + "list": ["plain", tin, ["deeper 291122846"]], + "nested": {"uri": tin, "n": 5, "fine": "no tin here 2026-10-16"}, + })), + )); + let text = format!("{} {}", e.message, e.data.as_ref().unwrap()); + assert!(!text.contains(tin) && !text.contains("291122846"), "{text}"); + assert_eq!(data_masked(&text), 5, "every TIN masked: {text}"); + let data = e.data.unwrap(); + assert_eq!(data["list"][0], "plain"); + assert_eq!(data["nested"]["n"], 5); + assert_eq!(data["nested"]["fine"], "no tin here 2026-10-16"); +} + +/// Fail closed (SharedStore INVARIANTS 1-2): a request that panics, +/// with the store held or not, stops the server, and every later +/// request, tool or lock, is refused with the plain STOPPED message, +/// never the panic's text (which could carry anything, a TIN +/// included). +#[test] +fn a_request_that_panics_stops_the_server() { + let rt = tokio::runtime::Builder::new_multi_thread() + .enable_all() + .build() + .unwrap(); + type Body = fn(BooksServer) -> Result; + async fn run(server: &BooksServer, body: Body) -> Result, rmcp::ErrorData> { + let s = server.clone(); + run_blocking(&server.store, move || Box::pin(async move { body(s) })).await + } + let bodies: [(&str, Body); 2] = [ + ("holding the store", |s| { + let _store = s.lock()?; + panic!("mid-mutation 123-45-6789"); + }), + ("without the store", |_| { + panic!("before the lock 123-45-6789") + }), + ]; + for (how, body) in bodies { + let books = unit_dir("stops"); + let parts = Served::open(store(&books)).unwrap(); + let server = BooksServer::new(&parts); + rt.block_on(async { + let seq: Body = |s| s.lock().map(|st| st.journal_seq()); + // New books: store.created, then their journal format. + assert_eq!(run(&server, seq).await.unwrap(), Ok(2), "{how}"); + let died = run(&server, body).await.unwrap_err(); + assert_eq!(died.message, STOPPED, "{how}"); + // Every later request is refused, before it runs. + let after = run(&server, seq).await.unwrap_err(); + assert_eq!(after.message, STOPPED, "{how}"); + // So is any other session with the books. + let other = BooksServer::new(&parts); + assert_eq!(run(&other, seq).await.unwrap_err().message, STOPPED); + }); + // And the lock itself, from any thread. + let s = server.clone(); + let locked = std::thread::spawn(move || s.lock().map(|_| ())) + .join() + .unwrap(); + assert_eq!(locked, Err(STOPPED.to_string()), "{how}"); + } + + // A guard dropped by a panic on a thread of its own stops it too. + let books = unit_dir("stops-thread"); + let server = served(store(&books)); + let s = server.clone(); + let died = std::thread::spawn(move || { + let _store = s.lock().unwrap(); + panic!("mid-mutation"); + }) + .join(); + assert!(died.is_err()); + assert_eq!(server.lock().map(|_| ()), Err(STOPPED.to_string())); +} + +/// run_blocking drives a request to completion even when it waits: a +/// future that is pending until another thread wakes it completes, +/// with its own result (the waker unparks the request's thread). +#[test] +fn a_request_that_waits_is_woken_and_completes() { + /// Pending on its first poll, woken from another thread shortly + /// after; ready on the next. + struct WakeLater(bool); + impl std::future::Future for WakeLater { + type Output = u64; + fn poll( + mut self: std::pin::Pin<&mut Self>, + cx: &mut std::task::Context<'_>, + ) -> std::task::Poll { + if self.0 { + return std::task::Poll::Ready(7); + } + self.0 = true; + let waker = cx.waker().clone(); + std::thread::spawn(move || { + std::thread::sleep(std::time::Duration::from_millis(20)); + waker.wake(); + }); + std::task::Poll::Pending + } + } + let rt = tokio::runtime::Builder::new_multi_thread() + .enable_all() + .build() + .unwrap(); + let books = unit_dir("wakes"); + let shared = SharedStore::new(store(&books)); + let got = rt.block_on(async { + tokio::time::timeout( + std::time::Duration::from_secs(10), + run_blocking(&shared, || Box::pin(WakeLater(false))), + ) + .await + }); + // A request never woken would hold its thread: don't wait for it. + rt.shutdown_background(); + assert_eq!(got.ok().and_then(Result::ok), Some(7), "never woken"); +} + +// Calls through the access door (access.rs), driven straight into the +// handler with a context the HTTP door would have left: its Caller, +// or none. + +/// A peer to make request contexts with: a client and a server over +/// an in-memory pipe, kept alive while it is. +struct Session { + peer: rmcp::service::Peer, + _server: rmcp::service::RunningService, + _client: rmcp::service::RunningService, + _books: TestDir, +} + +async fn session(tag: &str) -> Session { + use rmcp::ServiceExt; + let books = unit_dir(&format!("{tag}-peer")); + let server = served(store(&books)); + let (a, b) = tokio::io::duplex(1 << 16); + let served = tokio::spawn(async move { server.serve(a).await }); + let client = ().serve(b).await.unwrap(); + let server = served.await.unwrap().unwrap(); + Session { + peer: server.peer().clone(), + _server: server, + _client: client, + _books: books, + } +} + +/// A request's context: from a token with `scopes` (the door's +/// Caller), or with none. +fn context( + peer: &rmcp::service::Peer, + scopes: Option<&[ledger_store::auth::Scope]>, +) -> RequestContext { + let mut cx = RequestContext::new(rmcp::model::NumberOrString::Number(7), peer.clone()); + if let Some(scopes) = scopes { + let token = ledger_store::auth::Token { + id: "tok_0123456789ab".into(), + book: "acme".into(), + name: "test".into(), + scopes: scopes.iter().copied().collect(), + created: "2026-10-20T00:00:00Z".into(), + sha256: String::new(), + }; + let (mut parts, ()) = axum::http::Request::new(()).into_parts(); + parts.extensions.insert(crate::access::Caller { token }); + cx.extensions.insert(parts); + } + cx +} + +const ALL: &[ledger_store::auth::Scope] = &[ + ledger_store::auth::Scope::Read, + ledger_store::auth::Scope::Write, + ledger_store::auth::Scope::Post, + ledger_store::auth::Scope::Admin, +]; + +/// Books as the books server serves them: calls are checked against +/// their caller's token and recorded in the access log beside them. +fn logged(tag: &str) -> (BooksServer, TestDir) { + let books = unit_dir(tag); + let server = served(store(&books)); + (server, books) +} + +/// Books whose access log can't be written: its file is a FIFO no one +/// reads, so every record fails (as on a full disk). +fn unwritable(tag: &str) -> (BooksServer, TestDir) { + let books = unit_dir(tag); + let store = store(&books); + let fifo = books.0.join(ledger_store::access::ACCESS_FILE); + let made = std::process::Command::new("mkfifo") + .arg(&fifo) + .status() + .unwrap(); + assert!(made.success()); + let path = fifo.clone(); + let other_end = std::thread::spawn(move || { + // The log reads the file whole as it opens (empty: a writer + // that goes at once), then opens it to append (a reader that + // goes at once): nothing reads what is written after. + drop(std::fs::OpenOptions::new().write(true).open(&path).unwrap()); + drop(std::fs::File::open(&path).unwrap()); + }); + let server = served(store); + other_end.join().unwrap(); + (server, books) +} + +/// The access log's records: (subject, outcome, whether it carries +/// the arguments' digest). +fn records(books: &TestDir) -> Vec<(String, ledger_store::access::Outcome, bool)> { + ledger_store::access::read(&books.0) + .unwrap() + .0 + .into_iter() + .map(|r| { + ( + r.access.subject, + r.access.result, + r.access.args_digest.is_some(), + ) + }) + .collect() +} + +fn refused(code: &str) -> ledger_store::access::Outcome { + ledger_store::access::Outcome::Refused(code.into()) +} + +fn tool_call(name: &str, args: serde_json::Value) -> rmcp::model::CallToolRequestParams { + rmcp::model::CallToolRequestParams::new(name.to_string()) + .with_arguments(args.as_object().cloned().unwrap_or_default()) +} + +/// A tool's answer: Ok((is it a refusal, its text)), or the protocol +/// error. +fn answer( + out: Result, +) -> Result<(bool, String), rmcp::ErrorData> { + match out? { + rmcp::model::CallToolResponse::Complete(r) => Ok(( + r.is_error == Some(true), + r.content + .iter() + .find_map(|c| c.as_text().map(|t| t.text.clone())) + .unwrap_or_default(), + )), + other => panic!("not a complete answer: {other:?}"), + } +} + +fn rt() -> tokio::runtime::Runtime { + tokio::runtime::Builder::new_multi_thread() + .enable_all() + .build() + .unwrap() +} + +/// access.rs INVARIANTS 1-3: a call that reaches the books without a +/// Caller, or names a tool no table knows, is refused unrun (fail +/// closed), told why in words, and recorded so. +#[test] +fn a_call_without_a_caller_or_to_no_tool_is_refused_unrun_and_recorded() { + rt().block_on(async { + let s = session("door").await; + let (server, books) = logged("door"); + let add = || { + tool_call( + "books.add_account", + serde_json::json!({"code": "1000", "name": "Cash", "account_type": "asset"}), + ) + }; + let (refusal, text) = + answer(server.call_tool(add(), context(&s.peer, None)).await).unwrap(); + assert!(refusal, "{text}"); + assert!(text.contains("without a token"), "{text}"); + // An unknown tool is a protocol error (mcp-tools.md), never run. + let unknown = tool_call("books.no_such_tool", serde_json::json!({})); + let err = server + .call_tool(unknown, context(&s.peer, Some(ALL))) + .await + .unwrap_err(); + assert_eq!(err.code, rmcp::model::ErrorCode::INVALID_PARAMS, "{err:?}"); + assert!(err.message.contains("there is no tool"), "{err:?}"); + // Neither ran: the account isn't there. + let chart = tool_call("books.chart", serde_json::json!({})); + let (refusal, text) = + answer(server.call_tool(chart, context(&s.peer, Some(ALL))).await).unwrap(); + assert!(!refusal, "{text}"); + assert!(!text.contains("Cash"), "{text}"); + assert_eq!( + records(&books), + vec![ + ("books.add_account".into(), refused("unauthorized"), true), + ("books.no_such_tool".into(), refused("unknown_tool"), true), + ( + "books.chart".into(), + ledger_store::access::Outcome::Ok, + true + ), + ] + ); + }); +} + +/// SharedStore INVARIANT 3: once the books are closing a call or a +/// read isn't run, and says so with the not-run code (a JSON-RPC +/// server error, which the agents' bridge sends again once the books +/// are back); each is recorded as refused. +#[test] +fn a_call_while_the_books_close_is_refused_as_not_run() { + rt().block_on(async { + let s = session("closing").await; + let (server, books) = logged("closing"); + server.store.close().await; + let chart = tool_call("books.chart", serde_json::json!({})); + let e = server + .call_tool(chart, context(&s.peer, Some(ALL))) + .await + .unwrap_err(); + assert_eq!(e.code.0, NOT_RUN_CLOSING_CODE); + assert_eq!(e.message, NOT_RUN_CLOSING); + // JSON-RPC 2.0 5.1: -32000 to -32099 is for the server's own + // errors. + assert!((-32099..=-32000).contains(&NOT_RUN_CLOSING_CODE)); + assert!((-32099..=-32000).contains(&NOT_RUN_STOPPED_CODE)); + let read = ReadResourceRequestParams::new("books://chart"); + let e = server + .read_resource(read, context(&s.peer, Some(ALL))) + .await + .unwrap_err(); + assert_eq!(e.code.0, NOT_RUN_CLOSING_CODE); + assert_eq!( + records(&books), + vec![ + ("books.chart".into(), refused("closing"), true), + ("books://chart".into(), refused("closing"), false), + ] + ); + }); +} + +/// Reading a resource is reading the books (access.rs): only a token +/// with books:read reads one, and every read is recorded with what +/// came of it. +#[test] +fn a_resource_is_read_only_with_books_read_and_each_read_is_recorded() { + use ledger_store::access::Outcome; + rt().block_on(async { + let s = session("resources").await; + let (server, books) = logged("resources"); + let read = |uri: &str| ReadResourceRequestParams::new(uri); + assert!( + server + .read_resource(read("books://chart"), context(&s.peer, Some(ALL))) + .await + .is_ok() + ); + let writer = [ledger_store::auth::Scope::Write]; + let e = server + .read_resource(read("books://chart"), context(&s.peer, Some(&writer[..]))) + .await + .unwrap_err(); + assert_eq!(e.message, crate::access::INSUFFICIENT_SCOPE); + let e = server + .read_resource(read("books://chart"), context(&s.peer, None)) + .await + .unwrap_err(); + assert_eq!(e.message, crate::access::UNAUTHORIZED); + // One that ran and failed is an error, not a refusal. + assert!( + server + .read_resource(read("books://nope"), context(&s.peer, Some(ALL))) + .await + .is_err() + ); + let outcomes: Vec<_> = records(&books).into_iter().map(|r| r.1).collect(); + assert_eq!( + outcomes, + vec![ + Outcome::Ok, + refused("insufficient_scope"), + refused("unauthorized"), + Outcome::Error, + ] + ); + }); +} + +/// SharedStore INVARIANTS 2-3 at the last moment: a call that passed +/// the checks, then found the books closing (or the server stopped) +/// as it went to run, isn't run either, and is answered and recorded +/// as one refused at the start would be. The settings are held so +/// the call waits between its checks and its run. +#[test] +fn a_call_the_books_close_or_stop_under_is_refused_as_not_run() { + let rt = rt(); + for (how, stops) in [("closes", false), ("stops", true)] { + let (server, books) = logged(&format!("late-{how}")); + // The settings, held on a thread of their own until released. + let (locked, is_locked) = std::sync::mpsc::channel(); + let (release, released) = std::sync::mpsc::channel::<()>(); + let settings = server.settings.clone(); + let holder = std::thread::spawn(move || { + let _held = settings.lock().unwrap(); + locked.send(()).unwrap(); + let _ = released.recv(); + }); + is_locked.recv().unwrap(); + let answered = rt.block_on(async { + let s = session(&format!("late-{how}")).await; + let (started, began) = tokio::sync::oneshot::channel(); + let calling = server.clone(); + let cx = context(&s.peer, Some(ALL)); + let call = tokio::spawn(async move { + let _ = started.send(()); + let chart = tool_call("books.chart", serde_json::json!({})); + calling.call_tool(chart, cx).await + }); + began.await.unwrap(); + // Past its checks (no I/O between): waiting for the settings. + tokio::time::sleep(std::time::Duration::from_millis(300)).await; + if stops { + server.store.stop(); + } else { + server + .store + .closing + .store(true, std::sync::atomic::Ordering::SeqCst); + } + release.send(()).unwrap(); + call.await.unwrap() + }); + holder.join().unwrap(); + if stops { + assert_eq!(answer(answered).unwrap(), (true, STOPPED.to_string())); + // Refused after its arguments were taken: their digest too. + assert_eq!( + records(&books), + vec![("books.chart".into(), refused("stopped"), true)] + ); + } else { + let e = answered.unwrap_err(); + assert_eq!(e.code.0, NOT_RUN_CLOSING_CODE, "{e:?}"); + // Its arguments went with the request: no digest. + assert_eq!( + records(&books), + vec![("books.chart".into(), refused("closing"), false)] + ); + } + } +} + +/// access.rs INVARIANT 3: a record that can't be written stops the +/// books, and the answer says so; a call or read refused as not run +/// (closing) keeps its not-run code, so the bridge still sends it +/// again; anything else is an error in words. +#[test] +fn an_access_record_that_cant_be_written_stops_the_books_and_says_so() { + rt().block_on(async { + let s = session("unrecorded").await; + // A call refused as the books close. + let (server, _books) = unwritable("unrecorded-call"); + server.store.close().await; + let chart = tool_call("books.chart", serde_json::json!({})); + let e = server + .call_tool(chart, context(&s.peer, Some(ALL))) + .await + .unwrap_err(); + assert_eq!(e.code.0, NOT_RUN_CLOSING_CODE); + assert!(e.message.contains(crate::access::NOT_RECORDED), "{e:?}"); + assert!(server.store.stopped()); + // Any other answer refused unrun becomes the words alone. + let (server, _books) = unwritable("unrecorded-refusal"); + let call = crate::access::Call::new( + &server.access, + &context(&s.peer, Some(ALL)), + "tool", + "books.chart", + ); + let out = server + .refuse( + &call, + crate::access::STOPPED, + Err(rmcp::ErrorData::internal_error("not run", None)), + ) + .await; + assert_eq!( + answer(out).unwrap(), + (true, crate::access::NOT_RECORDED.to_string()) + ); + assert!(server.store.stopped()); + // A read refused as the books close. + let (server, _books) = unwritable("unrecorded-closing-read"); + server.store.close().await; + let e = server + .read_resource( + ReadResourceRequestParams::new("books://chart"), + context(&s.peer, Some(ALL)), + ) + .await + .unwrap_err(); + assert_eq!(e.code.0, NOT_RUN_CLOSING_CODE); + assert!(e.message.contains(crate::access::NOT_RECORDED), "{e:?}"); + assert!(server.store.stopped()); + // A read that ran and failed. + let (server, _books) = unwritable("unrecorded-read"); + let e = server + .read_resource( + ReadResourceRequestParams::new("books://nope"), + context(&s.peer, Some(ALL)), + ) + .await + .unwrap_err(); + assert_eq!(e.code, rmcp::model::ErrorCode::INTERNAL_ERROR); + assert_eq!(e.message, crate::access::NOT_RECORDED); + assert!(server.store.stopped()); + }); +} + +/// A request whose blocking work the runtime never ran (it was +/// shutting down) is answered as not run, in the words a stopped +/// server uses, and stops nothing: only a request that panicked +/// stops the books (run_blocking). +#[test] +fn a_request_the_runtime_never_ran_is_answered_not_run_and_stops_nothing() { + let dead = rt(); + let handle = dead.handle().clone(); + drop(dead); + rt().block_on(async { + let s = session("never-ran").await; + let (server, _books) = logged("never-ran"); + let chart = tool_call("books.chart", serde_json::json!({})); + let mut call = std::pin::pin!(server.call_tool(chart, context(&s.peer, Some(ALL)))); + // Its blocking work goes to the runtime that shut down. + let out = std::future::poll_fn(|cx| { + let _in = handle.enter(); + call.as_mut().poll(cx) + }) + .await; + assert_eq!(answer(out).unwrap(), (true, STOPPED.to_string())); + assert!(!server.store.stopped()); + }); +} + +/// A tool list differs by caller (scopes.rs): a client on 2026-07-28 +/// or later is told to cache it for this caller alone, and not to keep +/// it; an older one, which knows neither hint, is told neither. +#[test] +fn a_tool_list_is_cached_only_for_its_caller() { + rt().block_on(async { + let s = session("hints").await; + let books = unit_dir("hints"); + let server = served(store(&books)); + for (version, hinted) in [ + (rmcp::model::ProtocolVersion::V_2026_07_28, true), + (rmcp::model::ProtocolVersion::V_2025_11_25, false), + ] { + let mut cx = context(&s.peer, Some(ALL)); + cx.meta.set_protocol_version(version.clone()); + let list = server.list_tools(None, cx).await.unwrap(); + assert!(!list.tools.is_empty()); + assert_eq!(list.ttl_ms, hinted.then_some(0), "{version:?}"); + assert_eq!( + list.cache_scope, + hinted.then_some(rmcp::model::CacheScope::Private), + "{version:?}" + ); + } + }); +} diff --git a/crates/ledger-mcp/src/tools/assets.rs b/crates/ledger-mcp/src/tools/assets.rs new file mode 100644 index 0000000..5b708bd --- /dev/null +++ b/crates/ledger-mcp/src/tools/assets.rs @@ -0,0 +1,1009 @@ +//! Fixed assets through the server: `books.register_asset`, +//! `books.asset_schedule`, `books.list_assets`, `books.run_depreciation`, +//! `books.dispose_asset`, and the basis, label and correction tools. +//! Split whole out of `server.rs` (plan/served.md 8.2 U0a). + +use super::*; + +/// A parameter and its deprecated alias (one release): the value given, +/// noting when it came by the alias; both given and meaning different +/// values (by `same`: an amount, a day or a rule however written) is +/// refused. +fn aliased<'a>( + name: &str, + value: Option<&'a str>, + alias: &str, + old: Option<&'a str>, + notes: &mut Vec, + same: fn(&str, &str) -> bool, +) -> Result, String> { + match (value, old) { + (Some(v), Some(o)) if !same(v, o) => Err(format!( + "{name} {v:?} and its old name {alias} {o:?} disagree: give {name} only" + )), + (Some(v), _) => Ok(Some(v)), + (None, Some(o)) => { + notes.push(format!("{alias} is now {name}")); + Ok(Some(o)) + } + (None, None) => Ok(None), + } +} + +/// Two amounts given for one figure mean the same (`"100"` and +/// `"100.00"`); text that isn't an amount compares as text. +fn same_amount(a: &str, b: &str) -> bool { + match (a.trim().parse::(), b.trim().parse::()) { + (Ok(x), Ok(y)) => x == y, + _ => a == b, + } +} + +/// Two days given for one date mean the same. +fn same_day(a: &str, b: &str) -> bool { + match (parse_date(a), parse_date(b)) { + (Ok(x), Ok(y)) => x == y, + _ => a == b, + } +} + +/// Two basis rules mean the same, by wire or pre-P3 name +/// (`"carryover"` and `"carryover_723"`). +fn same_rule(a: &str, b: &str) -> bool { + let rule = |r: &str| { + ledger_core::BasisRule::parse(r).or_else(|| ledger_core::BasisRule::parse_legacy(r)) + }; + match (rule(a), rule(b)) { + (Some(x), Some(y)) => x == y, + _ => a == b, + } +} + +/// A basis rule from a tool: its wire name, or its pre-P3 name (an alias +/// for one release, noted). +fn parse_basis_rule(rule: &str, notes: &mut Vec) -> Result { + if let Some(r) = ledger_core::BasisRule::parse(rule) { + return Ok(r); + } + match ledger_core::BasisRule::parse_legacy(rule) { + Some(r) => { + notes.push(format!("the rule {rule:?} is now {:?}", r.key())); + Ok(r) + } + None => Err(format!( + "basis rule {rule:?}: expected purchase, carryover_723, or personal_use_conversion" + )), + } +} + +/// A basis record from a tool's facts, checked (AssetBasis::check). A +/// contribution's value at contribution is required: it is a fact the +/// human gives, never assumed from the cost. +fn parse_asset_basis( + rule: &str, + adjusted: Option<&str>, + fmv: Option<&str>, + method: Option<&str>, + source: Option<&BasisSourceIn>, + notes: &mut Vec, +) -> Result { + let rule = parse_basis_rule(rule, notes)?; + if rule.is_contribution() && fmv.is_none() { + return Err(format!( + "a contribution ({}) needs fmv_at_contribution: its fair market value when contributed (ask the human; the books' amount in the contribution entry, if that is what it was worth)", + rule.key() + )); + } + let basis = ledger_core::AssetBasis { + rule, + contributor_adjusted_basis: adjusted.map(parse_amount).transpose()?, + fmv_at_contribution: fmv.map(parse_amount).transpose()?, + sec704c_method: method + .map(|m| { + ledger_core::Sec704cMethod::parse(m).ok_or_else(|| { + format!( + "sec704c_method {m:?}: expected traditional, traditional_with_curative, or remedial" + ) + }) + }) + .transpose()?, + source: source + .map(|s| -> Result<_, String> { + Ok(ledger_core::settings::Source { + decided_by: s.decided_by.clone(), + decided_on: match &s.decided_on { + Some(d) => parse_date(d)?, + None => today(), + }, + citation: s.citation.clone(), + note: s.note.clone(), + }) + }) + .transpose()?, + }; + basis + .check() + .map_err(|why| format!("bad tax basis: {why}"))?; + Ok(basis) +} + +/// An asset's basis facts and figures for a tool's output. +fn basis_out(a: &ledger_core::Asset) -> Result, String> { + let Some(b) = &a.basis else { + return Ok(None); + }; + let Some(f) = a.basis_figures().map_err(|e| e.to_string())? else { + return Ok(None); + }; + use ledger_core::BasisRule; + let loss = f + .built_in_gain_704c + .filter(|g| g.is_sign_negative() && !g.is_zero()); + Ok(Some(AssetBasisOut { + rule: b.rule.key().to_string(), + contributor_adjusted_basis: b.contributor_adjusted_basis.map(money), + fmv_at_contribution: a.fmv_at_contribution().map(money), + fmv_recorded: a.fmv_is_recorded(), + tax_basis_723: f.tax_basis_723.map(money), + depreciable_basis: money(f.depreciable_basis), + built_in_gain_704c: f.built_in_gain_704c.map(money), + sec704c_1c_note: loss.map(|l| { + format!( + "a built-in loss of {}: it counts only for the contributing member(s); for the others the basis is treated as the value at contribution (IRC §704(c)(1)(C))", + money(-l) + ) + }), + sec704c_method: b.sec704c_method.map(|m| m.key().to_string()), + source: b.source.as_ref().map(|s| BasisSourceOut { + decided_by: s.decided_by.clone(), + decided_on: s.decided_on.to_string(), + citation: s.citation.clone(), + note: s.note.clone(), + }), + sources: BasisSourcesOut { + tax_basis_723: b.rule.is_contribution().then(|| "IRC §723".to_string()), + depreciable_basis: match b.rule { + BasisRule::Purchase => "IRC §1012", + BasisRule::Carryover723 => "IRC §723", + BasisRule::PersonalUseConversion => "Treas. Reg. §1.168(i)-4(b)", + } + .to_string(), + built_in_gain_704c: b + .rule + .is_contribution() + .then(|| "Treas. Reg. §1.704-3(a)(3)(ii)".to_string()), + }, + })) +} + +/// An asset named by id or by tag (exactly one). +fn resolve_asset(store: &Store, id: Option, tag: Option<&str>) -> Result { + match (id, tag) { + (Some(id), None) => store + .ledger() + .get_asset(id) + .map(|a| a.id) + .ok_or_else(|| format!("unknown asset {id}")), + (None, Some(tag)) => store + .ledger() + .asset_by_tag(tag) + .map(|a| a.id) + .ok_or_else(|| format!("no asset is tagged {tag:?}")), + _ => Err("name the asset by asset_id or by tag (one of them)".into()), + } +} + +#[tool_router(router = assets_router, vis = "pub(super)")] +impl BooksServer { + #[tool( + name = "books.label_asset", + title = "Label a fixed asset", + description = "Change a fixed asset's description, link it to the entry that acquired it (acquisition_entry), or set or change its tag (the label on the thing: letters, digits, - _ . /, at most 32; unique ignoring case and never reused by another asset, even after a retag or disposal) and its manufacturer serial number. The asset is its id or current tag. Journaled. Print labels with books.asset_labels.", + annotations( + read_only_hint = false, + destructive_hint = false, + open_world_hint = false + ) + )] + async fn label_asset( + &self, + params: Parameters, + ) -> Result, String> { + let p = params.0; + let mut store = self.lock()?; + let id = resolve_asset_ref(&store, &p.asset)?; + let clear = p.clear_serial.unwrap_or(false); + if clear && p.serial.is_some() { + return Err("pass serial or clear_serial, not both".into()); + } + if p.tag.is_none() + && p.serial.is_none() + && !clear + && p.acquisition_entry.is_none() + && p.description.is_none() + { + return Err( + "nothing to change: pass tag, serial, clear_serial, description, or acquisition_entry" + .into(), + ); + } + if let Some(d) = &p.description { + store + .ledger() + .check_asset_description(id, d) + .map_err(|e| e.to_string())?; + } + if let Some(entry) = p.acquisition_entry { + store + .ledger() + .check_asset_acquisition(id, entry) + .map_err(|e| e.to_string())?; + } + // Both checked first, so a bad serial doesn't leave a new tag behind. + if let Some(tag) = &p.tag { + store + .ledger() + .check_asset_tag_for(Some(id), tag) + .map_err(|e| e.to_string())?; + } + let serial = if clear { None } else { p.serial.as_deref() }; + if p.serial.is_some() || clear { + store + .ledger() + .check_asset_serial(id, serial) + .map_err(|e| e.to_string())?; + } + if let Some(tag) = &p.tag { + store.set_asset_tag(id, tag).map_err(|e| e.to_string())?; + } + if p.serial.is_some() || clear { + store + .set_asset_serial(id, serial) + .map_err(|e| e.to_string())?; + } + if let Some(entry) = p.acquisition_entry { + store + .set_asset_acquisition(id, entry) + .map_err(|e| e.to_string())?; + } + if let Some(d) = &p.description { + store + .set_asset_description(id, d) + .map_err(|e| e.to_string())?; + } + let a = store.ledger().get_asset(id).expect("resolved"); + Ok(Json(LabelAssetOut { + asset_id: id, + tag: a.tag.clone(), + serial: a.serial.clone(), + acquisition_entry: a.acquired_by, + description: a.description.clone(), + })) + } + + #[tool( + name = "books.correct_asset", + title = "Correct a fixed asset", + description = "Correct a registered fixed asset's cost, salvage value, useful life, or in-service date (never before its acquisition entry's date) when it was registered wrong, with the reason; omitted fields keep their values. Refused while the books carry depreciation for it (a run posted and not reversed: correct with entries then). Its tag, serial, links and basis record stay (a recorded value at contribution never moves; a pre-P3 basis reads it as the book cost, so its built-in gain moves, noted); correct the entry that put it on the books too (books.export fixed_asset_register shows any difference). Journaled with the old figures.", + annotations( + read_only_hint = false, + destructive_hint = true, + open_world_hint = false + ) + )] + async fn correct_asset( + &self, + params: Parameters, + ) -> Result, String> { + let p = params.0; + let mut notes = Vec::new(); + let in_service = aliased( + "in_service_date", + p.in_service_date.as_deref(), + "in_service", + p.in_service.as_deref(), + &mut notes, + same_day, + )?; + let mut store = self.lock()?; + let id = resolve_asset_ref(&store, &p.asset)?; + let a = store.ledger().get_asset(id).expect("resolved").clone(); + let base = store.ledger().base_currency(); + let fix = ledger_core::AssetCorrection { + cost: match &p.cost { + Some(c) => Money::new(parse_amount(c)?, base), + None => a.cost, + }, + salvage_value: match &p.salvage_value { + Some(c) => Money::new(parse_amount(c)?, base), + None => a.salvage_value, + }, + useful_life_months: p.useful_life_months.unwrap_or(a.useful_life_months), + in_service_date: match in_service { + Some(d) => parse_date(d)?, + None => a.in_service_date, + }, + }; + let before = a.built_in_gain_704c(); + store + .correct_asset(id, fix, &p.reason) + .map_err(|e| e.to_string())?; + let ledger = store.ledger(); + let a = ledger.get_asset(id).expect("resolved"); + if a.built_in_gain_704c() != before { + notes.push(format!( + "its basis was recorded before P3 with no value at contribution, which reads as the book cost, so its §704(c) built-in gain moved with the cost ({} to {}): record the value at contribution with books.set_asset_tax_basis", + before.map_or("none".into(), money), + a.built_in_gain_704c().map_or("none".into(), money) + )); + } + Ok(Json(AssetSummaryOne { + asset_id: id, + tag: a.tag.clone(), + cost: money(a.cost.amount), + salvage_value: money(a.salvage_value.amount), + useful_life_months: a.useful_life_months, + in_service_date: a.in_service_date.to_string(), + acquired_date: ledger + .asset_acquired_date(id) + .expect("known asset") + .to_string(), + basis: basis_out(a)?, + notes, + })) + } + + #[tool( + name = "books.set_asset_tax_basis", + title = "Set an asset's tax basis", + description = "Record how the company got a fixed asset and its tax figures beside book value. rule: purchase (basis is cost, IRC §1012); carryover_723 (a member's business property: their adjusted basis carries over, IRC §723); personal_use_conversion (a member's personal property: §723 basis likewise, depreciable basis the lesser of value and that basis, Treas. Reg. §1.168(i)-4(b)). A contribution needs contributor_adjusted_basis and fmv_at_contribution (ask the human; never assume the cost); built_in_gain_704c = value − §723 basis (Treas. Reg. §1.704-3(a)(3)(ii)), a loss counting only for the contributor. sec704c_method once chosen; basis_source: who says so. Journaled; book figures don't change.", + annotations( + read_only_hint = false, + destructive_hint = false, + open_world_hint = false + ) + )] + async fn set_asset_tax_basis( + &self, + params: Parameters, + ) -> Result, String> { + let p = params.0; + let mut notes = Vec::new(); + let adjusted = aliased( + "contributor_adjusted_basis", + p.contributor_adjusted_basis.as_deref(), + "contributor_basis", + p.contributor_basis.as_deref(), + &mut notes, + same_amount, + )?; + let basis = parse_asset_basis( + &p.rule, + adjusted, + p.fmv_at_contribution.as_deref(), + p.sec704c_method.as_deref(), + p.basis_source.as_ref(), + &mut notes, + )?; + let mut store = self.lock()?; + let id = resolve_asset_ref(&store, &p.asset)?; + store + .set_asset_basis(id, basis) + .map_err(|e| e.to_string())?; + let a = store.ledger().get_asset(id).expect("resolved"); + Ok(Json(SetAssetTaxBasisOut { + asset_id: id, + basis: basis_out(a)?.ok_or("the basis just set has no figures")?, + notes, + })) + } + + #[tool( + name = "books.asset_labels", + title = "Asset label sheet", + description = "A printable sheet of asset labels (a PDF, handed over: run its download to save it): each label shows the company, the tag, a QR code of the tag, the description, and the serial number. Avery 5160/8160 (US letter, 30 labels of 1\" x 2-5/8\"); skip reuses a partly used sheet. Omit assets for every active asset with a tag. Save the file and tell the human to print it at 100% scale.", + annotations(read_only_hint = true, open_world_hint = false) + )] + async fn asset_labels( + &self, + params: Parameters, + ) -> Result, String> { + let p = params.0; + let store = self.lock()?; + let ledger = store.ledger(); + let assets: Vec<&ledger_core::Asset> = match &p.assets { + Some(refs) => refs + .iter() + .map(|r| { + let id = resolve_asset_ref(&store, r)?; + Ok(ledger.get_asset(id).expect("resolved")) + }) + .collect::>()?, + None => ledger + .registered_assets() + .filter(|a| a.status == ledger_core::AssetStatus::Active && a.tag.is_some()) + .collect(), + }; + let labels = assets + .iter() + .map(|a| { + Ok(ledger_import::AssetLabel { + tag: a.tag.clone().ok_or_else(|| { + format!( + "asset {} has no tag: give it one with books.label_asset first", + a.id + ) + })?, + description: a.description.clone(), + serial: a.serial.clone(), + }) + }) + .collect::, String>>()?; + let pdf = ledger_import::asset_labels_pdf( + &labels, + &self.settings().company.name, + ledger_import::LabelSheet::Avery5160, + p.skip.unwrap_or(0) as usize, + ) + .map_err(|e| e.to_string())?; + let file = self.hand_over( + &book_name(&store), + &format!("asset-labels-{}.pdf", today()), + "application/pdf", + pdf, + )?; + Ok(Json(AssetLabelsOut { + file, + tags: labels.into_iter().map(|l| l.tag).collect(), + })) + } + + #[tool( + name = "books.register_asset", + title = "Register a fixed asset", + description = "Register a fixed asset (equipment, vehicles, buildings). Links three accounts: cost (asset), accumulated depreciation (asset, contra), depreciation expense. in_service_date (ready and available for its use) starts depreciation; the acquisition_entry (its purchase or contribution, dated on or before that) gives the acquired date, which puts it on the register. Its tax basis (basis_rule and the contribution facts) as books.set_asset_tax_basis takes it. Depreciation is straight-line, book basis only — tax depreciation (MACRS, Section 179) stays with the CPA.", + annotations( + read_only_hint = false, + destructive_hint = false, + open_world_hint = false + ) + )] + async fn register_asset( + &self, + params: Parameters, + ) -> Result, String> { + let p = params.0; + let mut notes = Vec::new(); + let in_service_date = parse_date( + aliased( + "in_service_date", + p.in_service_date.as_deref(), + "purchase_date", + p.purchase_date.as_deref(), + &mut notes, + same_day, + )? + .ok_or("in_service_date is required: the day it was placed in service")?, + )?; + let mut store = self.lock()?; + let base = store.ledger().base_currency(); + let cost_account = resolve_account(&store, &p.cost_account)?; + let accum_depr_account = resolve_account(&store, &p.accum_depr_account)?; + let expense_account = resolve_account(&store, &p.expense_account)?; + let tag = match p.tag { + Some(t) => t, + None => store.ledger().next_asset_tag(self.settings().tag_prefix()), + }; + // Checked before registering, so a bad tag or serial registers + // nothing. + store + .ledger() + .check_asset_tag_for(None, &tag) + .map_err(|e| e.to_string())?; + if let Some(serial) = &p.serial { + ledger_core::assets::check_serial(serial) + .map_err(|why| format!("bad serial number: {why}"))?; + } + if let Some(entry) = p.acquisition_entry { + let e = store + .ledger() + .get_entry(entry) + .ok_or_else(|| format!("unknown entry {entry}"))?; + if !e + .lines() + .iter() + .any(|l| l.account == cost_account && l.side == Side::Debit) + || e.status() != ledger_core::EntryStatus::Posted + { + return Err(format!( + "entry {entry} can't be the acquisition: it must be posted and debit the cost account" + )); + } + let reversed = (1..store.ledger().next_entry_id()) + .filter_map(|id| store.ledger().get_entry(id)) + .any(|x| x.reversal_of() == Some(entry)); + if e.reversal_of().is_some() || reversed { + return Err(format!( + "entry {entry} can't be the acquisition: it is a reversal or has been reversed (link the entry that stands)" + )); + } + if e.date() > in_service_date { + return Err(format!( + "entry {entry} can't be the acquisition: it is dated {}, after the in-service date {in_service_date} (an asset is acquired before it serves: correct the in-service date, or the entry)", + e.date() + )); + } + } + // Checked before registering, so a bad tax basis registers nothing + // (found by the spec-blind MCP suite, 2026-10-15). + let rule = aliased( + "basis_rule", + p.basis_rule.as_deref(), + "tax_basis_rule", + p.tax_basis_rule.as_deref(), + &mut notes, + same_rule, + )?; + let adjusted = aliased( + "contributor_adjusted_basis", + p.contributor_adjusted_basis.as_deref(), + "contributor_basis", + p.contributor_basis.as_deref(), + &mut notes, + same_amount, + )?; + let basis = match rule { + Some(rule) => Some(parse_asset_basis( + rule, + adjusted, + p.fmv_at_contribution.as_deref(), + p.sec704c_method.as_deref(), + p.basis_source.as_ref(), + &mut notes, + )?), + None if adjusted.is_some() + || p.fmv_at_contribution.is_some() + || p.sec704c_method.is_some() + || p.basis_source.is_some() => + { + return Err("the basis facts need basis_rule".into()); + } + None => None, + }; + let new = NewAsset { + description: p.description, + in_service_date, + cost: Money::new(parse_amount(&p.cost)?, base), + salvage_value: Money::new( + parse_amount(p.salvage_value.as_deref().unwrap_or("0"))?, + base, + ), + useful_life_months: p.useful_life_months, + cost_account, + accum_depr_account, + expense_account, + }; + // One registration is several commands: rehearse them all on a + // copy first, and ask for the journal feature the basis needs + // (B-27), so a refusal anywhere (the tag, the serial, the link, the + // basis, books waiting for their format upgrade) journals nothing. + { + let mut copy = store.ledger().clone(); + let id = copy + .register_asset(new.clone()) + .map_err(|e| e.to_string())?; + copy.set_asset_tag(id, &tag).map_err(|e| e.to_string())?; + if let Some(serial) = &p.serial { + copy.set_asset_serial(id, Some(serial)) + .map_err(|e| e.to_string())?; + } + if let Some(entry) = p.acquisition_entry { + copy.set_asset_acquisition(id, entry) + .map_err(|e| e.to_string())?; + } + if let Some(basis) = &basis { + copy.set_asset_basis(id, basis.clone()) + .map_err(|e| e.to_string())?; + store + .ensure_feature(ledger_store::format::ASSET_BASIS) + .map_err(|e| e.to_string())?; + } + } + let asset_id = store.register_asset(new).map_err(|e| e.to_string())?; + store + .set_asset_tag(asset_id, &tag) + .map_err(|e| e.to_string())?; + if let Some(serial) = &p.serial { + store + .set_asset_serial(asset_id, Some(serial)) + .map_err(|e| e.to_string())?; + } + if let Some(entry) = p.acquisition_entry { + store + .set_asset_acquisition(asset_id, entry) + .map_err(|e| e.to_string())?; + } + if let Some(basis) = basis { + store + .set_asset_basis(asset_id, basis) + .map_err(|e| e.to_string())?; + } + let ledger = store.ledger(); + let a = ledger.get_asset(asset_id).expect("registered"); + Ok(Json(RegisterAssetOut { + asset_id, + tag, + in_service_date: a.in_service_date.to_string(), + acquired_date: ledger + .asset_acquired_date(asset_id) + .expect("registered") + .to_string(), + basis: basis_out(a)?, + notes, + })) + } + + #[tool( + name = "books.asset_schedule", + title = "Depreciation schedule", + description = "Month-by-month straight-line depreciation schedule for an asset: period, amount, accumulated, book value. Depreciation accrues by the day: a partial first month, the life's final month, and a month the schedule stops in (through a date, or a disposed asset's disposal day, which is held) are prorated by days; the lifetime total is exactly cost minus salvage.", + annotations(read_only_hint = true, open_world_hint = false) + )] + async fn asset_schedule( + &self, + params: Parameters, + ) -> Result, String> { + let p = params.0; + let store = self.lock()?; + let asset_id = resolve_asset(&store, p.asset_id, p.tag.as_deref())?; + let asset = store + .ledger() + .get_asset(asset_id) + .ok_or_else(|| format!("unknown asset {asset_id}"))? + .clone(); + // The schedule clamps to the depreciation end internally, so an + // unbounded default just means "the whole life". + let through = match p.through { + Some(t) => parse_date(&t)?, + None => NaiveDate::MAX, + }; + let schedule = depreciation_schedule(&asset, through) + .map_err(|e| format!("asset {asset_id}'s schedule: {e}"))? + .into_iter() + .map(|l| ScheduleLineOut { + period: l.period, + amount: money(l.amount), + accumulated: money(l.accumulated), + book_value: money(l.book_value), + }) + .collect(); + Ok(Json(AssetScheduleOut { + asset_id: asset.id, + description: asset.description.clone(), + cost: money(asset.cost.amount), + salvage_value: money(asset.salvage_value.amount), + useful_life_months: asset.useful_life_months, + status: match asset.status { + AssetStatus::Active => "active".to_string(), + AssetStatus::Disposed { .. } => "disposed".to_string(), + }, + schedule, + })) + } + + #[tool( + name = "books.list_assets", + title = "List fixed assets", + description = "List every registered fixed asset as of a date (as_of, default today): in-service and acquired dates, cost, and per asset accumulated_posted (what the books carry: runs posted, less reversals), accumulated_scheduled_through (what the schedule says is due by as_of), unposted_depreciation (the difference: run books.run_depreciation for it) and book_value (cost less posted: what the books carry); status (active or disposed) and held (acquired and not yet disposed) are as of as_of too, so a later disposal never shows. With its basis facts and tax figures. An asset whose figures can't be computed (one an earlier version took) lists with null figures and schedule_error.", + annotations(read_only_hint = true, open_world_hint = false) + )] + async fn list_assets( + &self, + params: Parameters, + ) -> Result, String> { + let p = params.0; + let as_of = match &p.as_of { + Some(d) => parse_date(d)?, + None => today(), + }; + let store = self.lock()?; + let ledger = store.ledger(); + // The register's rows (one derivation, reports::register_row): an + // asset acquired by as_of has one; a later one has no figures yet. + let assets = ledger + .registered_assets() + .map(|a| { + let acquired = ledger.asset_acquired_date(a.id).expect("registered"); + let disposed = + matches!(a.status, AssetStatus::Disposed { date, .. } if date <= as_of); + // One asset whose figures can't be computed (one an earlier + // version took) lists with why, never failing the whole + // list. + let row = ledger + .accumulated_posted(a.id, as_of) + .map(|posted| std::collections::BTreeMap::from([(a.id, posted)])) + .and_then(|posted| reports::register_row(ledger, a, as_of, &posted)); + let (row, schedule_error) = match row { + Ok(row) => (row, None), + Err(e) => (None, Some(e.to_string())), + }; + let row = row.as_ref(); + let figure = + |f: fn(&ledger_core::AssetRegisterRow) -> Decimal| row.map(|r| money(f(r))); + Ok(AssetSummary { + asset_id: a.id, + tag: a.tag.clone(), + serial: a.serial.clone(), + acquisition_entry: a.acquired_by, + basis: basis_out(a)?, + description: a.description.clone(), + in_service_date: a.in_service_date.to_string(), + acquired_date: acquired.to_string(), + cost: money(a.cost.amount), + useful_life_months: a.useful_life_months, + // As of as_of, like every figure here: a disposal + // after it hasn't happened yet (rules.md B-33). + status: if disposed { "disposed" } else { "active" }.to_string(), + held: acquired <= as_of && !disposed, + accumulated_posted: figure(|r| r.accumulated_posted), + accumulated_scheduled_through: figure(|r| r.accumulated_scheduled), + unposted_depreciation: figure(|r| { + r.accumulated_scheduled - r.accumulated_posted + }), + book_value: figure(|r| r.book_value), + schedule_error, + }) + }) + .collect::>()?; + Ok(Json(ListAssetsOut { + as_of: as_of.to_string(), + assets, + })) + } + + #[tool( + name = "books.run_depreciation", + title = "Post depreciation", + description = "Post one month of depreciation (Dr Depreciation Expense / Cr Accumulated Depreciation) for one asset, or for every active asset when asset_id is omitted: what the schedule says is due through the month's end, so a month skipped (or reversed) is caught up in the month run (caught_up says how much; a material miss is a restatement for the CPA, ASC 250). Idempotent: re-running a posted period returns the original entry. Nothing due (rounds to nothing, already caught up, life over) is listed under skipped. Exempt from the receipt policy: the asset and its schedule are the evidence.", + annotations( + read_only_hint = false, + destructive_hint = true, + idempotent_hint = true, + open_world_hint = false + ) + )] + async fn run_depreciation( + &self, + params: Parameters, + ) -> Result, String> { + let p = params.0; + let (py, pm) = parse_period_mcp(&p.period)?; + let period_end = last_day_of_month(py, pm)?; + let mut store = self.lock()?; + let targets: Vec<(u64, String)> = match p.asset_id { + Some(id) => { + let a = store + .ledger() + .get_asset(id) + .ok_or_else(|| format!("unknown asset {id}"))?; + vec![(id, a.description.clone())] + } + None => store + .ledger() + .registered_assets() + .filter(|a| a.status == AssetStatus::Active) + .map(|a| (a.id, a.description.clone())) + .collect(), + }; + let mut runs = Vec::with_capacity(targets.len()); + let mut skipped = Vec::new(); + for (id, description) in targets { + let skip = |reason: String| SkippedDepreciation { + asset_id: id, + description: description.clone(), + reason, + }; + // Run-all skips assets not yet in service, and any whose + // schedule can't be computed (an earlier version's), instead of + // failing the whole batch. + if p.asset_id.is_none() + && let Some(asset) = store.ledger().get_asset(id) + && asset.in_service_date > period_end + { + skipped.push(skip(format!( + "placed in service {}, after {}", + asset.in_service_date, p.period + ))); + continue; + } + let r = match store.run_depreciation(id, &p.period) { + Ok(r) => r, + Err(ledger_store::StoreError::Ledger( + e @ (ledger_core::LedgerError::TooLargeToCompute(_) + | ledger_core::LedgerError::DateOutOfRange(_)), + )) if p.asset_id.is_none() => { + skipped.push(skip(format!("its schedule can't be computed: {e}"))); + continue; + } + Err(e) => return Err(e.to_string()), + }; + let Some(entry_id) = r.entry_id else { + skipped + .push(skip(r.nothing_due.unwrap_or_else(|| { + format!("nothing is due for {}", p.period) + }))); + continue; + }; + runs.push(DepreciationRunOut { + asset_id: r.asset_id, + description, + period: r.period, + amount: money(r.amount), + caught_up: money((r.amount - r.scheduled).max(Decimal::ZERO)), + entry_id, + already_posted: r.already_posted, + }); + } + Ok(Json(RunDepreciationOut { runs, skipped })) + } + + #[tool( + name = "books.dispose_asset", + title = "Dispose of a fixed asset", + description = "Dispose of a fixed asset (sell or retire). Without confirmed=true returns a preview (book value, gain/loss, the depreciation through the disposal day, which is held, and the disposal entry's lines) and posts nothing. With confirmed=true posts it after the receipt policy: file the bill of sale with books.file_document and pass its ids as receipt_attachment_ids. Depreciation a run posted past the disposal day is credited back (excess_depreciation); a later month's run is refused (reverse it first). Name the buyer (a party id or name): [books] parties = \"require\" refuses a sale without one.", + annotations( + read_only_hint = false, + destructive_hint = true, + open_world_hint = false + ) + )] + async fn dispose_asset( + &self, + params: Parameters, + ) -> Result, String> { + let p = params.0; + let mut store = self.lock()?; + let date = parse_date(&p.date)?; + let base = store.ledger().base_currency(); + let proceeds = Money::new(parse_amount(&p.proceeds)?, base); + let proceeds_account = resolve_account(&store, &p.proceeds_account)?; + let gain_loss_account = resolve_account(&store, &p.gain_loss_account)?; + let asset_id = resolve_asset(&store, p.asset_id, p.tag.as_deref())?; + let buyer = p + .buyer + .as_deref() + .map(|r| resolve_party(&store, r)) + .transpose()?; + let asset = store + .ledger() + .get_asset(asset_id) + .ok_or_else(|| format!("unknown asset {asset_id}"))? + .clone(); + let preview = store + .dispose_preview(asset_id, date, proceeds) + .map_err(|e| e.to_string())?; + + // The would-be disposal entry's lines, for the preview and the + // receipt-policy check. + let raw: Vec<(AccountId, Side, Money)> = Store::disposal_lines( + &asset, + &preview, + proceeds.amount, + proceeds_account, + gain_loss_account, + ) + .into_iter() + .map(|(a, side, amount)| (a, side, Money::new(amount, base))) + .collect(); + // Disposals are money movement: the bill of sale is the receipt. + let chart = store.ledger().chart(); + let code_of = |id: AccountId| { + chart + .get(id) + .map(|a| a.code.clone()) + .ok_or_else(|| format!("unknown account {id}")) + }; + let adjusts = + !(preview.excess_depreciation.is_zero() && preview.catch_up_depreciation.is_zero()); + let codes: Vec = [ + asset.accum_depr_account, + proceeds_account, + asset.cost_account, + gain_loss_account, + ] + .into_iter() + .chain(adjusts.then_some(asset.expense_account)) + .map(code_of) + .collect::>()?; + let code_refs: Vec<&str> = codes.iter().map(|s| s.as_str()).collect(); + let debits = preview.accumulated + + proceeds.amount + + preview.catch_up_depreciation + + if preview.gain_loss < Decimal::ZERO { + -preview.gain_loss + } else { + Decimal::ZERO + }; + let receipt_required = self + .settings() + .policy + .receipt_required_for(&code_refs, debits); + + let out = DisposeAssetOut { + status: "preview".to_string(), + asset_id, + entry_id: 0, + accumulated: money(preview.accumulated), + book_value: money(preview.book_value), + proceeds: money(proceeds.amount), + gain_loss: money(preview.gain_loss), + final_depreciation: money(preview.final_depreciation), + excess_depreciation: money(preview.excess_depreciation), + catch_up_depreciation: money(preview.catch_up_depreciation), + lines: preview_lines_for(&store, &raw)?, + receipt_required, + }; + if !p.confirmed.unwrap_or(false) { + return Ok(Json(out)); + } + let attachment_ids = p.receipt_attachment_ids.unwrap_or_default(); + if receipt_required && attachment_ids.is_empty() { + return Err(ReceiptRequiredError { + code: "receipt_required".to_string(), + message: format!( + "disposing {} moves {} in debits ({}); file the bill of sale with books.file_document, then retry with receipt_attachment_ids", + asset.description, + money(debits), + self.settings().policy.describe() + ), + entry_id: None, + asset_id: Some(asset_id), + debits: debits.to_string(), + policy: self.settings().policy.describe(), + remedy_tool: "books.file_document".to_string(), + remedy_hint: + "file the bill of sale with books.file_document, then retry books.dispose_asset with confirmed=true and receipt_attachment_ids=[]" + .to_string(), + split_group: Vec::new(), + } + .to_tool_error()); + } + for aid in &attachment_ids { + store.check_linkable(*aid).map_err(|e| e.to_string())?; + } + if buyer.is_none() + && !proceeds.amount.is_zero() + && self.settings().parties == ledger_config::PartyPolicy::Require + { + return Err(party_required( + "pass buyer: the party the asset was sold to", + )); + } + let entry_id = store + .dispose_asset( + asset_id, + date, + proceeds, + proceeds_account, + gain_loss_account, + ) + .map_err(|e| e.to_string())?; + for aid in &attachment_ids { + store + .link_receipt(*aid, entry_id) + .map_err(|e| e.to_string())?; + } + match buyer { + Some(b) => store.set_entry_party(entry_id, Some(b)), + None if proceeds.amount.is_zero() => { + store.set_entry_no_party(entry_id, "asset retired with no proceeds") + } + None => Ok(()), + } + .map_err(|e| e.to_string())?; + let mut posted = out; + posted.status = "posted".to_string(); + posted.entry_id = entry_id; + Ok(Json(posted)) + } +} diff --git a/crates/ledger-mcp/src/tools/attachments.rs b/crates/ledger-mcp/src/tools/attachments.rs new file mode 100644 index 0000000..a9d0121 --- /dev/null +++ b/crates/ledger-mcp/src/tools/attachments.rs @@ -0,0 +1,541 @@ +//! Evidence through the server: receipts and filed documents, and their +//! links to whatever they belong to. Split whole out of `server.rs` +//! (plan/served.md 8.2 U0a). + +use super::*; + +/// Resolve a tool's subject: its kind, and its id resolved the way the +/// other tools resolve references (an account by code or id, an asset by +/// tag or id, a party by id or name), checked to exist. +fn resolve_subject(store: &Store, s: &SubjectParam) -> Result { + use ledger_store::Subject; + let kind = s.kind.trim(); + let text = match &s.id { + None => None, + Some(RefId::Number(n)) => Some(n.to_string()), + Some(RefId::Text(t)) => Some(t.trim().to_string()), + }; + if Subject::from_parts(kind, 0).is_none() { + return Err(format!( + "unknown subject kind {kind:?}: expected one of {SUBJECT_KINDS}" + )); + } + if kind == "company" { + return match text { + None => Ok(Subject::Company), + Some(_) => Err("the company subject takes no id".into()), + }; + } + let t = text.ok_or_else(|| format!("a {kind} subject needs its id"))?; + let sub = match kind { + "account" => Subject::Account(resolve_account(store, &t)?), + "asset" => Subject::Asset(resolve_asset_ref(store, &t)?), + "party" => Subject::Party(resolve_party(store, &t)?), + // A JSON number is an id, never a name. + "project" => match &s.id { + Some(RefId::Number(n)) => Subject::Project(*n), + _ => Subject::Project(u64::from(resolve_class(store, &t)?)), + }, + k => { + let id = t + .parse::() + .map_err(|_| format!("a {k} subject's id is a number, got {t:?}"))?; + Subject::from_parts(k, id).expect("kind checked") + } + }; + if !store.subject_exists(sub).map_err(|e| e.to_string())? { + return Err(format!("{} doesn't exist", sub.describe())); + } + Ok(sub) +} + +/// Check every `link_to` subject before anything is filed: each resolves, +/// its journal feature is declared, none twice, none the entry the file is +/// filed on (all or nothing). +fn check_link_to( + store: &Store, + filed_on: Option, + link_to: &[SubjectParam], +) -> Result, String> { + let mut out: Vec = Vec::new(); + for s in link_to { + let sub = resolve_subject(store, s)?; + // A link to a subject kind an older version doesn't know needs its + // journal feature, asked here, before the file is filed: a refusal + // leaves nothing half-filed (rules.md B-27). + if let Some(f) = Store::subject_feature(sub) { + store.ensure_feature(f).map_err(|e| e.to_string())?; + } + if out.contains(&sub) { + return Err(format!("link_to names {} twice", sub.describe())); + } + if filed_on.map(ledger_store::Subject::Entry) == Some(sub) { + return Err(format!( + "link_to names {}, which it is filed on already", + sub.describe() + )); + } + out.push(sub); + } + Ok(out) +} + +#[tool_router(router = attachments_router, vis = "pub(super)")] +impl BooksServer { + #[tool( + name = "books.attach_receipt", + title = "Attach a receipt", + description = "Attach a receipt to an entry, or file it unlinked and link later with books.link_receipt. The bytes: upload_id, from books.begin_upload and `wanderbooks upload` (a file never passes through you). Files are content-addressed and deduplicated. link_to links it to more subjects in the same call (another entry it also supports, an asset, a party, a project, ...: see books.link_attachment); every subject is checked first, so one bad subject files nothing. A file holding a full TIN is refused: one the server read or wrote with TINs, or text with a TIN the vault keeps or an SSN or EIN printed 3-2-4 or 2-7 with hyphens.", + annotations( + read_only_hint = false, + destructive_hint = false, + open_world_hint = false + ) + )] + async fn attach_receipt( + &self, + params: Parameters, + ) -> Result, String> { + let p = params.0; + let mut store = self.lock()?; + if let Some(e) = p.entry_id + && store.ledger().get_entry(e).is_none() + { + return Err(format!("unknown entry {e}")); + } + let link_to = check_link_to(&store, p.entry_id, &p.link_to)?; + // Taken once everything else is known good. + let file = self.take_file(files::Given { + upload_id: &p.upload_id, + purposes: files::FILED, + })?; + let (file_name, mime_type) = named(p.file_name, p.mime_type, &file)?; + let (bytes, claim) = (file.bytes, file.claim); + self.refuse_tin_file(&store, &bytes, &mime_type, &file_name)?; + let (attachment_id, sha256, deduplicated) = store + .attach_receipt( + p.entry_id, + &file_name, + &mime_type, + &bytes, + p.description.as_deref(), + ) + .map_err(|e| e.to_string())?; + files::filed(claim); + for sub in link_to { + store + .link_attachment(attachment_id, sub) + .map_err(|e| e.to_string())?; + } + Ok(Json(AttachReceiptOut { + links: links_out(&store, attachment_id)?, + attachment_id, + sha256, + deduplicated, + })) + } + + #[tool( + name = "books.file_document", + title = "File a document", + description = "File a document: category formation, legal, tax, accounting, statement (a bank or card statement: then books.record_statement), contract, photo, warranty, import_source (a bank export), or other. link_to links it to subjects in the same call (an account, an asset, a party, a project, the company: see books.link_attachment), all checked first. The bytes: upload_id, from books.begin_upload and `wanderbooks upload` (a file never passes through you). Never a W-9 (books.set_w9 w9_upload_id puts it in the vault). A file holding a full TIN is refused: one the server read or wrote with TINs, or text with a TIN the vault keeps or an SSN or EIN printed 3-2-4 or 2-7 with hyphens.", + annotations( + read_only_hint = false, + destructive_hint = false, + open_world_hint = false + ) + )] + async fn file_document( + &self, + params: Parameters, + ) -> Result, String> { + let p = params.0; + match p.category.as_str() { + "formation" | "legal" | "tax" | "accounting" | "statement" | "contract" | "photo" + | "warranty" | "import_source" | "other" => {} + _ => { + return Err(format!( + "bad category {:?}: expected formation, legal, tax, accounting, statement, contract, photo, warranty, import_source, or other", + p.category + )); + } + } + let mut store = self.lock()?; + let link_to = check_link_to(&store, None, &p.link_to)?; + let file = self.take_file(files::Given { + upload_id: &p.upload_id, + purposes: files::FILED, + })?; + let (file_name, mime_type) = named(p.file_name, p.mime_type, &file)?; + let (bytes, claim) = (file.bytes, file.claim); + self.refuse_tin_file(&store, &bytes, &mime_type, &file_name)?; + let (attachment_id, sha256, deduplicated) = store + .file_document( + &p.category, + &file_name, + &mime_type, + &bytes, + p.description.as_deref(), + ) + .map_err(|e| e.to_string())?; + files::filed(claim); + for sub in link_to { + store + .link_attachment(attachment_id, sub) + .map_err(|e| e.to_string())?; + } + Ok(Json(AttachReceiptOut { + links: links_out(&store, attachment_id)?, + attachment_id, + sha256, + deduplicated, + })) + } + + #[tool( + name = "books.link_receipt", + title = "Link a receipt to an entry", + description = "Link a previously filed (unlinked) receipt to an entry.", + annotations( + read_only_hint = false, + destructive_hint = false, + open_world_hint = false + ) + )] + async fn link_receipt( + &self, + params: Parameters, + ) -> Result, String> { + let p = params.0; + let mut store = self.lock()?; + store + .link_receipt(p.attachment_id, p.entry_id) + .map_err(|e| e.to_string())?; + Ok(Json(LinkReceiptOut { ok: true })) + } + + #[tool( + name = "books.unlink_receipt", + title = "Unlink a receipt", + description = "Detach a receipt from its entry, with a reason recorded in the journal. The receipt stays filed (unlinked); link it elsewhere or withdraw it with books.withdraw_attachment. The receipt policy still holds: an entry that requires a receipt cannot be left with none, so to replace a receipt attach the new one first, then unlink the old.", + annotations( + read_only_hint = false, + destructive_hint = true, + open_world_hint = false + ) + )] + async fn unlink_receipt( + &self, + params: Parameters, + ) -> Result, String> { + let p = params.0; + let mut store = self.lock()?; + let attachment = store + .list_attachments(None) + .map_err(|e| e.to_string())? + .into_iter() + .find(|a| a.id == p.attachment_id) + .ok_or_else(|| format!("unknown attachment {}", p.attachment_id))?; + if let Some(entry_id) = attachment.entry_id { + let remaining = store + .list_attachments(Some(entry_id)) + .map_err(|e| e.to_string())? + .len() + - 1; + if remaining == 0 { + self.ensure_receipt_not_required(&store, entry_id)?; + } + } + let entry_id = store + .unlink_receipt(p.attachment_id, &p.reason) + .map_err(|e| e.to_string())?; + let remaining_receipts = store + .list_attachments(Some(entry_id)) + .map_err(|e| e.to_string())? + .len() as u64; + Ok(Json(UnlinkReceiptOut { + ok: true, + entry_id, + remaining_receipts, + })) + } + + #[tool( + name = "books.withdraw_attachment", + title = "Withdraw a receipt or document", + description = "Withdraw an unlinked receipt or a filed document, with a reason recorded in the journal. It drops out of books.list_receipts and can never be linked again. Nothing is deleted: the bytes stay in the blob store and the journal keeps the filing and the withdrawal. Unlink it first (books.unlink_receipt from its entry, books.unlink_attachment from anything else).", + annotations( + read_only_hint = false, + destructive_hint = true, + open_world_hint = false + ) + )] + async fn withdraw_attachment( + &self, + params: Parameters, + ) -> Result, String> { + let p = params.0; + let mut store = self.lock()?; + store + .withdraw_attachment(p.attachment_id, &p.reason) + .map_err(|e| e.to_string())?; + Ok(Json(WithdrawAttachmentOut { ok: true })) + } + + /// ```text + /// GOAL: one file, linked wherever it is evidence, without refiling. + /// SOURCE: docs/parties-and-attachments.md "Attachments"; + /// Store::link_attachment. + /// INVARIANTS: + /// 1. Journaled as `attachment.linked`; a refusal journals nothing. + /// 2. A file linked to an entry counts as that entry's receipt. + /// SCENARIOS: + /// 1. IF the attachment is unknown or withdrawn, the subject kind is + /// unknown, or the subject doesn't exist: SHOULD refuse. + /// 2. IF it is already linked there (or filed on that entry): SHOULD + /// refuse. + /// ``` + #[tool( + name = "books.link_attachment", + title = "Link a file to a subject", + description = "Link a stored receipt or document to a subject, so one file is evidence wherever it applies without filing it again. subject: {kind, id}: entry (id), account (code or id: its statements, the agreement that opened it), asset (tag or id: photos, warranty, manual), party (id or name: contracts, correspondence), project (id or name: its contract, spec, pitch), invoice, bill, period (its close package), payroll_run, schedule (the prepaid contract), reconciliation, or company (no id: formation documents). A file linked to an entry counts as one of its receipts. Journaled as attachment.linked. Returns the attachment's links.", + annotations( + read_only_hint = false, + destructive_hint = false, + open_world_hint = false + ) + )] + async fn link_attachment( + &self, + params: Parameters, + ) -> Result, String> { + let p = params.0; + let mut store = self.lock()?; + find_attachment(&store, p.attachment_id)?; + let sub = resolve_subject(&store, &p.subject)?; + store + .link_attachment(p.attachment_id, sub) + .map_err(|e| e.to_string())?; + Ok(Json(AttachmentLinksOut { + attachment_id: p.attachment_id, + links: links_out(&store, p.attachment_id)?, + })) + } + + /// ```text + /// GOAL: take a link off, on the record, without leaving evidence + /// missing where a rule needs it. + /// SOURCE: R-13a (receipt corrections are append-only events); + /// Store::unlink_attachment. + /// INVARIANTS: + /// 1. Journaled as `attachment.unlinked` with the reason; the file + /// stays filed. A refusal journals nothing. + /// SCENARIOS: + /// 1. IF the reason is blank, or it isn't linked to the subject: + /// SHOULD refuse. + /// 2. IF it is filed on that entry (rather than linked): SHOULD refuse, + /// naming books.unlink_receipt. + /// 3. IF it is a posted entry's last receipt and the receipt policy + /// covers the entry: SHOULD refuse (receipt_required). + /// 4. IF it is the statement of a reconciliation not voided: SHOULD + /// refuse (void the reconciliation first). + /// ``` + #[tool( + name = "books.unlink_attachment", + title = "Unlink a file from a subject", + description = "Take a link (books.link_attachment) off a subject, with the reason recorded in the journal. The file stays filed and keeps its other links. A receipt filed on an entry comes off with books.unlink_receipt instead. Refused when it would leave a posted entry the receipt policy covers without a receipt (attach the replacement first), or take the statement off a reconciliation that isn't voided. Journaled as attachment.unlinked. Returns the attachment's links.", + annotations( + read_only_hint = false, + destructive_hint = true, + open_world_hint = false + ) + )] + async fn unlink_attachment( + &self, + params: Parameters, + ) -> Result, String> { + use ledger_store::Subject; + let p = params.0; + let mut store = self.lock()?; + let a = find_attachment(&store, p.attachment_id)?; + let sub = resolve_subject(&store, &p.subject)?; + let linked = store + .attachment_links(a.id) + .map_err(|e| e.to_string())? + .contains(&sub); + match sub { + Subject::Entry(e) if a.entry_id == Some(e) => { + return Err(format!( + "attachment {} is filed on entry {e}, not linked to it: take it off with books.unlink_receipt", + a.id + )); + } + Subject::Entry(e) if linked => { + let others = store + .list_attachments(Some(e)) + .map_err(|e| e.to_string())? + .iter() + .filter(|x| x.id != a.id) + .count(); + if others == 0 { + self.ensure_receipt_not_required(&store, e)?; + } + } + Subject::Reconciliation(r) + if store + .reconciliations(None) + .map_err(|e| e.to_string())? + .iter() + .any(|x| x.id == r && x.attachment_id == a.id && x.voided.is_none()) => + { + return Err(format!( + "attachment {} is the statement reconciliation {r} rests on: void the reconciliation first (books.void_reconciliation)", + a.id + )); + } + _ => {} + } + store + .unlink_attachment(a.id, sub, &p.reason) + .map_err(|e| e.to_string())?; + Ok(Json(AttachmentLinksOut { + attachment_id: a.id, + links: links_out(&store, a.id)?, + })) + } + + #[tool( + name = "books.attachments", + title = "Files linked to a subject", + description = "The files on a subject ({kind, id} as in books.link_attachment): for an entry, the receipts filed on it and the files linked to it; for anything else, the files linked to it. Withdrawn files are left out. Each comes with all its links.", + annotations(read_only_hint = true, open_world_hint = false) + )] + async fn attachments( + &self, + params: Parameters, + ) -> Result, String> { + let store = self.lock()?; + let sub = resolve_subject(&store, ¶ms.0.subject)?; + let attachments = store + .attachments_for(sub) + .map_err(|e| e.to_string())? + .into_iter() + .map(|a| receipt_out(&store, a)) + .collect::>()?; + Ok(Json(AttachmentsOut { + subject: subject_out(&store, sub), + attachments, + })) + } + + #[tool( + name = "books.list_receipts", + title = "List receipts and documents", + description = "List receipts and filed documents, optionally filtered to one entry (filed on it or linked to it), each with everything it is linked to (links). Withdrawn attachments are hidden unless include_withdrawn=true. For any other subject's files, books.attachments.", + annotations(read_only_hint = true, open_world_hint = false) + )] + async fn list_receipts( + &self, + params: Parameters, + ) -> Result, String> { + let p = params.0; + let store = self.lock()?; + let include_withdrawn = p.include_withdrawn.unwrap_or(false); + let receipts = store + .list_attachments(p.entry_id) + .map_err(|e| e.to_string())? + .into_iter() + .filter(|a| include_withdrawn || a.withdrawn_at.is_none()) + .map(|a| receipt_out(&store, a)) + .collect::>()?; + Ok(Json(ListReceiptsOut { receipts })) + } + + #[tool( + name = "books.get_receipt", + title = "Get a receipt", + description = "A receipt or document: what it is and everything it is linked to. view bytes adds the file, handed over: run its download (wanderbooks download) to save a copy in a folder you name; its bytes never come back here.", + annotations(read_only_hint = true, open_world_hint = false) + )] + pub(super) async fn get_receipt( + &self, + params: Parameters, + ) -> Result, String> { + let p = params.0; + let bytes_wanted = match p.view.as_deref().unwrap_or("meta") { + "meta" => false, + "bytes" => true, + other => { + return Err(format!( + "view {:?}: meta (the default) or bytes", + other.chars().take(20).collect::() + )); + } + }; + let store = self.lock()?; + let meta = find_attachment(&store, p.attachment_id)?; + // Handed over, read hash-verified (D-46): never its bytes here. + let file = if bytes_wanted { + let bytes = store + .attachment_bytes(p.attachment_id) + .map_err(|e| e.to_string())?; + Some(self.hand_over(&book_name(&store), &meta.file_name, &meta.mime_type, bytes)?) + } else { + None + }; + Ok(Json(GetReceiptOut { + file, + links: links_out(&store, meta.id)?, + description: meta.description.clone(), + attachment_id: meta.id, + sha256: meta.blob_sha, + entry_id: meta.entry_id, + category: meta.category, + file_name: meta.file_name, + mime_type: meta.mime_type, + size_bytes: meta.size_bytes, + })) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::server::test_support::*; + + /// Unlinking a receipt is refused only from a posted, non-reversal + /// entry the policy covers (crate::policy). + #[test] + fn receipts_are_kept_only_where_the_policy_needs_them() { + let books = unit_dir("unlink"); + let mut s = store(&books); + let cash = s + .add_account("1000", "Cash", AccountType::Asset, None, false) + .unwrap(); + let rent = s + .add_account("6000", "Rent", AccountType::Expense, None, false) + .unwrap(); + let draft = s.create_entry(d(2026, 3, 1), "draft").unwrap(); + s.add_line(draft, rent, Side::Debit, usd("10.00")).unwrap(); + s.add_line(draft, cash, Side::Credit, usd("10.00")).unwrap(); + let posted = post(&mut s, d(2026, 3, 2), rent, cash, "10.00"); + let reversal = s.reverse_entry(posted, d(2026, 3, 3), "undo").unwrap(); + let server = served(settled( + s, + &[("policy.evidence.threshold", serde_json::json!("0.00"))], + )); + let store = server.lock().unwrap(); + assert!( + server.ensure_receipt_not_required(&store, draft).is_ok(), + "a draft" + ); + assert!( + server.ensure_receipt_not_required(&store, reversal).is_ok(), + "a reversal" + ); + assert!( + server.ensure_receipt_not_required(&store, posted).is_err(), + "a posted entry" + ); + } +} diff --git a/crates/ledger-mcp/src/tools/chart.rs b/crates/ledger-mcp/src/tools/chart.rs new file mode 100644 index 0000000..0efb56e --- /dev/null +++ b/crates/ledger-mcp/src/tools/chart.rs @@ -0,0 +1,543 @@ +//! The chart of accounts through the server: `books.chart`, +//! `books.add_account`, the chart template and CSV import, renaming and +//! renumbering, and the control roles. Split whole out of `server.rs` +//! (plan/served.md 8.2 U0a). + +use super::*; + +/// The settings that disagree with the books: each names the setting and +/// what to change. A code must name an account in the chart, of the kind +/// the setting needs (cash and investing: assets; financing: liabilities; +/// capital, distributions, close_into, close_sweep: equity). +/// The live conventions that name account `code` (a posting template's +/// line, an approval rule's accounts): after a renumber, each still names +/// the old code until revised. +fn convention_mentions(ledger: &Ledger, code: &str) -> Vec { + convention_codes(ledger) + .into_iter() + .filter(|(_, c)| c == code) + .map(|(id, _)| format!("convention {id} (revise it with books.set_convention)")) + .collect() +} + +/// Every control role and the account filling it, for books.roles. +fn roles_out(ledger: &Ledger) -> Vec { + ledger_core::control_accounts() + .map(|c| { + let account = ledger.role_account(c).and_then(|id| ledger.chart().get(id)); + RoleOut { + role: c.key.to_string(), + purpose: c.role.to_string(), + account_type: format!("{:?}", c.account_type), + default_code: c.code.to_string(), + assigned: ledger.role_assignment(c).is_some(), + account_code: account.map(|a| a.code.clone()), + account_name: account.map(|a| a.name.clone()), + } + }) + .collect() +} + +fn parse_account_type(s: &str) -> Result { + match s.to_lowercase().as_str() { + "asset" => Ok(AccountType::Asset), + "liability" => Ok(AccountType::Liability), + "equity" => Ok(AccountType::Equity), + "revenue" => Ok(AccountType::Revenue), + "expense" => Ok(AccountType::Expense), + _ => Err(format!( + "bad account_type {s:?}: expected asset, liability, equity, revenue, or expense" + )), + } +} + +#[tool_router(router = chart_router, vis = "pub(super)")] +impl BooksServer { + #[tool( + name = "books.chart", + title = "Chart of accounts", + description = "List the chart of accounts with current balances. Start here: every line you post must use a real account from this chart.", + annotations(read_only_hint = true, open_world_hint = false) + )] + pub(super) async fn chart(&self) -> Result, String> { + let store = self.lock()?; + let ledger = store.ledger(); + let mut accounts = Vec::new(); + let mut all: Vec<_> = ledger.chart().all().collect(); + all.sort_by(|a, b| a.code.cmp(&b.code)); + for a in all { + let balance = ledger.account_balance(a.id).map_err(|e| e.to_string())?; + accounts.push(ChartAccount { + id: a.id, + code: a.code.clone(), + name: a.name.clone(), + account_type: format!("{:?}", a.account_type).to_lowercase(), + parent: a.parent, + placeholder: a.placeholder, + balance: money(if balance.is_zero() { + Decimal::ZERO + } else { + balance + }), + tax_category: a.tax_category.clone(), + deductible_percent: a.deductible_percent.map(|p| p.to_string()), + }); + } + Ok(Json(ChartOut { + accounts, + base_currency: store.base_currency().code().to_string(), + })) + } + + #[tool( + name = "books.add_account", + title = "Add an account", + description = "Add an account to the chart. Codes must be unique; check books.chart first. Give a revenue or expense account its tax_category (books.tax_categories): without one the result warns, or, under policy.require_tax_category = refuse, the account is refused. A name another account under the same parent has (ignoring capitals), one holding nine digits in a row, or one that isn't a single line of at most 200 characters, is refused.", + annotations( + read_only_hint = false, + destructive_hint = false, + open_world_hint = false + ) + )] + async fn add_account( + &self, + params: Parameters, + ) -> Result, String> { + let p = params.0; + let account_type = parse_account_type(&p.account_type)?; + let placeholder = p.placeholder.unwrap_or(false); + let pct = match &p.deductible_percent { + Some(s) => Some(parse_amount(s)?), + None => None, + }; + let category = p.tax_category.as_deref(); + if pct.is_some() && category.is_none() { + return Err("deductible_percent needs a tax_category".into()); + } + if let Some(cat) = category { + ledger_core::tax::check_category(cat, account_type, self.settings().company.tax_form) + .map_err(|e| e.to_string())?; + } + let mut store = self.lock()?; + let id = store + .add_account_with_tax( + &p.code, + &p.name, + account_type, + p.parent, + placeholder, + category.map(|c| (c, pct)), + ) + .map_err(|e| e.to_string())?; + let warnings = + if category.is_none() && ledger_core::tax::needs_category(account_type, placeholder) { + vec![uncategorized_warning(&p.code, &p.name)] + } else { + Vec::new() + }; + Ok(Json(AddAccountOut { + account_id: id, + warnings, + })) + } + + #[tool( + name = "books.chart_template", + title = "Starter chart of accounts", + description = "A starter chart of accounts for a kind of business (sole proprietorship, partnership or multi-member LLC, S corporation, C corporation): the right equity and owner-pay accounts (draws; per-partner capital and distributions plus guaranteed payments; officer compensation and distributions; dividends and income tax), common income and expense accounts with tax categories assigned, and the settings that go with it (changes for books.set_setting). A proposal: review it with the human, adjust, then books.import_chart_csv and books.set_setting.", + annotations(read_only_hint = true, open_world_hint = false) + )] + async fn chart_template( + &self, + params: Parameters, + ) -> Result, String> { + let p = params.0; + let entity = ledger_import::EntityType::parse(&p.entity_type).ok_or_else(|| { + format!( + "entity_type {:?}: expected sole_proprietorship, partnership, s_corporation, or c_corporation (an LLC is one of these for tax purposes)", + p.entity_type + ) + })?; + let t = ledger_import::chart_template(entity, &p.owners)?; + // The template's settings, as the changes that set them in new + // books (the one way settings change: books.set_setting). + let settings = ledger_config::codec::import( + &t.settings_toml, + None, + &ledger_core::settings::Settings::default(), + self.currency(), + )? + .changes + .into_iter() + .filter_map(|c| { + Some(TemplateSettingOut { + value: c.value?, + key: c.key, + }) + }) + .collect(); + Ok(Json(ChartTemplateOut { + csv: t.csv(), + settings, + notes: t.notes, + })) + } + + #[tool( + name = "books.renumber_account", + title = "Renumber an account", + description = "Give an account a new code. Only the code changes: its id, name, and every entry posted to it stay, so balances and reports don't move. Refuses a code in use, or a control account's code (invoices, bills, payroll, closes) for an account of the wrong type. Lists any settings that still name the old code. Never renumber the human's accounts on your own: only when they ask.", + annotations( + read_only_hint = false, + destructive_hint = true, + open_world_hint = false + ) + )] + async fn renumber_account( + &self, + params: Parameters, + ) -> Result, String> { + let p = params.0; + let mut store = self.lock()?; + let id = resolve_account(&store, &p.account)?; + let previous_code = store + .ledger() + .chart() + .get(id) + .expect("resolved") + .code + .clone(); + store + .renumber_account(id, &p.code) + .map_err(|e| e.to_string())?; + Ok(Json(RenumberAccountOut { + id, + config_mentions: self + .config_mentions(&previous_code) + .into_iter() + .chain(convention_mentions(store.ledger(), &previous_code)) + .collect(), + previous_code, + code: p.code, + })) + } + + #[tool( + name = "books.rename_account", + title = "Rename an account", + description = "Give an account a new name. Only the name changes: its id, code, type, tax category and every entry posted to it stay, so no entry, balance or statement amount changes; every report, past periods included, prints the new name, and the journal keeps the old one. Refuses a blank name, the name it has, a name another account under the same parent has (ignoring capitals), one holding nine digits in a row, or one that isn't a single line of at most 200 characters. QuickBooks matches accounts by name: if the CPA imported an earlier IIF, tell them. Never rename the human's accounts on your own: only when they ask.", + annotations( + read_only_hint = false, + destructive_hint = true, + open_world_hint = false + ) + )] + async fn rename_account( + &self, + params: Parameters, + ) -> Result, String> { + use ledger_store::reconcilable; + let p = params.0; + let mut store = self.lock()?; + let id = resolve_account(&store, &p.account)?; + let reason = p.reason.as_deref().map(str::trim).filter(|r| !r.is_empty()); + // The sets the books pick by name, before and after (rules.md + // B-31: a rename moves no amount, but it can move an account into + // or out of one of these). + let configured_cash = self.settings().roles.cash.clone(); + let picked = |ledger: &Ledger| { + ( + configured_cash.is_empty() + && reconcilable::cash_accounts(ledger, &[]).contains(&id), + reconcilable::card_accounts(ledger).contains(&id), + ) + }; + let before = picked(store.ledger()); + let previous_name = store + .ledger() + .chart() + .get(id) + .expect("resolved") + .name + .clone(); + store + .rename_account(id, &p.name, reason) + .map_err(|e| e.to_string())?; + let ledger = store.ledger(); + let a = ledger.chart().get(id).expect("resolved"); + let after = picked(ledger); + let mut notes = vec![format!( + "Every report, past periods included, prints \u{201c}{}\u{201d} now; the journal keeps \u{201c}{previous_name}\u{201d}. QuickBooks matches accounts by name: if the CPA imported an earlier IIF, tell them.", + a.name + )]; + let children = ledger + .chart() + .all() + .filter(|c| c.parent == Some(id)) + .count(); + if children > 0 { + notes.push(format!( + "Its {children} sub-account{} carry the new name in their QuickBooks full names (IIF) too.", + if children == 1 { "" } else { "s" } + )); + } + for (was, is, what) in [ + ( + before.0, + after.0, + "a cash account (books.cash_accounts is unset, so cash accounts are the asset accounts whose names say cash, bank, checking, savings or money market): the reconciliation reminders and the viewer's cash figures, Cash page and cash flow statement follow it. Set books.cash_accounts to choose them by code", + ), + ( + before.1, + after.1, + "a card account (a liability whose name says card): the reconciliation reminders follow it", + ), + ] { + if was != is { + notes.push(format!( + "{} {} is {} {what}.", + a.code, + a.name, + if is { "now" } else { "no longer" } + )); + } + } + // A blank old name (an earlier version wrote one) is in every + // text: it names no convention. + let old = previous_name.trim().to_lowercase(); + for c in ledger + .conventions() + .conventions() + .filter(|c| c.retired.is_none() && !old.is_empty()) + .filter(|c| { + c.title.to_lowercase().contains(&old) || c.body_md.to_lowercase().contains(&old) + }) + { + notes.push(format!( + "Convention {} names \u{201c}{previous_name}\u{201d} in its text: if it means this account, revise it with books.set_convention.", + c.id + )); + } + Ok(Json(RenameAccountOut { + id, + code: a.code.clone(), + previous_name, + name: a.name.clone(), + notes, + })) + } + + #[tool( + name = "books.roles", + title = "Control account roles", + description = "The control roles invoices, bills, payroll, and closes post to (receivables, revenue, payables, general expense, retained earnings, payroll expense and withholdings), and the account filling each: the one assigned with books.assign_role, else the account at the role's default code.", + annotations(read_only_hint = true, open_world_hint = false) + )] + async fn roles(&self) -> Result, String> { + let store = self.lock()?; + Ok(Json(RolesOut { + roles: roles_out(store.ledger()), + })) + } + + #[tool( + name = "books.assign_role", + title = "Assign a control role", + description = "Assign a control role (see books.roles) one of the chart's own accounts, so invoices, bills, payroll, or closes post there instead of the default code; account null returns the role to its default code. Journaled. Refuses an account of the wrong type, a placeholder, one already filling another role, and moving a receivables, payables, withholding, or retained earnings role while its account holds a balance (move the balance with an entry first). Posted entries never change. Only when the human agrees.", + annotations( + read_only_hint = false, + destructive_hint = true, + open_world_hint = false + ) + )] + async fn assign_role( + &self, + params: Parameters, + ) -> Result, String> { + let p = params.0; + let mut store = self.lock()?; + let account = match &p.account { + Some(a) => Some(resolve_account(&store, a)?), + None => None, + }; + store + .assign_role(&p.role, account) + .map_err(|e| e.to_string())?; + Ok(Json(RolesOut { + roles: roles_out(store.ledger()), + })) + } + + #[tool( + name = "books.import_chart_csv", + title = "Import a chart of accounts", + description = "Import a chart of accounts from CSV (header: code,name,type,parent_code,tax_category; the last is optional), as upload_id: upload it with books.begin_upload purpose import and `wanderbooks upload` (a file never passes through you). Types: asset, liability, equity, revenue, expense. Parents must come before children. Fails on the first bad row; nothing is half-imported. A revenue or expense row without a tax_category is listed in warnings, or, under policy.require_tax_category = refuse, refuses the file.", + annotations( + read_only_hint = false, + destructive_hint = false, + open_world_hint = false + ) + )] + async fn import_chart_csv( + &self, + params: Parameters, + ) -> Result, String> { + let p = params.0; + let (csv, claim) = self.csv_given(&p.upload_id)?; + let rows = ledger_import::parse_chart_csv(&csv).map_err(|e| e.to_string())?; + let mut store = self.lock()?; + let parse_type = |t: &str| match t { + "asset" => Ok(AccountType::Asset), + "liability" => Ok(AccountType::Liability), + "equity" => Ok(AccountType::Equity), + "revenue" => Ok(AccountType::Revenue), + "expense" => Ok(AccountType::Expense), + _ => Err(format!("bad account type: {t}")), + }; + // Rehearse the whole import on a copy of the ledger first, so a bad + // row anywhere (a duplicate within the file, a parent listed after + // its child or of another type, a blank code, a name a sibling + // has, a missing category the policy requires) refuses the file + // and nothing is half-imported. The old up-front checks covered + // only clashes with the existing chart and tax categories. + let mut scratch = store.ledger().clone(); + let mut warnings = Vec::new(); + for row in &rows { + let account_type = parse_type(&row.account_type)?; + let parent = match &row.parent_code { + Some(code) => Some( + scratch + .chart() + .find_by_code(code) + .map(|a| a.id) + .ok_or_else(|| format!("{}: unknown parent code {code}", row.code))?, + ), + None => None, + }; + let id = scratch + .add_account(&row.code, &row.name, account_type, parent, false) + .map_err(|e| format!("{}: {e}", row.code))?; + // A row is never a header account (the file can't say so), so + // a revenue or expense row needs a category under refuse. + scratch + .check_tax_category_required( + &row.code, + &row.name, + account_type, + false, + row.tax_category.as_deref(), + ) + .map_err(|e| { + format!( + "{}: {e}, as the file's fifth column (tax_category); a row of the file is never a header account", + row.code + ) + })?; + if let Some(cat) = &row.tax_category { + ledger_core::tax::check_category( + cat, + account_type, + self.settings().company.tax_form, + ) + .map_err(|e| format!("{}: {e}", row.code))?; + // A category an earlier version doesn't know waits for the + // books' format upgrade: refused here, with the file whole, + // never after the rows before it were written. + if let Some(f) = ledger_store::format::tax_category_feature(cat) { + store + .ensure_feature(f) + .map_err(|e| format!("{}: {e}", row.code))?; + } + scratch + .set_account_tax(id, Some(cat), None) + .map_err(|e| format!("{}: {e}", row.code))?; + } else if ledger_core::tax::needs_category(account_type, false) { + warnings.push(uncategorized_warning(&row.code, &row.name)); + } + } + // Rehearsed whole: the upload is used up as the writing begins. + files::filed(claim); + let mut created = Vec::new(); + for row in rows { + let account_type = match row.account_type.as_str() { + "asset" => AccountType::Asset, + "liability" => AccountType::Liability, + "equity" => AccountType::Equity, + "revenue" => AccountType::Revenue, + "expense" => AccountType::Expense, + _ => return Err(format!("bad account type: {}", row.account_type)), + }; + let parent = match &row.parent_code { + Some(code) => Some( + store + .ledger() + .chart() + .find_by_code(code) + .map(|a| a.id) + .ok_or_else(|| format!("unknown parent code: {code}"))?, + ), + None => None, + }; + store + .add_account_with_tax( + &row.code, + &row.name, + account_type, + parent, + false, + row.tax_category.as_deref().map(|c| (c, None)), + ) + .map_err(|e| e.to_string())?; + created.push(row.code.clone()); + } + let count = created.len(); + Ok(Json(ImportChartCsvOut { + created, + count, + warnings, + })) + } +} + +#[cfg(test)] +mod tests { + use crate::server::test_support::*; + + /// Settings naming a code are found: cash/financing/investing roles, + /// close_into, and each member's capital and distributions accounts. + #[test] + fn config_mentions_finds_every_setting_naming_a_code() { + let books = unit_dir("mentions"); + let server = served(settled( + store(&books), + &[ + members(), + ("books.cash_accounts", serde_json::json!(["1000"])), + ("books.financing_accounts", serde_json::json!(["2100"])), + ("books.investing_accounts", serde_json::json!(["1400"])), + ], + )); + assert_eq!(server.config_mentions("1000"), ["books.cash_accounts"]); + assert_eq!(server.config_mentions("2100"), ["books.financing_accounts"]); + assert_eq!(server.config_mentions("1400"), ["books.investing_accounts"]); + assert_eq!( + server.config_mentions("3000"), + ["company.members Ada capital_account"] + ); + assert_eq!( + server.config_mentions("3001"), + ["company.members Bo capital_account"] + ); + assert_eq!( + server.config_mentions("3100"), + ["company.members Ada distribution_account"] + ); + assert!(server.config_mentions("6000").is_empty()); + let books = unit_dir("mentions-solo"); + let solo = served(settled( + store(&books), + &[("books.close_into", serde_json::json!("3000"))], + )); + assert_eq!(solo.config_mentions("3000"), ["books.close_into"]); + } +} diff --git a/crates/ledger-mcp/src/tools/documents.rs b/crates/ledger-mcp/src/tools/documents.rs new file mode 100644 index 0000000..7b057c0 --- /dev/null +++ b/crates/ledger-mcp/src/tools/documents.rs @@ -0,0 +1,633 @@ +//! `books.documents`' group: the A/R and A/P documents (invoices and +//! bills), the payments and voids against them, the read tools +//! (`books.documents`, `books.document`), the control check and the +//! agings. Split whole out of `server.rs` (plan/served.md 8.2 U0a): the +//! tools, their names, descriptions and rules are unchanged +//! (`docs/mcp-interface.md`; rules.md B-38). + +use super::*; + +/// A payment's method and reference from its parameters (the method by +/// id or label, books.instruments). +fn payment_details( + store: &Store, + p: &PaymentParams, +) -> Result { + Ok(ledger_core::PaymentDetails { + instrument: p + .instrument + .as_deref() + .map(|r| resolve_instrument(store, r)) + .transpose()?, + reference: p.reference.clone(), + }) +} + +/// What a payment tool answers: the document's figures after it, derived +/// from its lines that stand (rules.md B-38), as of the end of the books. +fn payment_out( + ledger: &Ledger, + doc: ledger_core::DocRef, + entry_id: u64, +) -> Result, String> { + let st = ledger + .doc_state(doc, chrono::NaiveDate::MAX) + .map_err(|e| e.to_string())? + .ok_or_else(|| format!("{} not found", doc_label(doc)))?; + Ok(Json(PaymentOut { + entry_id, + remaining: money(st.outstanding), + status: st.status.key().to_string(), + })) +} + +/// A document's id. +fn doc_id(doc: ledger_core::DocRef) -> u64 { + match doc { + ledger_core::DocRef::Invoice(id) | ledger_core::DocRef::Bill(id) => id, + } +} + +/// A document as books.documents and books.document name it +/// ("invoice:7", "bill:12"). +fn doc_ref_text(doc: ledger_core::DocRef) -> String { + match doc { + ledger_core::DocRef::Invoice(id) => format!("invoice:{id}"), + ledger_core::DocRef::Bill(id) => format!("bill:{id}"), + } +} + +/// A tagged line's movement on the wire. +fn movement_key(m: ledger_core::Movement) -> &'static str { + match m { + ledger_core::Movement::Origin => "origin", + ledger_core::Movement::Payment => "payment", + ledger_core::Movement::Discount => "discount", + ledger_core::Movement::WriteOff => "write_off", + ledger_core::Movement::Adopted => "adopted", + ledger_core::Movement::RoleMove => "role_move", + } +} + +/// A document's kind and id for people ("invoice 7"). +fn doc_label(doc: ledger_core::DocRef) -> String { + match doc { + ledger_core::DocRef::Invoice(id) => format!("invoice {id}"), + ledger_core::DocRef::Bill(id) => format!("bill {id}"), + } +} + +/// An invoice's or bill's lines as the tool takes them: each amount +/// parsed and each project resolved ([`resolve_class`]) before anything is +/// written. +fn document_lines( + store: &Store, + lines: Vec, +) -> Result, String> { + lines + .into_iter() + .map(|l| { + Ok(DocumentLine { + description: l.description, + amount: parse_amount(&l.amount)?, + class_id: l + .class + .as_deref() + .map(|c| resolve_class(store, c)) + .transpose()?, + account: None, + quantity: None, + unit_price: None, + }) + }) + .collect() +} + +/// A document's notes, each project once, in the order first named: its +/// late-tag note ([`projects::late_tag_note`]) for a finished or abandoned +/// project, and on an invoice ([`projects::on_spec_invoice_note`]) one for +/// an on-spec project (rules.md B-37 items 4, 5 and 8). +fn document_notes( + ledger: &ledger_core::Ledger, + lines: &[DocumentLine], + invoice: bool, +) -> Vec { + let mut named: Vec = Vec::new(); + for c in lines.iter().filter_map(|l| l.class_id) { + if !named.contains(&c) { + named.push(c); + } + } + let mut notes = Vec::new(); + for c in named { + notes.extend(projects::late_tag_note(ledger, c)); + if invoice { + notes.extend(projects::on_spec_invoice_note(ledger, c)); + } + } + notes +} + +#[tool_router(router = documents_router, vis = "pub(super)")] +impl BooksServer { + #[tool( + name = "books.create_invoice", + title = "Create and post an invoice", + description = "Create a sales invoice. Auto-posts Dr Accounts Receivable / Cr Revenue. Lines are {description, amount, class?} with decimal-string amounts; class (a project: name or id, books.projects) tags that line's revenue: one revenue line per project, the untagged lines' as one; the receivable line is never tagged. Its result gains notes for a finished, abandoned or on-spec project (on spec: is there now an agreement?), tagged anyway.", + annotations( + read_only_hint = false, + destructive_hint = true, + open_world_hint = false + ) + )] + async fn create_invoice( + &self, + params: Parameters, + ) -> Result, String> { + let p = params.0; + let mut store = self.lock()?; + let lines = document_lines(&store, p.lines)?; + let first_new = store.ledger().next_account_id(); + let id = store + .create_invoice( + p.party_id, + parse_date(&p.date)?, + parse_date(&p.due_date)?, + lines, + ) + .map_err(|e| e.to_string())?; + let inv = store + .ledger() + .get_invoice(id) + .ok_or_else(|| format!("invoice {id} not found after creation"))?; + Ok(Json(CreateDocumentOut { + id, + total: inv.total().map_err(|e| e.to_string())?.to_string(), + entry_id: inv.entry_id, + warnings: created_uncategorized(store.ledger(), first_new), + notes: document_notes(store.ledger(), &inv.lines, true), + })) + } + + #[tool( + name = "books.create_bill", + title = "Create and post a bill", + description = "Create a vendor bill. Auto-posts Dr General Expenses / Cr Accounts Payable. Lines are {description, amount, class?} with decimal-string amounts; class (a project: name or id, books.projects) tags that line's cost: one expense line per project, the untagged lines' as one; the payable line is never tagged. Its result gains notes for a finished or abandoned project, tagged anyway.", + annotations( + read_only_hint = false, + destructive_hint = true, + open_world_hint = false + ) + )] + async fn create_bill( + &self, + params: Parameters, + ) -> Result, String> { + let p = params.0; + let mut store = self.lock()?; + let lines = document_lines(&store, p.lines)?; + let first_new = store.ledger().next_account_id(); + let id = store + .create_bill( + p.party_id, + parse_date(&p.date)?, + parse_date(&p.due_date)?, + lines, + ) + .map_err(|e| e.to_string())?; + let bill = store + .ledger() + .get_bill(id) + .ok_or_else(|| format!("bill {id} not found after creation"))?; + Ok(Json(CreateDocumentOut { + id, + total: bill.total().map_err(|e| e.to_string())?.to_string(), + entry_id: bill.entry_id, + warnings: created_uncategorized(store.ledger(), first_new), + notes: document_notes(store.ledger(), &bill.lines, false), + })) + } + + #[tool( + name = "books.receive_payment", + title = "Receive an invoice payment", + description = "Record a customer payment against an invoice. Posts Dr Cash / Cr A/R, the receivable line tagged with the invoice (its figures are derived from its lines that stand: a reversed payment no longer counts). Name instrument (how the money moved, books.instruments) and the processor's or bank's reference when known. The books record payments; they never collect them (D-45). Refused above what it owes, before its date, or into an account that isn't cash.", + annotations( + read_only_hint = false, + destructive_hint = true, + open_world_hint = false + ) + )] + async fn receive_payment( + &self, + params: Parameters, + ) -> Result, String> { + let p = params.0; + let mut store = self.lock()?; + let cash = resolve_account(&store, &p.cash_account)?; + let details = payment_details(&store, &p)?; + let entry_id = store + .receive_payment_with( + p.document_id, + parse_date(&p.date)?, + parse_amount(&p.amount)?, + cash, + &details, + ) + .map_err(|e| e.to_string())?; + payment_out( + store.ledger(), + ledger_core::DocRef::Invoice(p.document_id), + entry_id, + ) + } + + #[tool( + name = "books.pay_bill", + title = "Pay a bill", + description = "Record a payment of a vendor bill. Posts Dr A/P / Cr Cash, the payable line tagged with the bill (its figures are derived from its lines that stand: a reversed payment no longer counts). Name instrument (how the money moved, books.instruments: a card payment is left out of 1099-NEC) and the payment's reference when known. The books record payments; they never make them (D-45). Refused above what it owes, before its date, or from an account that isn't cash.", + annotations( + read_only_hint = false, + destructive_hint = true, + open_world_hint = false + ) + )] + async fn pay_bill( + &self, + params: Parameters, + ) -> Result, String> { + let p = params.0; + let mut store = self.lock()?; + let cash = resolve_account(&store, &p.cash_account)?; + let details = payment_details(&store, &p)?; + let entry_id = store + .pay_bill_with( + p.document_id, + parse_date(&p.date)?, + parse_amount(&p.amount)?, + cash, + &details, + ) + .map_err(|e| e.to_string())?; + payment_out( + store.ledger(), + ledger_core::DocRef::Bill(p.document_id), + entry_id, + ) + } + + #[tool( + name = "books.void_document", + title = "Void an invoice or bill", + description = "Void an invoice or bill that should never have existed (a duplicate, the wrong customer or vendor): reverses its own entry, dated as given, with the reason. It keeps its number and shows as void; nothing is deleted. Refused while a payment stands on it (reverse that payment first: the refusal names it), when it is void already, before its date, or in a closed period. To lower a valid document, not void it: a credit memo (later). Posts at once.", + annotations( + read_only_hint = false, + destructive_hint = true, + open_world_hint = false + ) + )] + async fn void_document( + &self, + params: Parameters, + ) -> Result, String> { + let p = params.0; + let doc = doc_ref(&p.kind, p.id)?; + let date = parse_date(&p.date)?; + let mut store = self.lock()?; + let entry_id = store + .void_document(doc, date, &p.reason) + .map_err(|e| e.to_string())?; + let status = store + .ledger() + .doc_state(doc, date) + .map_err(|e| e.to_string())? + .map_or("void", |st| st.status.key()); + Ok(Json(VoidDocumentOut { + entry_id, + status: status.to_string(), + })) + } + + #[tool( + name = "books.documents", + title = "List invoices or bills", + description = described( + "List invoices (kind invoices: money owed to the company) or bills (kind bills: money it owes), due date first: each with its ref (for books.document), party, dates, total, what it still owes and its status, all derived from its ledger lines that stand. status: open (default: anything still owed), overdue, paid, void, written_off, overpaid, or all; party and due_by narrow it (due_by: what is due by a day, e.g. what to pay this week).", + ledger_core::calendar::PeriodKind::AsOf + ), + annotations(read_only_hint = true, open_world_hint = false) + )] + async fn documents( + &self, + params: Parameters, + ) -> Result, String> { + let p = params.0; + let as_of = self.as_of(p.as_of.as_deref())?; + let due_by = p.due_by.as_deref().map(parse_date).transpose()?; + let invoices = match p.kind.trim().to_ascii_lowercase().as_str() { + "invoices" | "invoice" => true, + "bills" | "bill" => false, + other => return Err(format!("bad kind {other:?}: expected invoices or bills")), + }; + let want = p + .status + .as_deref() + .unwrap_or("open") + .trim() + .to_ascii_lowercase(); + if !matches!( + want.as_str(), + "open" | "overdue" | "paid" | "void" | "written_off" | "overpaid" | "all" + ) { + return Err(format!( + "bad status {want:?}: expected open, overdue, paid, void, written_off, overpaid or all" + )); + } + let store = self.lock()?; + let ledger = store.ledger(); + let docs: Vec<(ledger_core::DocRef, String, NaiveDate, NaiveDate)> = if invoices { + ledger + .invoices() + .into_iter() + .map(|i| { + let who = ledger + .get_customer(i.customer_id) + .map(|c| c.name.clone()) + .unwrap_or_default(); + (ledger_core::DocRef::Invoice(i.id), who, i.date, i.due_date) + }) + .collect() + } else { + ledger + .bills() + .into_iter() + .map(|b| { + let who = ledger + .get_vendor(b.vendor_id) + .map(|v| v.name.clone()) + .unwrap_or_default(); + (ledger_core::DocRef::Bill(b.id), who, b.date, b.due_date) + }) + .collect() + }; + let party = p.party.as_deref().map(|s| s.trim().to_lowercase()); + let mut rows = Vec::new(); + let mut owed = Decimal::ZERO; + for (doc, who, date, due) in docs { + if let Some(want_party) = &party + && who.to_lowercase() != *want_party + && doc_id(doc).to_string() != *want_party + { + continue; + } + if due_by.is_some_and(|d| due > d) { + continue; + } + let Some(st) = ledger.doc_state(doc, as_of).map_err(|e| e.to_string())? else { + continue; + }; + let key = st.status.key(); + let keep = match want.as_str() { + "all" => true, + "open" => !st.outstanding.is_zero() && key != "void", + "overdue" => st.overdue_days > 0, + other => key == other, + }; + if !keep { + continue; + } + owed = owed + .checked_add(st.outstanding) + .ok_or("the total is too large to compute")?; + rows.push(( + due, + doc_id(doc), + DocumentRowOut { + r#ref: doc_ref_text(doc), + party: who, + date: date.to_string(), + due_date: due.to_string(), + total: money(st.total), + outstanding: money(st.outstanding), + status: key.to_string(), + overdue_days: st.overdue_days, + }, + )); + } + rows.sort_by_key(|r| (r.0, r.1)); + Ok(Json(DocumentsOut { + as_of: as_of.to_string(), + kind: if invoices { "invoices" } else { "bills" }.to_string(), + documents: rows.into_iter().map(|r| r.2).collect(), + outstanding_total: money(owed), + })) + } + + #[tool( + name = "books.document", + title = "An invoice or bill", + description = described( + "One invoice or bill (ref \"invoice:7\" or \"bill:12\", from books.documents) as of a day: party, dates, lines with projects, total, paid, discounted, written off, still owed, status; and its timeline, every ledger line tagged with it in posting order, each saying whether its entry still stands (a reversed payment shows as the payment, not standing, and its reversal, standing: they net to nothing). pdf: true also hands over an invoice's PDF to download, re-created from the books.", + ledger_core::calendar::PeriodKind::AsOf + ), + annotations(read_only_hint = true, open_world_hint = false) + )] + async fn document( + &self, + params: Parameters, + ) -> Result, String> { + let p = params.0; + let as_of = self.as_of(p.as_of.as_deref())?; + let doc = parse_doc_ref(&p.r#ref)?; + let store = self.lock()?; + let ledger = store.ledger(); + let (who, date, due, entry_id, lines) = match doc { + ledger_core::DocRef::Invoice(id) => { + let i = ledger + .get_invoice(id) + .ok_or_else(|| format!("no invoice {id}: see books.documents"))?; + let who = ledger + .get_customer(i.customer_id) + .map(|c| c.name.clone()) + .unwrap_or_default(); + (who, i.date, i.due_date, i.entry_id, i.lines.clone()) + } + ledger_core::DocRef::Bill(id) => { + let b = ledger + .get_bill(id) + .ok_or_else(|| format!("no bill {id}: see books.documents"))?; + let who = ledger + .get_vendor(b.vendor_id) + .map(|v| v.name.clone()) + .unwrap_or_default(); + (who, b.date, b.due_date, b.entry_id, b.lines.clone()) + } + }; + let st = ledger + .doc_state(doc, as_of) + .map_err(|e| e.to_string())? + .ok_or_else(|| format!("{} is dated after {as_of}", doc_ref_text(doc)))?; + let pdf = match (p.pdf, doc) { + (false, _) => None, + (true, ledger_core::DocRef::Bill(_)) => { + return Err( + "a bill is the vendor's document: its PDF is the file filed with it (books.list_receipts)" + .into(), + ); + } + (true, ledger_core::DocRef::Invoice(id)) => { + let settings = self.settings(); + let printed = ledger_package::invoice_document( + ledger, + &settings.company, + id, + as_of, + today(), + )?; + let bytes = + ledger_import::invoice_pdf::invoice_pdf(&printed).map_err(|e| e.to_string())?; + Some(self.hand_over( + &book_name(&store), + &format!("invoice-{id}.pdf"), + "application/pdf", + bytes, + )?) + } + }; + let timeline = ledger + .doc_lines(doc) + .into_iter() + .filter(|l| l.date <= as_of) + .map(|l| DocumentEventOut { + date: l.date.to_string(), + entry_id: l.entry, + kind: movement_key(l.movement).to_string(), + amount: money(l.amount), + reversal: l.reversal, + stands: ledger.stands_on(l.entry, as_of), + }) + .collect(); + Ok(Json(DocumentOut { + r#ref: doc_ref_text(doc), + party: who, + date: date.to_string(), + due_date: due.to_string(), + entry_id, + lines: lines + .iter() + .map(|l| DocumentLineOut { + description: l.description.clone(), + amount: money(l.amount), + project: l + .class_id + .and_then(|c| ledger.get_class(c)) + .map(|c| c.name.clone()), + }) + .collect(), + total: money(st.total), + paid: money(st.paid), + discounted: money(st.discounted), + written_off: money(st.written_off), + outstanding: money(st.outstanding), + status: st.status.key().to_string(), + overdue_days: st.overdue_days, + timeline, + pdf, + })) + } + + #[tool( + name = "books.control_check", + title = "Receivables and payables agree", + description = described( + "Check that Accounts Receivable and Accounts Payable equal their documents as of a day: for each control account, its ledger balance, what its open invoices or bills still owe on it, every line on it that is no document's (a hand entry or an older version's), the difference (always 0.00), and the documents needing attention (overpaid, or not in the ledger). agrees is true when there is nothing to look at. The first thing a CPA ties out.", + ledger_core::calendar::PeriodKind::AsOf + ), + annotations(read_only_hint = true, open_world_hint = false) + )] + async fn control_check( + &self, + params: Parameters, + ) -> Result, String> { + let p = params.0; + let as_of = self.as_of(p.as_of.as_deref())?; + let store = self.lock()?; + let ledger = store.ledger(); + let checks = ledger.control_check(as_of).map_err(|e| e.to_string())?; + Ok(Json(control_check_out(ledger, as_of, &checks))) + } + + #[tool( + name = "books.ar_aging", + title = "Receivables aging", + description = described( + "A/R aging report as of a date: outstanding balances per customer, bucketed by days past due (current, 30, 60, 90, 90+).", + ledger_core::calendar::PeriodKind::AsOf + ), + annotations(read_only_hint = true, open_world_hint = false) + )] + async fn ar_aging(&self, params: Parameters) -> Result, String> { + let p = params.0; + let as_of = self.as_of(p.as_of.as_deref())?; + let store = self.lock()?; + let rows = store + .ledger() + .ar_aging(as_of) + .map_err(|e| e.to_string())? + .into_iter() + .map(|r| { + let total = r.total().map_err(|e| e.to_string())?.to_string(); + Ok(AgingRowOut { + name: r.name, + current: money(r.current), + days_30: money(r.days_30), + days_60: money(r.days_60), + days_90: money(r.days_90), + days_90_plus: money(r.days_90_plus), + total, + }) + }) + .collect::>()?; + Ok(Json(AgingOut { + as_of: as_of.to_string(), + rows, + })) + } + + #[tool( + name = "books.ap_aging", + title = "Payables aging", + description = described( + "A/P aging report as of a date: outstanding balances per vendor, bucketed by days past due (current, 30, 60, 90, 90+).", + ledger_core::calendar::PeriodKind::AsOf + ), + annotations(read_only_hint = true, open_world_hint = false) + )] + async fn ap_aging(&self, params: Parameters) -> Result, String> { + let p = params.0; + let as_of = self.as_of(p.as_of.as_deref())?; + let store = self.lock()?; + let rows = store + .ledger() + .ap_aging(as_of) + .map_err(|e| e.to_string())? + .into_iter() + .map(|r| { + let total = r.total().map_err(|e| e.to_string())?.to_string(); + Ok(AgingRowOut { + name: r.name, + current: money(r.current), + days_30: money(r.days_30), + days_60: money(r.days_60), + days_90: money(r.days_90), + days_90_plus: money(r.days_90_plus), + total, + }) + }) + .collect::>()?; + Ok(Json(AgingOut { + as_of: as_of.to_string(), + rows, + })) + } +} diff --git a/crates/ledger-mcp/src/tools/entries.rs b/crates/ledger-mcp/src/tools/entries.rs new file mode 100644 index 0000000..ca48fba --- /dev/null +++ b/crates/ledger-mcp/src/tools/entries.rs @@ -0,0 +1,902 @@ +//! Journal entries through the server: `books.draft_entry`, +//! `books.add_line`, `books.post_entry`, `books.reverse_entry`, +//! `books.get_entry`, `books.search_entries`, and the bank CSV import. +//! Split whole out of `server.rs` (plan/served.md 8.2 U0a): nothing about +//! the tools changed. + +use super::*; + +/// Likely counterparties for an entry, best first (at most three): parties +/// named in its description, then in its receipts' names and +/// descriptions, then the party of an earlier entry with the same +/// description. Names match as whole words, ignoring case; a party also +/// matches by the first word of its name when that word has 4+ letters +/// ("Apple" for "Apple Inc."). +fn suggest_parties(store: &Store, entry: &JournalEntry) -> Result, String> { + let ledger = store.ledger(); + let words = |t: &str| -> Vec { + t.to_lowercase() + .split(|c: char| !c.is_alphanumeric()) + .filter(|w| !w.is_empty()) + .map(str::to_string) + .collect() + }; + let contains = |hay: &[String], needle: &[String]| { + !needle.is_empty() && hay.windows(needle.len()).any(|w| w == needle) + }; + let names_of = |p: &ledger_core::Party| -> Vec> { + let mut out = vec![words(&p.name)]; + if let Some(d) = &p.display_name { + out.push(words(d)); + } + if let Some(first) = words(&p.name).first() + && first.len() >= 4 + { + out.push(vec![first.clone()]); + } + out + }; + let receipts: String = store + .attachments_for(ledger_store::Subject::Entry(entry.id())) + .map_err(|e| e.to_string())? + .iter() + .map(|a| { + format!( + "{} {} ", + a.file_name, + a.description.clone().unwrap_or_default() + ) + }) + .collect(); + let desc = words(entry.description()); + let rec = words(&receipts); + let mut out: Vec = Vec::new(); + let push = |out: &mut Vec, p: &ledger_core::Party, why: String| { + if !out.iter().any(|s| s.party_id == p.id) { + out.push(PartySuggestionOut { + party_id: p.id, + name: p.name.clone(), + why, + }); + } + }; + let live: Vec<&ledger_core::Party> = ledger.parties().filter(|p| !p.archived).collect(); + for p in &live { + if names_of(p).iter().any(|n| contains(&desc, n)) { + push(&mut out, p, "named in the description".into()); + } + } + for p in &live { + if names_of(p).iter().any(|n| contains(&rec, n)) { + push(&mut out, p, "named in a receipt".into()); + } + } + let key = entry.description().trim().to_lowercase(); + for e in ledger.posted_entries() { + if e.id() != entry.id() + && e.description().trim().to_lowercase() == key + && let Some(p) = ledger + .entry_parties(e.id()) + .first() + .and_then(|s| ledger.get_party(s.party)) + && !p.archived + { + push( + &mut out, + p, + format!("tagged on entry {} with the same description", e.id()), + ); + } + } + out.truncate(3); + Ok(out) +} + +/// An entry's parties for output, each share resolved, and why they don't +/// resolve when they don't. +fn entry_parties_out( + store: &Store, + settings: &Settings, + entry: &JournalEntry, +) -> (Vec, Option) { + let ledger = store.ledger(); + let shares = ledger.entry_parties(entry.id()); + let (accounts, members) = share_context(ledger, settings); + let (resolved, error) = + match ledger_core::shares::resolve(ledger, entry.id(), shares, &accounts, &members) { + Ok(r) => (Some(r), None), + Err(e) => (None, Some(e)), + }; + let out = shares + .iter() + .enumerate() + .map(|(i, s)| { + let (kind, amount, percent) = match s.share { + ledger_core::Share::Amount(a) => ("amount", Some(money(a)), None), + ledger_core::Share::Percent(p) => { + ("percent", None, Some(p.normalize().to_string())) + } + ledger_core::Share::Derived => ("derived", None, None), + }; + EntryPartyOut { + party_id: s.party, + party: ledger + .get_party(s.party) + .map(|p| p.label().to_string()) + .unwrap_or_else(|| format!("party {}", s.party)), + share: kind.to_string(), + amount, + percent, + resolved: resolved.as_ref().map(|r| money(r.parts[i].amount)), + direction: resolved + .as_ref() + .map(|r| r.target.direction.key().to_string()), + cash: resolved.as_ref().map(|r| money(r.parts[i].cash)), + in_kind: resolved.as_ref().map(|r| money(r.parts[i].in_kind)), + } + }) + .collect(); + (out, error) +} + +/// Preview lines with resulting balances for an entry. +fn preview_lines(store: &Store, entry: &JournalEntry) -> Result, String> { + let lines: Vec<(AccountId, Side, Money)> = entry + .lines() + .iter() + .map(|l| (l.account, l.side, l.amount)) + .collect(); + preview_lines_for(store, &lines) +} + +fn preview_receipts(store: &Store, entry_id: u64) -> Result, String> { + Ok(store + .list_attachments(Some(entry_id)) + .map_err(|e| e.to_string())? + .into_iter() + .map(|a| PreviewReceipt { + attachment_id: a.id, + file_name: a.file_name, + sha256: a.blob_sha, + }) + .collect()) +} + +#[tool_router(router = entries_router, vis = "pub(super)")] +impl BooksServer { + #[tool( + name = "books.draft_entry", + title = "Draft an entry", + description = "Start a draft journal entry. Drafts are journaled and survive restarts, but don't affect reports until posted; add lines with books.add_line. Pass party (id or name) for the counterparty: every entry with a vendor, platform, agency, bank, member, or contractor names its party (a party with a vendor record tags the vendor too, for 1099s). An entry with several counterparties passes parties instead, each with an amount, a percent, or neither (derived): name both members on a joint member transaction and let their shares derive.", + annotations( + read_only_hint = false, + destructive_hint = false, + open_world_hint = false + ) + )] + async fn draft_entry( + &self, + params: Parameters, + ) -> Result, String> { + let p = params.0; + let mut store = self.lock()?; + // Resolve every reference before writing anything (INVARIANT 2). + let date = parse_date(&p.date)?; + let vendor = p + .vendor + .as_deref() + .map(|v| resolve_vendor(&store, v)) + .transpose()?; + let instrument = p + .instrument + .as_deref() + .map(|r| resolve_instrument(&store, r)) + .transpose()?; + let party = p + .party + .as_deref() + .map(|r| resolve_party(&store, r)) + .transpose()?; + let no_party = p + .no_party_reason + .as_deref() + .map(str::trim) + .filter(|r| !r.is_empty()); + let shares = p + .parties + .as_deref() + .map(|ps| parse_shares(&store, ps)) + .transpose()?; + if no_party.is_some() && (party.is_some() || vendor.is_some() || shares.is_some()) { + return Err( + "no_party_reason is for an entry with no counterparty: pass it without party, parties, or vendor".into(), + ); + } + if shares.is_some() && (party.is_some() || vendor.is_some()) { + return Err( + "pass party (one) or parties (several, with shares), not both; a vendor party in parties tags its vendor record".into(), + ); + } + if let Some(sh) = &shares { + store + .ledger() + .check_party_shares(sh) + .map_err(|e| e.to_string())?; + } + if let (Some(v), Some(pid)) = (vendor, party) { + let party_vendor = store.ledger().get_party(pid).and_then(|p| p.vendor); + if party_vendor != Some(v) { + return Err(format!( + "vendor {v} isn't party {pid}'s vendor record: pass only the party (its vendor record is tagged with it), or the vendor alone" + )); + } + } + // A payment method exists only in books that declare the + // payment_instruments feature (books.add_instrument needs it), so + // tagging the draft with one below can't be refused for the format + // after the draft's first line (B-27). + let id = store + .create_entry(date, &p.description) + .map_err(|e| e.to_string())?; + if let Some(sh) = shares.filter(|sh| !sh.is_empty()) { + store + .set_entry_parties(id, &sh) + .map_err(|e| e.to_string())?; + } else if party.is_some() { + store + .set_entry_party(id, party) + .map_err(|e| e.to_string())?; + } else if vendor.is_some() { + store + .set_entry_vendor(id, vendor) + .map_err(|e| e.to_string())?; + } else if let Some(r) = no_party { + store.set_entry_no_party(id, r).map_err(|e| e.to_string())?; + } + if instrument.is_some() { + store + .set_entry_instrument(id, instrument) + .map_err(|e| e.to_string())?; + } + Ok(Json(DraftEntryOut { + entry_id: id, + status: "draft".to_string(), + })) + } + + #[tool( + name = "books.add_line", + title = "Add a line to a draft", + description = "Add one line to a draft entry. Amount is a decimal string, never a float. The entry must balance before posting. For foreign-currency lines, pass foreign_currency, foreign_amount, and fx_rate (foreign -> base); the base amount is computed as foreign_amount * fx_rate. Pass class (a project: name or id) to tag the line; see books.projects.", + annotations( + read_only_hint = false, + destructive_hint = false, + open_world_hint = false + ) + )] + async fn add_line( + &self, + params: Parameters, + ) -> Result, String> { + let p = params.0; + let mut store = self.lock()?; + let account = resolve_account(&store, &p.account)?; + let side = parse_side(&p.side)?; + // Resolve the class before writing anything (INVARIANT 2). + let class_id = match &p.class { + Some(c) => Some(resolve_class(&store, c)?), + None => None, + }; + // Foreign-currency line? + if p.foreign_currency.is_some() || p.foreign_amount.is_some() || p.fx_rate.is_some() { + let fc = p + .foreign_currency + .ok_or("foreign_currency is required for fx lines")?; + let fa = p + .foreign_amount + .ok_or("foreign_amount is required for fx lines")?; + let rate = p.fx_rate.ok_or("fx_rate is required for fx lines")?; + let currency = Currency::new(&fc).map_err(|e| e.to_string())?; + let foreign_amount = parse_amount(&fa)?; + let fx_rate = parse_amount(&rate)?; + store + .add_line_fx(p.entry_id, account, side, currency, foreign_amount, fx_rate) + .map_err(|e| e.to_string())?; + } else { + let amount = parse_amount(&p.amount)?; + if amount <= Decimal::ZERO { + return Err("amount must be positive".to_string()); + } + let currency = store.base_currency(); + store + .add_line(p.entry_id, account, side, Money::new(amount, currency)) + .map_err(|e| e.to_string())?; + } + // Class tag, if requested. The line just added is the last one. + if let Some(class_id) = class_id { + let line_index = store + .ledger() + .get_entry(p.entry_id) + .ok_or_else(|| format!("unknown entry {}", p.entry_id))? + .lines() + .len() + .checked_sub(1) + .ok_or_else(|| "entry has no lines".to_string())?; + store + .set_line_class(p.entry_id, line_index, Some(class_id)) + .map_err(|e| e.to_string())?; + } + let note = class_id.and_then(|c| projects::late_tag_note(store.ledger(), c)); + Ok(Json(AddLineOut { ok: true, note })) + } + + #[tool( + name = "books.post_entry", + title = "Post an entry", + description = "Post a draft entry. Without confirmed=true this returns a preview (lines, resulting balances, receipt checklist) and posts nothing. With confirmed=true it posts after enforcing the receipt policy.", + annotations( + read_only_hint = false, + destructive_hint = true, + open_world_hint = false + ) + )] + async fn post_entry( + &self, + params: Parameters, + ) -> Result, String> { + let p = params.0; + let mut store = self.lock()?; + let entry = store + .ledger() + .get_entry(p.entry_id) + .ok_or_else(|| format!("unknown entry {}", p.entry_id))? + .clone(); + if entry.status() != ledger_core::EntryStatus::Draft { + return Err(format!("entry {} is not a draft", p.entry_id)); + } + // Pure validation before anything is journaled. + entry + .validate(store.ledger().chart()) + .map_err(|e| e.to_string())?; + // Validated: its lines are in a line's range, so their sum is exact. + let total = ReceiptPolicy::entry_debits(&entry).map_err(|e| e.to_string())?; + if let Some(expected) = p.expected_total { + let want = parse_amount(&expected)?; + if total != want { + return Err(format!( + "expected_total mismatch: entry totals {total}, expected {want}" + )); + } + } + let mut receipt_required = self + .settings() + .policy + .receipt_required(store.ledger(), &entry); + let receipts = preview_receipts(&store, p.entry_id)?; + // Anti-splitting: if this entry looks like part of a split group, + // the receipt policy applies to the group total. + let split_group = self + .settings() + .policy + .split_group(store.ledger(), p.entry_id, &entry); + if !split_group.is_empty() { + receipt_required = true; + } + let receipt_missing = receipt_required && receipts.is_empty(); + let party_policy = self.settings().parties; + let party_id = store.ledger().entry_party(p.entry_id); + let (parties, parties_error) = entry_parties_out(&store, &self.settings(), &entry); + let no_party_reason = store + .ledger() + .entry_no_party(p.entry_id) + .map(str::to_string); + // Any party, or a no-party mark, satisfies the policy. + let party_missing = party_policy != ledger_config::PartyPolicy::Off + && parties.is_empty() + && no_party_reason.is_none(); + let party_suggestions = if party_missing { + suggest_parties(&store, &entry)? + } else { + Vec::new() + }; + let out = PostEntryOut { + status: "preview".to_string(), + entry_id: p.entry_id, + date: entry.date().to_string(), + description: entry.description().to_string(), + total: money(total), + lines: preview_lines(&store, &entry)?, + receipts, + receipt_required, + receipt_missing, + split_detected: split_group.clone(), + party_policy: party_policy.key().to_string(), + party_id, + party: party_id + .and_then(|i| store.ledger().get_party(i)) + .map(|x| x.name.clone()), + parties, + parties_error, + no_party_reason, + party_missing, + party_suggestions, + approvals_missing: store + .ledger() + .approvals_missing(p.entry_id) + .map_err(|e| e.to_string())? + .into_iter() + .map(|(rule, approver)| ApprovalNeededOut { rule, approver }) + .collect(), + }; + if !p.confirmed.unwrap_or(false) { + return Ok(Json(out)); + } + if !out.approvals_missing.is_empty() { + let missing: Vec = out + .approvals_missing + .iter() + .map(|a| format!("{} (rule {})", a.approver, a.rule)) + .collect(); + return Err(serde_json::json!({ + "code": "approval_required", + "message": format!( + "entry {} needs approval before it posts (policy.approval_rules is enforce): {}. Ask each approver; when they approve, record it with books.approve_entry, then post.", + p.entry_id, + missing.join("; ") + ), + "entry_id": p.entry_id, + "remedy_tool": "books.approve_entry", + }) + .to_string()); + } + if let Some(why) = &out.parties_error { + return Err(serde_json::json!({ + "code": "party_shares", + "message": format!( + "entry {}'s party shares don't add up: {why}. Fix them with books.set_entry_party parties (amounts that sum to what it moved, or percents summing to 100).", + p.entry_id + ), + "entry_id": p.entry_id, + "remedy_tool": "books.set_entry_party", + }) + .to_string()); + } + if party_missing && party_policy == ledger_config::PartyPolicy::Require { + let names: Vec = out + .party_suggestions + .iter() + .map(|s| format!("{} (party {}, {})", s.name, s.party_id, s.why)) + .collect(); + return Err(serde_json::json!({ + "code": "party_required", + "message": format!( + "entry {} names no counterparty, and these books require one (policy.parties = \"require\"). Tag it with books.set_entry_party (add the party with books.add_party first if it's new), or, if it truly has none, call books.set_entry_party with party null and no_party_reason.{}", + p.entry_id, + if names.is_empty() { String::new() } else { format!(" Likely: {}.", names.join("; ")) } + ), + "entry_id": p.entry_id, + "remedy_tool": "books.set_entry_party", + "party_suggestions": out.party_suggestions, + }) + .to_string()); + } + if receipt_missing { + let split_note = if split_group.is_empty() { + String::new() + } else { + format!( + " Anti-splitting: this looks like part of a split group with entries {:?}; the policy applies to the group total.", + split_group + ) + }; + return Err(ReceiptRequiredError { + code: "receipt_required".to_string(), + message: format!( + "entry {} moves {} in debits ({}); attach a receipt with books.attach_receipt before posting.{}", + p.entry_id, + money(total), + self.settings().policy.describe(), + split_note + ), + entry_id: Some(p.entry_id), + asset_id: None, + debits: total.to_string(), + policy: self.settings().policy.describe(), + remedy_tool: "books.attach_receipt".to_string(), + remedy_hint: format!( + "attach a receipt to entry {} with books.attach_receipt, then retry books.post_entry with confirmed=true", + p.entry_id + ), + split_group, + } + .to_tool_error()); + } + store.post_entry(p.entry_id).map_err(|e| e.to_string())?; + fix_derived_shares(&mut store, &self.settings(), p.entry_id)?; + let mut posted = out; + posted.status = "posted".to_string(); + Ok(Json(posted)) + } + + #[tool( + name = "books.reverse_entry", + title = "Reverse an entry", + description = "Reverse a posted entry with an offsetting entry. Without confirmed=true returns a preview; reversals are exempt from the receipt policy.", + annotations( + read_only_hint = false, + destructive_hint = true, + open_world_hint = false + ) + )] + async fn reverse_entry( + &self, + params: Parameters, + ) -> Result, String> { + let p = params.0; + let mut store = self.lock()?; + let orig = store + .ledger() + .get_entry(p.entry_id) + .ok_or_else(|| format!("unknown entry {}", p.entry_id))? + .clone(); + if orig.status() != ledger_core::EntryStatus::Posted { + return Err(format!("entry {} is not posted", p.entry_id)); + } + let date = parse_date(&p.date)?; + // Refused before the preview, as the post would be (an asset's + // disposal, a run its disposal took as final, a month posted twice). + store + .check_reverse_entry(p.entry_id, date) + .map_err(|e| e.to_string())?; + // Build the preview from the flipped lines without mutating. + let flipped: Vec<(AccountId, Side, Money)> = orig + .lines() + .iter() + .map(|l| { + let side = match l.side { + Side::Debit => Side::Credit, + Side::Credit => Side::Debit, + }; + (l.account, side, l.amount) + }) + .collect(); + let out = ReverseEntryOut { + status: "preview".to_string(), + reversal_id: 0, // filled in on post + reverses: p.entry_id, + lines: preview_lines_for(&store, &flipped)?, + }; + if !p.confirmed.unwrap_or(false) { + return Ok(Json(out)); + } + let reversal_id = store + .reverse_entry(p.entry_id, date, &p.description) + .map_err(|e| e.to_string())?; + let mut posted = out; + posted.status = "posted".to_string(); + posted.reversal_id = reversal_id; + Ok(Json(posted)) + } + + #[tool( + name = "books.get_entry", + title = "Get an entry", + description = "Full detail of one entry: lines, totals, status, and attached receipts.", + annotations(read_only_hint = true, open_world_hint = false) + )] + pub(super) async fn get_entry( + &self, + params: Parameters, + ) -> Result, String> { + let p = params.0; + let store = self.lock()?; + let entry = store + .ledger() + .get_entry(p.entry_id) + .ok_or_else(|| format!("unknown entry {}", p.entry_id))? + .clone(); + let lines = entry + .lines() + .iter() + .map(|l| { + // A draft line an earlier version journaled may name an + // account the chart doesn't have (rules.md B-28). + let (code, name) = match store.ledger().chart().get(l.account) { + Some(a) => (a.code.clone(), a.name.clone()), + None => (String::new(), format!("(no account {})", l.account)), + }; + EntryLineOut { + account_id: l.account, + code, + name, + side: match l.side { + Side::Debit => "debit".to_string(), + Side::Credit => "credit".to_string(), + }, + amount: money(l.amount.amount), + class_id: l.class_id, + project: l + .class_id + .and_then(|c| store.ledger().get_class(c)) + .map(|c| c.name.clone()), + } + }) + .collect(); + let (parties, parties_error) = entry_parties_out(&store, &self.settings(), &entry); + let total = ReceiptPolicy::entry_debits(&entry); + Ok(Json(GetEntryOut { + entry_id: p.entry_id, + date: entry.date().to_string(), + description: entry.description().to_string(), + status: match entry.status() { + ledger_core::EntryStatus::Draft => "draft".to_string(), + ledger_core::EntryStatus::Posted => "posted".to_string(), + }, + reversal_of: entry.reversal_of(), + lines, + total: total.as_ref().ok().map(|t| money(*t)), + total_error: total.err().map(|e| e.to_string()), + receipts: preview_receipts(&store, p.entry_id)?, + vendor: store + .ledger() + .entry_vendor(p.entry_id) + .and_then(|v| store.ledger().get_vendor(v)) + .map(|v| v.name.clone()), + instrument: store + .ledger() + .entry_instrument(p.entry_id) + .and_then(|i| store.ledger().get_instrument(i)) + .map(|i| i.label.clone()), + instrument_id: store.ledger().entry_instrument(p.entry_id), + party_id: store.ledger().entry_party(p.entry_id), + party: store + .ledger() + .entry_party(p.entry_id) + .and_then(|id| store.ledger().get_party(id)) + .map(|p| p.label().to_string()), + parties, + parties_error, + no_party_reason: store + .ledger() + .entry_no_party(p.entry_id) + .map(str::to_string), + })) + } + + #[tool( + name = "books.search_entries", + title = "Search entries", + description = "Search posted entries by account, date range, and description text. Newest first.", + annotations(read_only_hint = true, open_world_hint = false) + )] + async fn search_entries( + &self, + params: Parameters, + ) -> Result, String> { + let p = params.0; + let store = self.lock()?; + let rows = store + .search_entries( + p.account_id, + p.date_from.as_deref(), + p.date_to.as_deref(), + p.text.as_deref(), + p.limit.unwrap_or(50).min(500), + p.offset.unwrap_or(0), + ) + .map_err(|e| e.to_string())?; + let mut entries = Vec::with_capacity(rows.len()); + for r in rows { + let e = store + .ledger() + .get_entry(r.id) + .ok_or_else(|| format!("entry {} missing from ledger", r.id))?; + let total = ReceiptPolicy::entry_debits(e).map_err(|e| e.to_string())?; + let receipt_count = store + .list_attachments(Some(r.id)) + .map_err(|e| e.to_string())? + .len() as u64; + entries.push(EntrySummary { + entry_id: r.id, + date: r.date, + description: r.description, + total: money(total), + reversal_of: r.reversal_of, + receipt_count, + }); + } + Ok(Json(SearchEntriesOut { entries })) + } + + #[tool( + name = "books.import_bank_csv", + title = "Import a bank CSV as drafts", + description = "Import a QuickBooks-style bank/credit-card CSV: 3 columns Date,Description,Amount (payments negative), or 4 columns Date,Description, money out, money in (Intuit heads them Credit,Debit). Slash dates must settle month-first or day-first from the file itself or from date_order (\"month_first\" for mm/dd, \"day_first\" for dd/mm); ask the human when the tool says the dates read either way. Creates one DRAFT entry per transaction, all or none: bank account on one side, categorized offset on the other (the longest matching rule pattern, else default_offset_code). Give the CSV as upload_id: upload it with books.begin_upload purpose import and `wanderbooks upload` (a file never passes through you). Review with books.digest, then post. File the export too (books.file_document category import_source) and pass source_attachment_id: it is linked to the bank account, so the import traces to its source (never to the drafts: a bank export is not a receipt).", + annotations( + read_only_hint = false, + destructive_hint = false, + open_world_hint = false + ) + )] + async fn import_bank_csv( + &self, + params: Parameters, + ) -> Result, String> { + let p = params.0; + let order = match p.date_order.as_deref() { + None => None, + Some("month_first") => Some(ledger_import::DateOrder::MonthFirst), + Some("day_first") => Some(ledger_import::DateOrder::DayFirst), + Some(other) => { + return Err(format!( + "date_order must be \"month_first\" or \"day_first\", got {other:?}" + )); + } + }; + let (csv, claim) = self.csv_given(&p.upload_id)?; + let txns = ledger_import::parse_bank_csv_with(&csv, order).map_err(|e| e.to_string())?; + let mut store = self.lock()?; + let bank = store + .ledger() + .chart() + .find_by_code(&p.bank_account_code) + .map(|a| (a.id, a.code.clone())) + .ok_or_else(|| format!("unknown bank account code: {}", p.bank_account_code))?; + // The source file links to the account, checked before any draft. + let link_source = match p.source_attachment_id { + None => false, + Some(att) => { + let a = find_attachment(&store, att)?; + if a.withdrawn_at.is_some() { + return Err(format!("attachment {att} is withdrawn")); + } + !store + .attachment_links(att) + .map_err(|e| e.to_string())? + .contains(&ledger_store::Subject::Account(bank.0)) + } + }; + let default_offset = store + .ledger() + .chart() + .find_by_code(&p.default_offset_code) + .map(|a| (a.id, a.code.clone())) + .ok_or_else(|| format!("unknown offset account code: {}", p.default_offset_code))?; + // Resolve rule account codes upfront. + let mut rules: Vec<(String, (ledger_core::AccountId, String))> = Vec::new(); + for (pattern, code) in &p.rules { + let (id, c) = store + .ledger() + .chart() + .find_by_code(code) + .map(|a| (a.id, a.code.clone())) + .ok_or_else(|| format!("unknown rule account code: {code}"))?; + rules.push((pattern.to_lowercase(), (id, c))); + } + // The most specific pattern wins (longest, then alphabetical): the + // rules arrive as a map with no order, so "first match" would pick + // a different account from run to run when two patterns match. + rules.sort_by(|a, b| b.0.len().cmp(&a.0.len()).then_with(|| a.0.cmp(&b.0))); + let offset_for = |description: &str| { + let desc_lower = description.to_lowercase(); + rules + .iter() + .find(|(pat, _)| desc_lower.contains(pat.as_str())) + .map(|(_, v)| v.clone()) + .unwrap_or_else(|| default_offset.clone()) + }; + let base = store.ledger().base_currency(); + // Rehearse the whole file on a copy first, so a transaction that + // can't become a draft (a date in a closed period, say) refuses the + // file instead of leaving the ones before it behind. + let mut scratch = store.ledger().clone(); + for txn in &txns { + let (offset_id, _) = offset_for(&txn.description); + let amount = Money::new(txn.amount.abs(), base); + let (bank_side, offset_side) = if txn.amount > Decimal::ZERO { + (Side::Debit, Side::Credit) + } else { + (Side::Credit, Side::Debit) + }; + let e = scratch + .try_create_entry(txn.date, &txn.description) + .and_then(|e| scratch.add_line(e, bank.0, bank_side, amount).map(|()| e)) + .and_then(|e| scratch.add_line(e, offset_id, offset_side, amount)) + .map_err(|e| format!("{} {}: {e}", txn.date, txn.description)); + e?; + } + // Rehearsed whole: the upload is used up as the writing begins. + files::filed(claim); + let mut imported = Vec::new(); + for txn in txns { + let (offset_id, offset_code) = offset_for(&txn.description); + let amount = Money::new(txn.amount.abs(), store.ledger().base_currency()); + let entry_id = store + .create_entry(txn.date, &format!("Bank import — {}", txn.description)) + .map_err(|e| e.to_string())?; + // Money in: debit bank, credit offset. Money out: reverse. + let (bank_side, offset_side) = if txn.amount > Decimal::ZERO { + (Side::Debit, Side::Credit) + } else { + (Side::Credit, Side::Debit) + }; + store + .add_line(entry_id, bank.0, bank_side, amount) + .map_err(|e| e.to_string())?; + store + .add_line(entry_id, offset_id, offset_side, amount) + .map_err(|e| e.to_string())?; + imported.push(ImportedTxnOut { + entry_id, + date: txn.date.to_string(), + description: txn.description.clone(), + amount: money(txn.amount), + offset_account: offset_code, + }); + } + if link_source && let Some(att) = p.source_attachment_id { + store + .link_attachment(att, ledger_store::Subject::Account(bank.0)) + .map_err(|e| e.to_string())?; + } + let count = imported.len(); + Ok(Json(ImportBankCsvOut { + imported, + count, + source_attachment_id: p.source_attachment_id, + source_linked: link_source, + })) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::server::test_support::*; + + /// lines_settled: posted, or a draft with lines whose debits equal its + /// credits; a draft whose sides are past exact arithmetic (one an + /// earlier version journaled, rules.md B-28) is not settled. + #[test] + fn settled_lines_are_posted_or_balanced_drafts() { + let mut l = Ledger::new(Currency::USD); + let cash = l + .add_account("1000", "Cash", ledger_core::AccountType::Asset, None, false) + .unwrap(); + let rent = l + .add_account( + "6000", + "Rent", + ledger_core::AccountType::Expense, + None, + false, + ) + .unwrap(); + let mut draft = |lines: &[(u64, Side, Decimal)]| { + let e = l.create_entry(d(2026, 3, 1), "x"); + for (a, side, amount) in lines { + l.replay_add_line(e, *a, *side, Money::new(*amount, Currency::USD)) + .unwrap(); + } + e + }; + let ten = Decimal::TEN; + let empty = draft(&[]); + let balanced = draft(&[(rent, Side::Debit, ten), (cash, Side::Credit, ten)]); + let unbalanced = draft(&[(rent, Side::Debit, ten), (cash, Side::Credit, Decimal::ONE)]); + let huge = draft(&[ + (rent, Side::Debit, Decimal::MAX), + (rent, Side::Debit, Decimal::MAX), + (cash, Side::Credit, Decimal::MAX), + (cash, Side::Credit, Decimal::MAX), + ]); + let posted = draft(&[(rent, Side::Debit, ten), (cash, Side::Credit, ten)]); + l.post_entry(posted).unwrap(); + let settled = |e: u64| lines_settled(l.get_entry(e).unwrap()); + assert!(!settled(empty)); + assert!(settled(balanced)); + assert!(!settled(unbalanced)); + assert!(!settled(huge)); + assert!(settled(posted)); + } +} diff --git a/crates/ledger-mcp/src/tools/helpers.rs b/crates/ledger-mcp/src/tools/helpers.rs new file mode 100644 index 0000000..8a12991 --- /dev/null +++ b/crates/ledger-mcp/src/tools/helpers.rs @@ -0,0 +1,710 @@ +//! The helpers the tool groups share: resolving a name or a code to an +//! id, reading a tool's amounts and dates, shaping a party, a document or +//! a statement for output, and the journal's own readings. They live here +//! so that no group file reaches into another group's (plan/served.md 8.2 +//! U0a, Build step 2); `server.rs` re-exports them and keeps the request +//! machinery. + +use super::*; +use ledger_core::InstrumentId; + +/// The book's id, as the commands given to agents name it: its folder's +/// name in the data root. +pub(crate) fn book_name(store: &Store) -> String { + store + .data_dir() + .file_name() + .map(|n| n.to_string_lossy().to_string()) + .unwrap_or_default() +} + +/// A filed file's name and type: those given, else the upload's own. +pub(crate) fn named( + file_name: Option, + mime_type: Option, + file: &files::TakenFile, +) -> Result<(String, String), String> { + let name = file_name.unwrap_or_else(|| file.file_name.clone()); + let mime = mime_type.unwrap_or_else(|| file.mime.clone()); + if let Some(why) = ledger_store::uploads::file_name_problem(&name) + .or_else(|| ledger_store::uploads::mime_problem(&mime)) + { + return Err(why); + } + Ok((name, mime)) +} + +/// The settings as ledger_package reads them: the company, and the +/// `[books]` cash, financing, and investing roles. +pub(crate) fn package_settings(settings: &Settings) -> ledger_package::Settings<'_> { + ledger_package::Settings { + company: &settings.company, + cash_accounts: &settings.roles.cash, + financing_accounts: &settings.roles.financing, + investing_accounts: &settings.roles.investing, + } +} + +/// A decimal string, exactly `-?digits[.digits]` (INVARIANT 3). +pub(crate) fn parse_amount(s: &str) -> Result { + ledger_config::decimal(s) + .ok_or_else(|| format!("bad amount {s:?}: expected a decimal string like \"123.45\"")) +} + +pub(crate) fn parse_side(s: &str) -> Result { + match s.to_lowercase().as_str() { + "debit" => Ok(Side::Debit), + "credit" => Ok(Side::Credit), + _ => Err(format!("bad side {s:?}: expected \"debit\" or \"credit\"")), + } +} + +/// Every account code a live convention names: (convention id, code). +pub(crate) fn convention_codes(ledger: &Ledger) -> Vec<(String, String)> { + let mut out = Vec::new(); + for c in ledger + .conventions() + .conventions() + .filter(|c| c.retired.is_none()) + { + if let Some(t) = c.template() { + for l in t.lines { + if ledger_core::conventions::slot(&l.account).is_none() { + out.push((c.id.clone(), l.account)); + } + } + } + if let Some(r) = c.approval_rule() { + for a in r.accounts { + out.push((c.id.clone(), a)); + } + } + } + out.dedup(); + out +} + +pub(crate) fn settings_problems(ledger: &Ledger, cfg: &Settings) -> Vec { + let mut out = Vec::new(); + // Named as the books keep them. + let how = " with books.set_setting"; + // The cash flow roles: codes the chart lacks here; the rest is the + // engine's own check, the one the statement refuses on (rules.md B-14 + // rule 2), so the two never disagree. + let lists = [ + ( + ledger_core::RoleList::Cash, + "books.cash_accounts", + &cfg.roles.cash, + ), + ( + ledger_core::RoleList::Financing, + "books.financing_accounts", + &cfg.roles.financing, + ), + ( + ledger_core::RoleList::Investing, + "books.investing_accounts", + &cfg.roles.investing, + ), + ]; + let mut ids: [Vec; 3] = Default::default(); + for ((_, setting, codes), ids) in lists.iter().zip(&mut ids) { + for code in codes.iter() { + match ledger.chart().find_by_code(code) { + Some(a) => ids.push(a.id), + None => out.push(format!( + "{setting} names {code}, which isn't in the chart: add that account, or change {setting}{how}" + )), + } + } + } + let named = |l: ledger_core::RoleList| { + lists + .iter() + .find(|(list, _, _)| *list == l) + .map_or(String::new(), |(_, setting, _)| setting.to_string()) + }; + for p in ledger_core::role_problems(ledger, &ids[0], &ids[1], &ids[2]) { + out.push(format!("{}{how}", p.message(named))); + } + let mut check = |setting: &str, code: &str, want: &[AccountType]| { + match ledger.chart().find_by_code(code) { + None => out.push(format!( + "{setting} names {code}, which isn't in the chart: add that account, or change {setting}{how}" + )), + Some(a) if !want.contains(&a.account_type) => out.push(format!( + "{setting} names {code} \"{}\", a {:?} account, but needs {}: change {setting}{how}", + a.name, + a.account_type, + want.iter().map(|t| format!("{t:?}")).collect::>().join(" or "), + )), + Some(_) => {} + } + }; + use AccountType::*; + + let c = &cfg.company; + for m in &c.members { + check( + &format!("{} {} capital_account", "company.members", m.name), + &m.capital_account, + &[Equity], + ); + if let Some(d) = &m.distribution_account { + check( + &format!("{} {} distribution_account", "company.members", m.name), + d, + &[Equity], + ); + } + } + if let Some(code) = &c.close_into { + check("books.close_into", code, &[Equity]); + } + for code in &c.close_sweep { + check("books.close_sweep", code, &[Equity]); + } + // Conventions naming an account no longer in the chart (renumbered or + // never there): a template can't draft, a rule covers nothing. + for (id, code) in convention_codes(ledger) { + if ledger.chart().find_by_code(&code).is_none() { + out.push(format!( + "convention {id} names account {code}, which isn't in the chart: revise it with books.set_convention" + )); + } + } + out +} + +/// An asset named by a string that is its current tag or its id. A tag +/// wins: tags like "1001" are allowed, and the label is what the human +/// reads. +pub(crate) fn resolve_asset_ref(store: &Store, r: &str) -> Result { + if let Some(a) = store.ledger().asset_by_tag(r) { + return Ok(a.id); + } + match r.parse::() { + Ok(id) if store.ledger().get_asset(id).is_some() => Ok(id), + _ => Err(format!("no asset has the tag or id {r:?}")), + } +} + +/// Parse a calendar-month period "YYYY-MM". +pub(crate) fn parse_period_mcp(period: &str) -> Result<(i32, u32), String> { + let err = || format!("bad period {period:?}: expected YYYY-MM"); + let mut parts = period.split('-'); + let year: i32 = parts.next().ok_or_else(err)?.parse().map_err(|_| err())?; + let month: u32 = parts.next().ok_or_else(err)?.parse().map_err(|_| err())?; + // A month of the calendar the books keep (ledger_core::calendar). + if parts.next().is_some() || !(1..=9999).contains(&year) || !(1..=12).contains(&month) { + return Err(err()); + } + Ok((year, month)) +} + +/// Last calendar day of a month: refused, never a panic, for one chrono +/// can't reach (P0 review round 3, S1). +pub(crate) fn last_day_of_month(year: i32, month: u32) -> Result { + NaiveDate::from_ymd_opt(year, month, 1) + .and_then(|first| first.checked_add_months(chrono::Months::new(1))) + .and_then(|next| next.pred_opt()) + .ok_or_else(|| format!("bad period {year:04}-{month:02}: no such month")) +} + +/// Resolve an account reference: an account code, or a numeric id. A +/// number that is one account's code and another's id is refused +/// (INVARIANT 6). +pub(crate) fn resolve_account(store: &Store, account: &str) -> Result { + let chart = store.ledger().chart(); + let by_code = chart.find_by_code(account).map(|a| a.id); + let by_id = account + .parse::() + .ok() + .filter(|id| chart.get(*id).is_some()); + match (by_code, by_id) { + (Some(c), Some(i)) if c != i => Err(format!( + "account {account:?} is ambiguous: it is the code of account {c} and the id of account {i}; pass the code of the one you mean" + )), + (Some(c), _) => Ok(c), + (None, Some(i)) => Ok(i), + (None, None) => Err(format!( + "unknown account {account:?}: use an id or code from books.chart" + )), + } +} + +/// "invoice" or "bill" and an id, as a document. +pub(crate) fn doc_ref(kind: &str, id: u64) -> Result { + match kind.trim().to_ascii_lowercase().as_str() { + "invoice" => Ok(ledger_core::DocRef::Invoice(id)), + "bill" => Ok(ledger_core::DocRef::Bill(id)), + other => Err(format!("bad kind {other:?}: expected invoice or bill")), + } +} + +/// The control check as `books.control_check` answers it. +pub(crate) fn control_check_out( + ledger: &Ledger, + as_of: chrono::NaiveDate, + checks: &[ledger_core::ControlCheck], +) -> ControlCheckOut { + let accounts: Vec = checks + .iter() + .map(|c| { + let acct = ledger.chart().get(c.account); + ControlCheckRowOut { + role: c.role.clone(), + code: acct.map(|a| a.code.clone()).unwrap_or_default(), + name: acct.map(|a| a.name.clone()).unwrap_or_default(), + balance: money(c.balance), + documents_total: money(c.documents_total), + unattached: c + .unattached + .iter() + .map(|u| UnattachedLineOut { + entry_id: u.entry, + line: u.line, + date: u.date.to_string(), + amount: money(u.amount), + }) + .collect(), + difference: money(c.difference), + attention: c + .attention + .iter() + .map(|a| { + let (kind, id) = match a.doc { + ledger_core::DocRef::Invoice(id) => ("invoice", id), + ledger_core::DocRef::Bill(id) => ("bill", id), + }; + DocAttentionOut { + kind: kind.to_string(), + id, + status: a.status.key().to_string(), + total: money(a.total), + outstanding: money(a.outstanding), + } + }) + .collect(), + } + }) + .collect(); + let agrees = checks + .iter() + .all(|c| c.difference.is_zero() && c.unattached.is_empty() && c.attention.is_empty()); + ControlCheckOut { + as_of: as_of.to_string(), + agrees, + accounts, + } +} + +/// Resolve a party by id or by name: the name ignoring case and +/// surrounding space, among parties not archived (an archived party goes +/// by id). A number that is one party's id and another's name is refused +/// (INVARIANT 6). +pub(crate) fn resolve_party(store: &Store, party: &str) -> Result { + let ledger = store.ledger(); + let by_id = party + .trim() + .parse::() + .ok() + .filter(|id| ledger.get_party(*id).is_some()); + let by_name = ledger.party_by_name(party).map(|p| p.id); + match (by_id, by_name) { + (Some(i), Some(n)) if i != n => Err(format!( + "party {party:?} is ambiguous: it is party {i}'s id and party {n}'s name; pass the id" + )), + (Some(i), _) | (None, Some(i)) => Ok(i), + (None, None) => Err(format!( + "unknown party {party:?}: use an id or name from books.parties (an archived party goes by id), or add it with books.add_party" + )), + } +} + +/// A stored attachment's metadata, by id. +pub(crate) fn find_attachment(store: &Store, id: u64) -> Result { + store + .list_attachments(None) + .map_err(|e| e.to_string())? + .into_iter() + .find(|a| a.id == id) + .ok_or_else(|| format!("unknown attachment {id}")) +} + +/// An attachment for a listing, with its links. +pub(crate) fn receipt_out( + store: &Store, + a: ledger_store::Attachment, +) -> Result { + Ok(ReceiptOut { + links: links_out(store, a.id)?, + attachment_id: a.id, + sha256: a.blob_sha, + entry_id: a.entry_id, + category: a.category, + file_name: a.file_name, + mime_type: a.mime_type, + size_bytes: a.size_bytes, + description: a.description, + withdrawn: a.withdrawn_at.is_some(), + withdrawn_reason: a.withdrawn_reason, + }) +} + +/// An account's code and name. +pub(crate) fn code_name(store: &Store, id: AccountId) -> (String, String) { + store + .ledger() + .chart() + .get(id) + .map(|a| (a.code.clone(), a.name.clone())) + .unwrap_or_default() +} + +/// The latest statement date an account is reconciled through +/// (reconciliations not voided), if any. +pub(crate) fn reconciled_through( + store: &Store, + account: AccountId, +) -> Result, String> { + Ok(store + .reconciliations(Some(account)) + .map_err(|e| e.to_string())? + .into_iter() + .filter(|r| r.voided.is_none()) + .map(|r| r.statement_date) + .max()) +} + +/// The last four digits of every TIN the books know of, from the journal +/// (each party's TIN and W-9 facts), never the vault: the filing scan's +/// probe bound (tin_files.rs INVARIANT 4). +pub(crate) fn known_tin_last4(store: &Store) -> Vec { + store + .ledger() + .parties() + .flat_map(|p| { + [ + p.tin.as_ref().map(|t| t.last4.as_str()), + p.w9.as_ref().map(|w| w.tin_last4.as_str()), + ] + }) + .flatten() + .filter_map(|l| l.parse().ok()) + .collect() +} + +/// An optional party text from a tool: trimmed, and "" clears it. +pub(crate) fn optional_text(s: &str) -> Option { + let t = s.trim(); + (!t.is_empty()).then(|| t.to_string()) +} + +/// The sum of `values`, each computed or refused: refused (as text) when +/// one is, or when the sum is past exact arithmetic. Figures an earlier +/// version journaled are as journaled (rules.md B-28). +pub(crate) fn checked_sum( + values: impl IntoIterator>, + what: &str, +) -> Result { + values.into_iter().try_fold(Decimal::ZERO, |sum, v| { + let v = v.map_err(|e| e.to_string())?; + sum.checked_add(v).ok_or_else(|| { + ledger_core::LedgerError::TooLargeToCompute(what.to_string()).to_string() + }) + }) +} + +/// The `party_required` refusal for an engine-posted entry (payroll, a +/// remittance, a disposal) that names no counterparty under `[books] +/// parties = "require"`: `what` says what to pass. +pub(crate) fn party_required(what: &str) -> String { + serde_json::json!({ + "code": "party_required", + "message": format!( + "these books require a counterparty on every entry (their parties setting is \"require\"): {what} (add the party with books.add_party first if it's new)" + ), + }) + .to_string() +} + +/// The chart's ids for these account codes (codes it lacks are skipped). +pub(crate) fn cash_ids(ledger: &Ledger, codes: &[String]) -> Vec { + codes + .iter() + .filter_map(|c| ledger.chart().find_by_code(c).map(|a| a.id)) + .collect() +} + +/// What party shares resolve with ([`ledger_core::shares::resolve`]): the +/// money accounts over the `[books] cash_accounts` and +/// `financing_accounts` (with the cards), and the members. +pub(crate) fn share_context( + ledger: &Ledger, + settings: &Settings, +) -> ( + ledger_core::parties::MoneyAccounts, + Vec, +) { + ( + ledger_core::parties::money_accounts( + ledger, + &cash_ids(ledger, &settings.roles.cash), + &cash_ids(ledger, &settings.roles.financing), + ), + settings.company.party_members(ledger), + ) +} + +/// `parties` params as shares: each party resolved, `amount` or `percent` +/// parsed (not both), neither meaning derived. +pub(crate) fn parse_shares( + store: &Store, + params: &[PartyShareParam], +) -> Result, String> { + params + .iter() + .map(|p| { + let party = resolve_party(store, &p.party)?; + let share = match (&p.amount, &p.percent) { + (Some(_), Some(_)) => { + return Err(format!( + "party {}: give an amount or a percent, not both", + p.party + )); + } + (Some(a), None) => ledger_core::Share::Amount(parse_amount(a)?), + (None, Some(pc)) => ledger_core::Share::Percent(parse_amount(pc)?), + (None, None) => ledger_core::Share::Derived, + }; + Ok(ledger_core::PartyShare { party, share }) + }) + .collect() +} + +/// Whether an entry's lines are final enough to check shares against: +/// posted, or a draft with lines whose debits equal its credits. +pub(crate) fn lines_settled(entry: &JournalEntry) -> bool { + if entry.status() == ledger_core::EntryStatus::Posted { + return true; + } + // A draft's lines are as journaled, of any size (rules.md B-28): sides + // past exact arithmetic are not settled lines. + let side = |s: Side| -> Option { + entry + .lines() + .iter() + .filter(|l| l.side == s) + .try_fold(Decimal::ZERO, |sum, l| sum.checked_add(l.amount.amount)) + }; + match (side(Side::Debit), side(Side::Credit)) { + (Some(debits), Some(credits)) => !entry.lines().is_empty() && debits == credits, + _ => false, + } +} + +/// Fix a posted entry's derived split among several parties: journal the +/// exact amounts they resolve to now, so a later change to +/// `[[company.members]]` (a new income split, a member archived) never +/// re-splits posted history. +/// +/// ```text +/// GOAL: posted is forever, the split between its parties included. +/// SOURCE: AGENTS.md ("Posted is forever"); docs/parties-and-attachments.md +/// "Shares"; ledger_core::shares INVARIANT 10 (derived several: +/// the members' capital lines, else their income shares, both read +/// from the settings). +/// INVARIANTS: +/// 1. Only a posted entry naming two or more parties with derived shares +/// changes: its shares become Amount(|part|) for each party, in +/// order, journaled as entry.parties_set (the reversal chain too), so +/// each party's resolved part is exactly what it was. +/// 2. Nothing changes when the shares don't resolve, or a part is zero +/// (an amount must be positive): the entry keeps its derived shares. +/// ``` +pub(crate) fn fix_derived_shares( + store: &mut Store, + settings: &Settings, + entry_id: u64, +) -> Result<(), String> { + let ledger = store.ledger(); + let Some(entry) = ledger.get_entry(entry_id) else { + return Ok(()); + }; + let shares = ledger.entry_parties(entry_id); + if entry.status() != ledger_core::EntryStatus::Posted + || shares.len() < 2 + || !shares + .iter() + .any(|s| matches!(s.share, ledger_core::Share::Derived)) + { + return Ok(()); + } + let (money, members) = share_context(ledger, settings); + let Ok(resolved) = ledger_core::shares::resolve(ledger, entry_id, shares, &money, &members) + else { + return Ok(()); + }; + if resolved.parts.iter().any(|p| p.amount.is_zero()) { + return Ok(()); + } + let fixed: Vec = resolved + .parts + .iter() + .map(|p| ledger_core::PartyShare { + party: p.party, + share: ledger_core::Share::Amount(p.amount.abs()), + }) + .collect(); + store + .set_entry_parties(entry_id, &fixed) + .map_err(|e| e.to_string()) +} + +/// Resolve a class (a project) by id or name, trimmed, the name ignoring +/// capitals as the duplicate check does; a number that is one class's id +/// and another's name is refused (INVARIANT 6). New names can't be digits +/// only, so only old books can hold such a pair. +pub(crate) fn resolve_class(store: &Store, class: &str) -> Result { + let class = class.trim(); + let by_id = class + .parse::() + .ok() + .filter(|id| store.ledger().get_class(*id).is_some()); + let by_name = store.ledger().find_class_by_name(class).map(|c| c.id); + match (by_id, by_name) { + (Some(i), Some(n)) if i != n => { + let name = store + .ledger() + .get_class(i) + .map(|c| c.name.clone()) + .unwrap_or_default(); + Err(format!( + "project {class:?} is ambiguous: it is project {i}'s id and project {n}'s name; pass {n} for project {n}, or {name:?} (project {i}'s name) for project {i}" + )) + } + (Some(i), _) | (None, Some(i)) => Ok(i), + (None, None) => Err(format!( + "unknown project {class:?}: use an id or name from books.projects" + )), + } +} + +/// A tool description that ends with the one sentence for its kind of +/// dates (rules.md B-30), so no tool says other than the resolver does. +pub(crate) fn described(what: &str, kind: ledger_core::calendar::PeriodKind) -> String { + format!("{what} {}", kind.describe()) +} + +/// The warning for a revenue or expense account created without a tax +/// category (rules.md B-32). +pub(crate) fn uncategorized_warning(code: &str, name: &str) -> String { + format!( + "{} {} 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.", + code.trim(), + name.trim() + ) +} + +/// The warnings for the revenue and expense accounts an operation created +/// (control accounts: Revenue, General Expenses, payroll's expenses) +/// without a tax category: every account from `first_new` on. +pub(crate) fn created_uncategorized( + ledger: &Ledger, + first_new: ledger_core::AccountId, +) -> Vec { + let mut new: Vec<&ledger_core::Account> = ledger + .chart() + .all() + .filter(|a| a.id >= first_new && a.tax_category.is_none()) + .filter(|a| ledger_core::tax::needs_category(a.account_type, a.placeholder)) + .collect(); + new.sort_by_key(|a| a.id); + new.into_iter() + .map(|a| uncategorized_warning(&a.code, &a.name)) + .collect() +} + +/// Everything an attachment is linked to, for a tool's output. +pub(crate) fn links_out(store: &Store, attachment_id: u64) -> Result, String> { + Ok(store + .attachment_links(attachment_id) + .map_err(|e| e.to_string())? + .into_iter() + .map(|s| subject_out(store, s)) + .collect()) +} + +/// A subject for a tool's output, with human words for it. +pub(crate) fn subject_out(store: &Store, sub: ledger_store::Subject) -> SubjectOut { + use ledger_store::Subject as S; + let l = store.ledger(); + let label = match sub { + S::Entry(i) => l + .get_entry(i) + .map(|e| format!("entry #{i} {}", e.description())), + S::Account(i) => l + .chart() + .get(i) + .map(|a| format!("account {} {}", a.code, a.name)), + S::Asset(i) => l.get_asset(i).map(|a| match &a.tag { + Some(t) => format!("asset {t} {}", a.description), + None => format!("asset {i} {}", a.description), + }), + S::Party(i) => l.get_party(i).map(|p| format!("party {i} {}", p.label())), + S::Project(i) => u32::try_from(i) + .ok() + .and_then(|c| l.get_class(c)) + .map(|c| format!("project {i} {}", c.name)), + S::Period(i) => l + .list_periods() + .iter() + .find(|p| p.id == i) + .map(|p| format!("period {}", p.name)), + S::PayrollRun(i) => l + .get_payroll_run(i) + .map(|r| format!("payroll run {i} of {}", r.date)), + S::Schedule(i) => l + .get_schedule(i) + .map(|s| format!("schedule {i} {}", s.description)), + S::Reconciliation(i) => store.reconciliations(None).ok().and_then(|rs| { + rs.into_iter().find(|r| r.id == i).map(|r| { + let code = l + .chart() + .get(r.account_id) + .map(|a| a.code.clone()) + .unwrap_or_default(); + format!("reconciliation {i} of {code} through {}", r.statement_date) + }) + }), + S::Invoice(_) | S::Bill(_) | S::Company => None, + } + .unwrap_or_else(|| sub.describe()); + SubjectOut { + kind: sub.kind().to_string(), + id: (sub != S::Company).then(|| sub.id()), + label, + } +} + +/// Resolve a payment method by id or label (INVARIANT 5). +pub(crate) fn resolve_instrument(store: &Store, r: &str) -> Result { + let ledger = store.ledger(); + let by_id = r + .trim() + .parse::() + .ok() + .filter(|id| ledger.get_instrument(*id).is_some()); + let by_label = ledger.instrument_by_label(r).map(|i| i.id); + match (by_id, by_label) { + (Some(i), Some(l)) if i != l => Err(format!( + "payment method {r:?} is ambiguous: it is method {i}'s id and method {l}'s label; pass the id" + )), + (Some(i), _) | (None, Some(i)) => Ok(i), + (None, None) => Err(format!( + "unknown payment method {r:?}: use an id or label from books.instruments (books.add_instrument adds one)" + )), + } +} diff --git a/crates/ledger-mcp/src/tools/instruments.rs b/crates/ledger-mcp/src/tools/instruments.rs index 4b19675..fd67ad5 100644 --- a/crates/ledger-mcp/src/tools/instruments.rs +++ b/crates/ledger-mcp/src/tools/instruments.rs @@ -25,8 +25,8 @@ //! ``` use super::*; +use ledger_core::InstrumentKind; use ledger_core::form1099::{Applied, Dated1099, Decision1099, ExemptReason, Form1099}; -use ledger_core::{InstrumentId, InstrumentKind}; use schemars::JsonSchema; use serde::{Deserialize, Serialize}; @@ -189,26 +189,6 @@ fn instrument_out(store: &Store, i: &ledger_core::Instrument) -> InstrumentOut { } } -/// Resolve a payment method by id or label (INVARIANT 5). -pub(super) fn resolve_instrument(store: &Store, r: &str) -> Result { - let ledger = store.ledger(); - let by_id = r - .trim() - .parse::() - .ok() - .filter(|id| ledger.get_instrument(*id).is_some()); - let by_label = ledger.instrument_by_label(r).map(|i| i.id); - match (by_id, by_label) { - (Some(i), Some(l)) if i != l => Err(format!( - "payment method {r:?} is ambiguous: it is method {i}'s id and method {l}'s label; pass the id" - )), - (Some(i), _) | (None, Some(i)) => Ok(i), - (None, None) => Err(format!( - "unknown payment method {r:?}: use an id or label from books.instruments (books.add_instrument adds one)" - )), - } -} - /// The decision `books.set_vendor_1099` was given, or none when it /// changes only the W-9 flag (INVARIANTS 1-3). pub(super) fn decision_in(p: &SetVendor1099Params) -> Result, String> { diff --git a/crates/ledger-mcp/src/tools/parties.rs b/crates/ledger-mcp/src/tools/parties.rs new file mode 100644 index 0000000..cf0e961 --- /dev/null +++ b/crates/ledger-mcp/src/tools/parties.rs @@ -0,0 +1,1520 @@ +//! Parties through the server: customers, vendors and other parties, +//! their roles, W-9s, the TIN vault, 1099s, and the entry's parties. +//! Split whole out of `server.rs` (plan/served.md 8.2 U0a). + +use super::*; + +/// The 1099 worksheet for `year`, the one every tool and the CPA package +/// use (ledger_package::vendor_1099_report): payments out of the cash and +/// financing roles, an entry's shares resolved with the settings' members +/// (as [`share_context`] checks them). +fn vendor_1099_report( + ledger: &ledger_core::Ledger, + settings: &Settings, + year: i32, + threshold: Option, +) -> Result { + ledger_package::vendor_1099_report(ledger, &package_settings(settings), year, threshold, None) +} + +/// A TIN type from a tool: ssn, ein, or itin. +fn parse_tin_type(s: &str) -> Result { + match s.trim().to_lowercase().as_str() { + "ssn" => Ok(ledger_vault::TinType::Ssn), + "ein" => Ok(ledger_vault::TinType::Ein), + "itin" => Ok(ledger_vault::TinType::Itin), + _ => Err("tin_type is ssn, ein, or itin".into()), + } +} + +/// Parse role names (`Role::key`), refusing any unknown one. +fn parse_roles(roles: &[String]) -> Result, String> { + roles + .iter() + .map(|r| { + ledger_core::Role::parse(r.trim()).ok_or_else(|| { + format!( + "unknown role {r:?}: expected one of {}", + ledger_core::Role::ALL.map(|r| r.key()).join(", ") + ) + }) + }) + .collect() +} + +/// A party's roles (vendor and customer included), in display order. +fn party_roles(p: &ledger_core::Party) -> Vec { + let all = p.all_roles(); + ledger_core::Role::ALL + .into_iter() + .filter(|r| all.contains(r)) + .map(|r| r.key().to_string()) + .collect() +} + +/// A party's facts for a tool's output. +fn party_out(ledger: &Ledger, p: &ledger_core::Party) -> PartyOut { + PartyOut { + party_id: p.id, + name: p.name.clone(), + label: p.label().to_string(), + display_name: p.display_name.clone(), + roles: party_roles(p), + website: p.website.clone(), + address: p.address.clone(), + notes: p.notes.clone(), + archived: p.archived, + vendor_id: p.vendor, + customer_id: p.customer, + w9: p.w9.as_ref().map(|w| W9Out { + received_on: w.received.to_string(), + entity_type: w.entity_type.clone(), + exempt_payee_code: w.exempt_payee_code.clone(), + fatca_code: w.fatca_code.clone(), + tin_last4: w.tin_last4.clone(), + kept_at: w.kept_at.clone(), + in_vault: w.vault_record.is_some(), + }), + w4: p.w4.as_ref().map(|w| W4Out { + received_on: w.received.to_string(), + state: w.state.clone(), + kept_at: w.kept_at.clone(), + }), + tin: p.tin.as_ref().map(|t| TinOut { + tin_type: t.tin_type.key().to_string(), + last4: t.last4.clone(), + in_vault: t.vault_record.is_some(), + purged: t.purged.clone(), + }), + creditor_of: p + .creditor_of + .iter() + .filter_map(|a| ledger.chart().get(*a).map(|a| a.code.clone())) + .collect(), + } +} + +/// A document's status for a tool's output. +fn status_key(s: DocumentStatus) -> String { + match s { + DocumentStatus::Open => "open", + DocumentStatus::Partial => "partial", + DocumentStatus::Paid => "paid", + } + .to_string() +} + +/// An invoice or bill for `books.party`: its (date, due date), (total, +/// paid so far), status, and entry. +fn party_document( + id: u64, + (date, due): (NaiveDate, NaiveDate), + (total, balance, paid): ( + Result, + Result, + Decimal, + ), + status: DocumentStatus, + entry_id: u64, +) -> Result { + Ok(PartyDocumentOut { + id, + date: date.to_string(), + due_date: due.to_string(), + total: money(total.map_err(|e| e.to_string())?), + paid: money(paid), + balance: money(balance.map_err(|e| e.to_string())?), + status: status_key(status), + entry_id, + }) +} + +/// A party-totals `basis` from a tool: "cash" (the default) or "all" +/// (rules.md B-35 item 4); any other word is refused. +fn parse_basis(basis: Option<&str>) -> Result { + match basis { + None => Ok(ledger_core::parties::Basis::default()), + Some(b) => ledger_core::parties::Basis::parse(b) + .ok_or_else(|| format!("basis is \"cash\" or \"all\", not {b:?}")), + } +} + +/// Everything the books did with one party (`PartyTotalsOut` defines it). +struct PartyActivity { + /// The party's entries, drafts included. + entries: std::collections::BTreeSet, + /// Paid to it in the year, on the basis asked. + paid: Decimal, + /// Received from it in the year, on the basis asked. + received: Decimal, +} + +/// The party's entries and the year's paid and received on `basis` +/// (`PartyTotalsOut`): ledger_core::parties::activity_with over the +/// calendar year, with the money accounts of [`share_context`] and the +/// members (so an entry naming several parties counts this one's part). +fn party_activity( + store: &Store, + party: &ledger_core::Party, + settings: &Settings, + year: i32, + basis: ledger_core::parties::Basis, +) -> Result { + let ledger = store.ledger(); + let span = ( + NaiveDate::from_ymd_opt(year, 1, 1).ok_or("year out of range")?, + NaiveDate::from_ymd_opt(year, 12, 31).ok_or("year out of range")?, + ); + let (money, members) = share_context(ledger, settings); + let a = ledger_core::parties::activity_with(ledger, party.id, span, &money, &members); + let (paid, received) = a.totals(basis); + Ok(PartyActivity { + entries: a.entries, + paid, + received, + }) +} + +/// Refuse shares that don't resolve against the entry's lines, once the +/// lines are settled ([`lines_settled`]). +fn check_shares_resolve( + store: &Store, + settings: &Settings, + entry_id: u64, + shares: &[ledger_core::PartyShare], +) -> Result<(), String> { + let ledger = store.ledger(); + let entry = ledger + .get_entry(entry_id) + .ok_or_else(|| format!("unknown entry {entry_id}"))?; + if !lines_settled(entry) { + return Ok(()); + } + let (money, members) = share_context(ledger, settings); + ledger_core::shares::resolve(ledger, entry_id, shares, &money, &members).map(|_| ()) +} + +/// The party's customer record's invoices, by id. +fn party_invoices<'a>( + ledger: &'a Ledger, + party: &ledger_core::Party, +) -> Vec<&'a ledger_core::Invoice> { + let Some(c) = party.customer else { + return Vec::new(); + }; + ledger + .invoices() + .into_iter() + .filter(|i| i.customer_id == c) + .collect() +} + +/// The party's vendor record's bills, by id. +fn party_bills<'a>(ledger: &'a Ledger, party: &ledger_core::Party) -> Vec<&'a ledger_core::Bill> { + let Some(v) = party.vendor else { + return Vec::new(); + }; + ledger + .bills() + .into_iter() + .filter(|b| b.vendor_id == v) + .collect() +} + +#[tool_router(router = parties_router, vis = "pub(super)")] +impl BooksServer { + #[tool( + name = "books.list_vendors", + title = "List vendors", + description = "Vendors, each with its 1099 decision in force today (nec, misc, not_reportable with its reason, or undetermined; who decided and when) and every dated decision, whether its W-9 is on file, and the party it belongs to (books.party). undetermined with no decided_by: nobody has decided yet (books.set_vendor_1099).", + annotations(read_only_hint = true, open_world_hint = false) + )] + async fn list_vendors(&self) -> Result, String> { + let store = self.lock()?; + Ok(Json(ListVendorsOut { + vendors: store + .ledger() + .list_vendors() + .into_iter() + .map(|v| instruments::vendor_out(&store, v)) + .collect(), + })) + } + + #[tool( + name = "books.set_vendor_1099", + title = "Record a vendor's 1099 decision", + description = "Record whether a vendor gets a 1099, with who decided (decided_by): nec (1099-NEC: contractors, attorneys), misc (1099-MISC: rent, royalties), not_reportable with reason_kind (corporation, tax_exempt, united_states, state, foreign_government, foreign_person, merchandise, employee, other with a citation) and reason in words, or undetermined. A corporation paid for legal or medical services stays nec/misc. Paid by card is never a reason (cards are excluded per payment). effective: only when the payee's status changed (an LLC's corporate election, from its date); a payee that incorporates is a new vendor. tin_on_file: a W-9 is on file (alone, it keeps the decision). Ask the human; CPA confirms.", + annotations( + read_only_hint = false, + destructive_hint = false, + idempotent_hint = true, + open_world_hint = false + ) + )] + async fn set_vendor_1099( + &self, + params: Parameters, + ) -> Result, String> { + let p = params.0; + let mut store = self.lock()?; + let id = resolve_vendor(&store, &p.vendor)?; + let decision = instruments::decision_in(&p)?; + let (changed, warnings) = match decision { + None => { + let flag = p.tin_on_file.expect("decision_in: one of the two"); + store + .set_vendor_w9_flag(id, flag) + .map_err(|e| e.to_string())?; + (ledger_core::form1099::Applied::Unchanged, Vec::new()) + } + Some(d) => { + let reached = ledger_core::form1099::decision_changes(store.ledger(), id, &d) + .map_err(|e| e.to_string())?; + let applied = store + .set_vendor_1099(id, &d, p.tin_on_file) + .map_err(|e| e.to_string())?; + let name = &store.ledger().get_vendor(id).expect("resolved").name; + let warnings = instruments::filed_year_warnings( + store.ledger(), + &format!("{name}'s 1099 decision"), + &reached, + today(), + ); + (applied, warnings) + } + }; + let v = store.ledger().get_vendor(id).expect("exists"); + Ok(Json(SetVendor1099Out { + vendor: instruments::vendor_out(&store, v), + changed: instruments::applied_key(changed).to_string(), + warnings, + })) + } + + #[tool( + name = "books.set_entry_vendor", + title = "Tag an entry's vendor", + description = "Tag an entry (draft or posted) with the vendor it paid, for 1099 tracking, or omit vendor to clear the tag. Reporting metadata only: amounts never change. On an entry naming one party or none, the vendor's party becomes its party; clearing also clears that party. On an entry naming several parties (books.set_entry_party parties) the parties stay as they are: the vendor of a party already named is tagged with every share kept; any other vendor, or clearing the tag while its party is named, is refused (change the parties instead). An entry and its reversal are tagged together. books.pay_bill tags its payments automatically; books.draft_entry takes a vendor too.", + annotations( + read_only_hint = false, + destructive_hint = false, + open_world_hint = false + ) + )] + async fn set_entry_vendor( + &self, + params: Parameters, + ) -> Result, String> { + let p = params.0; + { + let mut store = self.lock()?; + let vendor = p + .vendor + .as_deref() + .map(|v| resolve_vendor(&store, v)) + .transpose()?; + store + .set_entry_vendor(p.entry_id, vendor) + .map_err(|e| e.to_string())?; + } + self.get_entry(Parameters(GetEntryParams { + entry_id: p.entry_id, + })) + .await + } + + #[tool( + name = "books.vendor_1099", + title = "1099 worksheet", + description = "The 1099 worksheet for a calendar year. Per vendor: what counts toward its 1099 (paid out of the cash accounts, or through a financing account with the method recorded, while not decided not_reportable; an entry with several parties counts only the vendor's share), what was paid by card or payment network (excluded: Form 1099-K's), what is undetermined (a member paid, method not recorded: books.set_entry_instrument), its 1099 decision, and its status (reportable at the threshold: $600 through 2025, $2,000 in 2026, indexed after; under_threshold; not_reportable; undetermined). Lists those payments, W-9s missing, each payee's TIN last four. include_tins adds full TINs from the vault; csv hands it over. cash_accounts and financing_accounts default to the settings' (as books.export_cpa_package's do); through stops the year at a day: given a package's accounts and its as_of, the CSV is the package's 1099 worksheet. A year whose threshold isn't known is refused: pass threshold from the IRS. For the CPA to confirm.", + annotations( + read_only_hint = false, + destructive_hint = false, + open_world_hint = false + ) + )] + async fn vendor_1099( + &self, + params: Parameters, + ) -> Result, String> { + let p = params.0; + let threshold = p.threshold.as_deref().map(parse_amount).transpose()?; + let through = p.through.as_deref().map(parse_date).transpose()?; + if let Some(t) = through + && chrono::Datelike::year(&t) != p.year + { + return Err(format!( + "through ({t}) is a day of {}: the worksheet counts payments made in {} from January 1 through it", + p.year, p.year + )); + } + let store = self.lock()?; + let ledger = store.ledger(); + for code in p.cash_accounts.iter().chain(&p.financing_accounts) { + if ledger.chart().find_by_code(code).is_none() { + return Err(format!( + "no account {code} in the chart (books.chart lists them)" + )); + } + } + // With include_tins, the vault is opened once (never creating a + // key: a locked vault becomes a note on each line). + let vault = p.include_tins.then(|| self.open_vault(&store, false)); + let tins = vault + .as_ref() + .map(|vault| move |id| vault_tin(ledger, vault, id)); + // The accounts given, else the settings': the CPA package's rule + // (ledger_package::Settings::with_accounts), so its 1099 worksheet + // is this one for the same accounts and `through` its as_of. + let settings = self.settings(); + let accounts = + package_settings(&settings).with_accounts(&p.cash_accounts, &p.financing_accounts); + // A year past what wanderbooks knows is refused with guidance + // (research or ask, pass `threshold`, then update the rule). + let w = ledger_package::vendor_1099_worksheet( + ledger, + &accounts, + p.year, + threshold, + through, + tins.as_ref().map(|f| f as ledger_package::TinSource<'_>), + )?; + let warning = p.include_tins.then(|| { + "This worksheet holds full TINs, for the CPA; keep it out of the books.".to_string() + }); + let csv_file = match p.csv { + false => None, + true => { + let csv = ledger_package::vendor_1099_csv(&w, p.include_tins); + if p.include_tins { + self.wrote_tins(&store, csv.as_bytes())?; + } + let name = ledger_package::vendor_1099_csv_name(w.year, p.include_tins); + Some(self.hand_over(&book_name(&store), &name, "text/csv", csv.into_bytes())?) + } + }; + let payments = |list: &[ledger_core::Payment1099]| -> Vec { + list.iter() + .map(|p| Payment1099Out { + entry_id: p.entry_id, + date: p.date.to_string(), + vendor_id: p.vendor_id, + vendor: ledger + .get_vendor(p.vendor_id) + .map(|v| v.name.clone()) + .unwrap_or_default(), + amount: money(p.amount), + instrument: p + .instrument + .and_then(|i| ledger.get_instrument(i)) + .map(|i| i.label.clone()), + }) + .collect() + }; + let excluded_card_payments = payments(&w.excluded_card_payments); + let undetermined_payments = payments(&w.undetermined_payments); + let other_receipts = payments(&w.other_receipts); + let vendors = w + .lines + .into_iter() + .map(|l| Vendor1099LineOut { + vendor_id: l.vendor_id, + name: l.name, + form_1099: l.form_1099, + decision_1099: instruments::decision_out(Some(&ledger_core::Dated1099 { + effective: l.effective, + decision: l.decision.clone(), + source: l.decided.clone(), + })), + decisions_in_year: l + .decisions + .iter() + .map(|d| instruments::decision_out(Some(d))) + .collect(), + tin_on_file: l.tin_on_file, + paid: money(l.paid), + excluded_card: money(l.excluded_card), + undetermined: money(l.undetermined), + not_reportable: money(l.not_reportable), + status: l.status.key().to_string(), + decision_needed: l.decision_needed, + reportable: l.reportable, + party_id: l.party_id, + tin_last4: l.tin_last4, + tin: l.tin, + tin_type: l.tin_type, + tin_note: l.tin_note, + }) + .collect(); + Ok(Json(Vendor1099Out { + year: w.year, + threshold: money(w.threshold), + vendors, + missing_w9: w.missing_w9, + excluded_card_payments, + undetermined_payments, + other_receipts, + untagged_entries: w.untagged_payments, + unresolved: w.unresolved, + notes: w.notes, + warning, + csv: csv_file, + })) + } + + #[tool( + name = "books.add_customer", + title = "Add a customer", + description = "Add a customer record for accounts receivable (invoices). It links to the party of the same name that has no customer record yet (ignoring case), else to a new party; the output names both ids.", + annotations( + read_only_hint = false, + destructive_hint = false, + open_world_hint = false + ) + )] + async fn add_customer( + &self, + params: Parameters, + ) -> Result, String> { + let p = params.0; + let mut store = self.lock()?; + let id = store.add_customer(&p.name).map_err(|e| e.to_string())?; + let party_id = store + .ledger() + .party_of_customer(id) + .ok_or_else(|| format!("customer {id} has no party"))?; + Ok(Json(AddRecordOut { id, party_id })) + } + + #[tool( + name = "books.add_vendor", + title = "Add a vendor", + description = "Add a vendor record for accounts payable (bills) and 1099s. It links to the party of the same name that has no vendor record yet (ignoring case), else to a new party; the output names both ids.", + annotations( + read_only_hint = false, + destructive_hint = false, + open_world_hint = false + ) + )] + async fn add_vendor( + &self, + params: Parameters, + ) -> Result, String> { + let p = params.0; + let mut store = self.lock()?; + let id = store.add_vendor(&p.name).map_err(|e| e.to_string())?; + let party_id = store + .ledger() + .party_of_vendor(id) + .ok_or_else(|| format!("vendor {id} has no party"))?; + Ok(Json(AddRecordOut { id, party_id })) + } + + #[tool( + name = "books.add_party", + title = "Add a party", + description = "Add a party to the directory: anyone the company deals with (a contractor, landlord, payout platform, tax agency, government agency, bank or card issuer, member, employee). Name it as on its documents (unique among parties not archived); give its roles: contractor, employee, member, payout_platform, tax_agency, government_agency, financial_institution, landlord. Vendor and customer aren't given by hand: books.add_vendor and books.add_customer add those records and link them to the party of the same name. Never put a full TIN or SSN in any party field: the engine refuses nine-digit numbers. Journaled as party.added; returns the party.", + annotations( + read_only_hint = false, + destructive_hint = false, + open_world_hint = false + ) + )] + async fn add_party( + &self, + params: Parameters, + ) -> Result, String> { + let p = params.0; + let mut roles = parse_roles(&p.roles)?; + roles.sort(); + roles.dedup(); + let mut store = self.lock()?; + let id = store + .add_party(&p.name, &roles) + .map_err(|e| e.to_string())?; + let party = store.ledger().get_party(id).expect("just added"); + Ok(Json(party_out(store.ledger(), party))) + } + + #[tool( + name = "books.update_party", + title = "Update a party", + description = "Change a party's facts: name, display_name, website, address, notes, archived, creditor_of. Give only the fields to change; \"\" clears display_name, website, address or notes. creditor_of replaces the list of liabilities the company owes the party itself (its card, its loan, what the members advanced): a credit to one never counts as paying it. Every field is checked before any is written, so a bad one (a blank or taken name, a full TIN, over 500 characters, not a liability) changes nothing; each is journaled as party.updated or party.creditor_set. Archive a party you no longer deal with (its history stays; it then goes by id). Returns the party.", + annotations( + read_only_hint = false, + destructive_hint = false, + open_world_hint = false + ) + )] + async fn update_party( + &self, + params: Parameters, + ) -> Result, String> { + use ledger_core::PartyField as F; + let p = params.0; + let mut store = self.lock()?; + let id = resolve_party(&store, &p.party)?; + let mut fields = Vec::new(); + if let Some(n) = p.name { + fields.push(F::Name(n)); + } + if let Some(v) = p.display_name { + fields.push(F::DisplayName(optional_text(&v))); + } + if let Some(v) = p.website { + fields.push(F::Website(optional_text(&v))); + } + if let Some(v) = p.address { + fields.push(F::Address(optional_text(&v))); + } + if let Some(v) = p.notes { + fields.push(F::Notes(optional_text(&v))); + } + if let Some(a) = p.archived { + fields.push(F::Archived(a)); + } + // creditor_of replaces the list: each account added or dropped is + // one change. + let mut creditor: Vec<(AccountId, bool)> = Vec::new(); + if let Some(codes) = &p.creditor_of { + let want: std::collections::BTreeSet = codes + .iter() + .map(|c| resolve_account(&store, c.trim())) + .collect::>()?; + let has = &store.ledger().get_party(id).expect("resolved").creditor_of; + creditor.extend(want.difference(has).map(|a| (*a, true))); + creditor.extend(has.difference(&want).map(|a| (*a, false))); + } + if fields.is_empty() && p.creditor_of.is_none() { + return Err( + "give at least one field to change: name, display_name, website, address, notes, archived, creditor_of" + .into(), + ); + } + // Every change, in order, before any is written (INVARIANT 2). + let mut rehearsal = store.ledger().clone(); + for f in &fields { + rehearsal + .update_party(id, f.clone()) + .map_err(|e| e.to_string())?; + } + for (a, on) in &creditor { + rehearsal + .set_party_creditor(id, *a, *on) + .map_err(|e| e.to_string())?; + } + // The creditor lines' journal feature, asked for before this + // command's first line, never between its lines (rules.md B-27: + // books waiting for their format upgrade must not be left with the + // name changed and the creditors not). + if !creditor.is_empty() { + store + .ensure_feature(ledger_store::format::PARTY_CREDITORS) + .map_err(|e| e.to_string())?; + } + for f in fields { + store.update_party(id, f).map_err(|e| e.to_string())?; + } + for (a, on) in creditor { + store + .set_party_creditor(id, a, on) + .map_err(|e| e.to_string())?; + } + let party = store.ledger().get_party(id).expect("resolved"); + Ok(Json(party_out(store.ledger(), party))) + } + + #[tool( + name = "books.set_party_roles", + title = "Set a party's roles", + description = "Give a party roles (add) or take them away (remove): contractor, employee, member, payout_platform, tax_agency, government_agency, financial_institution, landlord. Vendor and customer follow their records and can't be set by hand. Adds apply before removes; a role it already has (or lacks) is left alone. Removing a party's last 1099 role (contractor, landlord) needs its W-9 facts cleared first (books.set_w9 clear), and removing employee its W-4 facts. All or nothing: a refusal changes nothing. Each change is journaled as party.role_set. Returns the party.", + annotations( + read_only_hint = false, + destructive_hint = false, + idempotent_hint = true, + open_world_hint = false + ) + )] + async fn set_party_roles( + &self, + params: Parameters, + ) -> Result, String> { + let p = params.0; + let add = parse_roles(&p.add)?; + let remove = parse_roles(&p.remove)?; + if add.is_empty() && remove.is_empty() { + return Err("give at least one role to add or remove".into()); + } + if let Some(r) = add.iter().find(|r| remove.contains(r)) { + return Err(format!("role {} is both added and removed", r.key())); + } + if add + .iter() + .chain(&remove) + .any(|r| matches!(r, ledger_core::Role::Vendor | ledger_core::Role::Customer)) + { + return Err( + "vendor and customer roles follow their records: books.add_vendor or books.add_customer" + .into(), + ); + } + let mut store = self.lock()?; + let id = resolve_party(&store, &p.party)?; + let has = store + .ledger() + .get_party(id) + .expect("resolved") + .roles + .clone(); + let mut changes: Vec<(ledger_core::Role, bool)> = Vec::new(); + for r in add { + if !has.contains(&r) && !changes.contains(&(r, true)) { + changes.push((r, true)); + } + } + for r in remove { + if has.contains(&r) && !changes.contains(&(r, false)) { + changes.push((r, false)); + } + } + // The whole change, in order, before any is written (INVARIANT 2). + let mut rehearsal = store.ledger().clone(); + for (r, on) in &changes { + rehearsal + .set_party_role(id, *r, *on) + .map_err(|e| e.to_string())?; + } + for (r, on) in changes { + store.set_party_role(id, r, on).map_err(|e| e.to_string())?; + } + let party = store.ledger().get_party(id).expect("resolved"); + Ok(Json(party_out(store.ledger(), party))) + } + + /// ```text + /// GOAL: the books know a W-9 arrived and what it said, and the full + /// TIN (and the form, which shows it) is kept where it can be + /// handed to the CPA and purged, never in the journal. + /// SOURCE: docs/parties-and-attachments.md "The TIN vault"; B-26; + /// Treas. Reg. §301.6109-4 (truncated TINs); DECISION (owner, + /// 2026-10-15): full TINs only in the encrypted vault. + /// INVARIANTS: + /// 1. A full TIN goes only to the vault (`vault/` in the book's own + /// folder, its key in `keys/` beside it, created on first use); + /// the facts journaled (`party.w9_set`) carry its last four and + /// the vault record's hash. The output never holds more than the + /// last four. + /// 2. The W-9 file goes only to the vault, never the attachment store. + /// 3. The facts are checked (on a copy of the ledger) before the vault + /// is written; a refusal journals nothing and writes no record. + /// 4. The facts never disagree with the vault: a TIN in the vault has + /// the facts' last four. + /// SCENARIOS: + /// 1. IF tin is given: SHOULD validate it (Tin::new, with tin_type), + /// take tin_last4 from it (refusing a given one that differs), and + /// default kept_at to "wanderbooks vault". + /// 2. IF no tin is given but the vault holds one whose last four + /// differ: SHOULD refuse (pass the new TIN, or books.purge_tin). + /// 3. IF clearing while the vault holds a record: SHOULD refuse + /// (books.purge_tin first). + /// ``` + #[tool( + name = "books.set_w9", + title = "Record W-9 facts", + description = "Record what a contractor's or landlord's W-9 says, or clear it (clear: true alone): the date received (received_on), line 3 entity type, line 4 exempt payee and FATCA codes, the TIN's last four digits, and where the form is kept. Give the full TIN as tin with tin_type (ssn, ein, itin) and it goes only to the encrypted TIN vault (its key kept apart from it, created on first use): the books keep its last four (tin_last4 is then optional, and refused if it disagrees) and a hash of the vault record, in the W-9 facts and the party's TIN facts alike (party.tin_set, as books.set_tin), and kept_at defaults to \"wanderbooks vault\". The CPA package carries the full TIN to the CPA. Give the signed form (a PDF, PNG, or JPEG) as w9_upload_id (books.begin_upload purpose w9, then `wanderbooks upload`: a file never passes through you), and it goes to the vault too, never to the receipts and documents; read it back with books.get_w9. Never put a TIN in any other field, note, description, or file: the engine refuses any nine-digit number, and no tool output echoes one. The party needs a 1099 role (contractor or landlord: books.set_party_roles). Record it before paying them. A party with a vendor record also gets its W-9 marked on file there (books.vendor_1099 missing_w9). Clearing W-9 facts whose TIN is in the vault needs books.purge_tin first. Journaled as party.w9_set. Returns the party (the TIN's last four only).", + annotations( + read_only_hint = false, + destructive_hint = true, + open_world_hint = false + ) + )] + async fn set_w9(&self, params: Parameters) -> Result, String> { + let p = params.0; + let w9_given = p.w9_upload_id.is_some(); + if p.w9_file_name.is_some() && !w9_given { + return Err("w9_file_name names the W-9 given with it (w9_upload_id)".into()); + } + let vault_input = p.tin.is_some() || p.tin_type.is_some() || w9_given; + let tin = match (&p.tin, &p.tin_type) { + (Some(t), Some(k)) => Some( + ledger_vault::Tin::new(t.0.trim(), parse_tin_type(k)?) + .map_err(|e| e.to_string())?, + ), + (Some(_), None) => return Err("tin needs tin_type: ssn, ein, or itin".into()), + (None, Some(_)) => return Err("tin_type goes with tin".into()), + (None, None) => None, + }; + let facts = if p.clear { + if p.received_on.is_some() + || p.entity_type.is_some() + || p.exempt_payee_code.is_some() + || p.fatca_code.is_some() + || p.tin_last4.is_some() + || p.kept_at.is_some() + || vault_input + { + return Err("clear: true takes no other W-9 fact".into()); + } + None + } else { + let need = |v: &Option, name: &str| { + v.as_deref() + .map(|s| s.trim().to_string()) + .ok_or_else(|| format!("{name} is required (or pass clear: true)")) + }; + let tin_last4 = match (&tin, p.tin_last4.as_deref().map(str::trim)) { + (Some(t), Some(given)) if given != t.last4() => { + return Err( + "tin_last4 doesn't match the TIN's last four digits (leave it out: it comes from tin)" + .into(), + ); + } + (Some(t), _) => t.last4().to_string(), + (None, _) => need(&p.tin_last4, "tin_last4")?, + }; + let kept_at = match &p.kept_at { + Some(k) => k.trim().to_string(), + None if vault_input => "wanderbooks vault".to_string(), + None => need(&p.kept_at, "kept_at")?, + }; + Some(ledger_core::W9Facts { + received: parse_date(&need(&p.received_on, "received_on")?)?, + entity_type: need(&p.entity_type, "entity_type")?, + exempt_payee_code: p.exempt_payee_code.as_deref().and_then(optional_text), + fatca_code: p.fatca_code.as_deref().and_then(optional_text), + tin_last4, + kept_at, + vault_record: None, + }) + }; + let (w9_file, mut w9_claim) = if let Some(upload_id) = &p.w9_upload_id { + let (file, claim) = self.w9_file(&p, upload_id)?; + (Some(file), claim) + } else { + (None, None) + }; + let mut store = self.lock()?; + let id = resolve_party(&store, &p.party)?; + let existing = store.ledger().get_party(id).and_then(|p| p.w9.clone()); + let party_tin = store.ledger().get_party(id).and_then(|p| p.tin.clone()); + // A new TIN with another last four replaces the party's TIN facts: + // cleared first, so the two never disagree on the record (B-26). + let replace_tin = tin + .as_ref() + .is_some_and(|t| party_tin.as_ref().is_some_and(|pt| pt.last4 != t.last4())); + let mut tin_facts: Option = None; + let vault_dir = self.vault_dir(&store); + let (held_tin, held_w9) = + ledger_vault::records_held(&vault_dir, id).map_err(|e| e.to_string())?; + let facts = match facts { + None => { + if held_tin + || held_w9 + || existing.as_ref().is_some_and(|f| f.vault_record.is_some()) + { + return Err(format!( + "the TIN vault holds party {id}'s TIN or W-9: purge it first (books.purge_tin, with the reason), then clear" + )); + } + None + } + Some(mut f) => { + // The facts must agree with a TIN the vault keeps + // (INVARIANT 4). + if tin.is_none() && held_tin { + let held = self + .open_vault(&store, false)? + .tin(id) + .map_err(|e| e.to_string())?; + if let Some(t) = held + && t.last4() != f.tin_last4 + { + return Err(format!( + "the TIN vault holds a TIN ending in {} for party {id}: pass the new tin (with tin_type), or purge the old one first (books.purge_tin)", + t.last4() + )); + } + } + // Rehearse with a stand-in hash before writing the vault + // (INVARIANT 3). + let mut rehearsal = store.ledger().clone(); + f.vault_record = Some("0".repeat(64)); + let stand_in = tin.as_ref().map(|t| ledger_core::TinFacts { + tin_type: tin_kind(t.kind), + last4: t.last4().to_string(), + vault_record: Some("0".repeat(64)), + purged: None, + }); + if replace_tin { + rehearsal + .set_party_tin(id, None) + .map_err(|e| e.to_string())?; + } + rehearsal + .set_party_w9(id, Some(f.clone())) + .map_err(|e| e.to_string())?; + if stand_in.is_some() { + rehearsal + .set_party_tin(id, stand_in.clone()) + .map_err(|e| e.to_string())?; + } + f.vault_record = if tin.is_some() || w9_file.is_some() { + let vault = self.open_vault(&store, true)?; + // The W-9 read is recorded before the vault keeps it: + // never filed into the books (tin_files.rs INVARIANT + // 1). Only now: every check has passed. + if let Some(w) = &w9_file { + self.wrote_tins(&store, &w.bytes)?; + } + let tin_hash = match &tin { + Some(t) => Some(vault.put_tin(id, t).map_err(|e| e.to_string())?), + None => None, + }; + let w9_hash = match &w9_file { + Some(w) => Some(vault.put_w9(id, w).map_err(|e| e.to_string())?), + None => None, + }; + // The W-9 is in the vault: its upload is used up. + files::filed(w9_claim.take()); + match tin_hash { + Some(h) => Some(h), + None if held_tin => vault.record_hash(id).map_err(|e| e.to_string())?, + None => w9_hash, + } + } else if held_tin { + ledger_vault::Vault::open(&vault_dir, &self.key_file(&store), false) + .ok() + .and_then(|v| v.record_hash(id).ok().flatten()) + .or_else(|| existing.as_ref().and_then(|e| e.vault_record.clone())) + } else if held_w9 { + existing.as_ref().and_then(|e| e.vault_record.clone()) + } else { + None + }; + // The TIN's facts carry the same record as the W-9's. + tin_facts = stand_in.map(|t| ledger_core::TinFacts { + vault_record: f.vault_record.clone(), + ..t + }); + Some(f) + } + }; + let on_file = facts.is_some(); + // The vendor record's W-9 flag follows (below): its feature is + // asked for before this command's first line, never after it + // (B-27: books waiting for their format upgrade must not be left + // with the W-9 recorded and the flag not). + let vendor = store.ledger().get_party(id).and_then(|p| p.vendor); + if vendor + .and_then(|v| store.ledger().get_vendor(v)) + .is_some_and(|v| v.tin_on_file != on_file) + { + store + .ensure_feature(ledger_store::format::VENDOR_1099_DECISIONS) + .map_err(|e| e.to_string())?; + } + if replace_tin { + store.set_party_tin(id, None).map_err(|e| e.to_string())?; + } + store.set_party_w9(id, facts).map_err(|e| e.to_string())?; + if tin_facts.is_some() { + store + .set_party_tin(id, tin_facts) + .map_err(|e| e.to_string())?; + } + // Keep the vendor record's W-9 flag (the 1099 worksheet's) in + // step, its 1099 decision and who made it kept (P3 review R8). + if let Some(v) = vendor { + store + .set_vendor_w9_flag(v, on_file) + .map_err(|e| e.to_string())?; + } + let party = store.ledger().get_party(id).expect("resolved"); + Ok(Json(party_out(store.ledger(), party))) + } + + /// ```text + /// GOAL: any party's full TIN (a member's SSN for their K-1, a payee's + /// from their W-9) kept where the CPA package can carry it, and + /// nowhere else. + /// SOURCE: docs/parties-and-attachments.md "The TIN vault"; B-26; + /// DECISION (owner, 2026-10-15): "In the vault, that's the + /// WHOLE reason we built it. And it needs to be in the damn CPA + /// package." + /// INVARIANTS: + /// 1. The full TIN goes only to the vault (set_w9's path: key outside + /// the books, created on first use; books in git must ignore + /// vault/); `party.tin_set` journals its type, last four, and the + /// record's hash. The output never holds more than the last four. + /// 2. Any role. + /// 3. The facts are checked (on a copy of the ledger) before the vault + /// is written; a refusal journals nothing and writes no record. + /// 4. W-9 facts on file must have the same last four (else refused: + /// a payee's new TIN comes with its W-9, books.set_w9); when they + /// do, their vault record follows the new record (`party.w9_set`). + /// ``` + #[tool( + name = "books.set_tin", + title = "Store a TIN in the vault", + description = "Put a party's full taxpayer identification number in the encrypted TIN vault: tin (nine digits, hyphens allowed) and tin_type (ssn, ein, itin). Any role: collect every member's (their K-1 needs it) and every 1099 payee's (books.set_w9 tin records a W-9's TIN the same way). The books keep only its type, last four, and a hash of the vault record (journaled as party.tin_set); books.export_cpa_package carries the full number to the CPA. A party whose W-9 facts name another last four is refused: a payee's new TIN comes with its new W-9 (books.set_w9). Never repeat a TIN in chat, descriptions, notes, version-history messages, or file names. Returns the party (the last four only).", + annotations( + read_only_hint = false, + destructive_hint = true, + open_world_hint = false + ) + )] + async fn set_tin(&self, params: Parameters) -> Result, String> { + let p = params.0; + let tin = ledger_vault::Tin::new(p.tin.0.trim(), parse_tin_type(&p.tin_type)?) + .map_err(|e| e.to_string())?; + let mut store = self.lock()?; + let id = resolve_party(&store, &p.party)?; + let w9 = store.ledger().get_party(id).and_then(|p| p.w9.clone()); + let stand_in = ledger_core::TinFacts { + tin_type: tin_kind(tin.kind), + last4: tin.last4().to_string(), + vault_record: Some("0".repeat(64)), + purged: None, + }; + // Rehearse on a copy before the vault is written (INVARIANT 3). + let mut rehearsal = store.ledger().clone(); + rehearsal + .set_party_tin(id, Some(stand_in.clone())) + .map_err(|e| e.to_string())?; + if let Some(f) = &w9 { + rehearsal + .set_party_w9( + id, + Some(ledger_core::W9Facts { + vault_record: Some("0".repeat(64)), + ..f.clone() + }), + ) + .map_err(|e| e.to_string())?; + } + let vault = self.open_vault(&store, true)?; + let hash = vault.put_tin(id, &tin).map_err(|e| e.to_string())?; + store + .set_party_tin( + id, + Some(ledger_core::TinFacts { + vault_record: Some(hash.clone()), + ..stand_in + }), + ) + .map_err(|e| e.to_string())?; + if let Some(f) = w9 + && f.vault_record.as_deref() != Some(hash.as_str()) + { + store + .set_party_w9( + id, + Some(ledger_core::W9Facts { + vault_record: Some(hash), + ..f + }), + ) + .map_err(|e| e.to_string())?; + } + let party = store.ledger().get_party(id).expect("resolved"); + Ok(Json(party_out(store.ledger(), party))) + } + + /// ```text + /// GOAL: hand the human (for the CPA) the signed W-9 the vault keeps, + /// without it passing through the books or the conversation. + /// SOURCE: docs/parties-and-attachments.md "The TIN vault"; D-29. + /// INVARIANTS: + /// 1. The file is handed over (tools/files.rs INVARIANT 6: an export + /// for the caller's token), never written to a path; a name kept + /// before names were checked is made plain, never refused. It is + /// recorded (tin_files.rs) first, so it is never filed back into + /// the books. + /// 2. Nothing is journaled. + /// SCENARIOS: + /// 1. IF the vault is locked or holds no W-9 file for the party: + /// SHOULD refuse, saying which. + /// ``` + #[tool( + name = "books.get_w9", + title = "Write out a W-9", + description = "A party's signed W-9 file from the TIN vault, for the CPA or the human: run the download command it returns to save it in a folder. Never file it into the books (it shows the full TIN). Nothing is journaled.", + annotations( + read_only_hint = false, + destructive_hint = false, + open_world_hint = false + ) + )] + async fn get_w9(&self, params: Parameters) -> Result, String> { + let p = params.0; + let store = self.lock()?; + let id = resolve_party(&store, &p.party)?; + let file = self + .open_vault(&store, false)? + .w9(id) + .map_err(|e| e.to_string())? + .ok_or_else(|| { + format!( + "the TIN vault holds no W-9 file for party {id}: store one with books.set_w9 w9_upload_id" + ) + })?; + self.wrote_tins(&store, &file.bytes)?; + let handed = self.hand_over( + &book_name(&store), + &file.file_name, + &file.mime_type, + file.bytes, + )?; + Ok(Json(GetW9Out { + party_id: id, + file: handed, + })) + } + + /// ```text + /// GOAL: a TIN kept no longer than needed can be destroyed, on the + /// record. + /// SOURCE: docs/parties-and-attachments.md "The TIN vault" ("Purge"); + /// IRS Pub. 583 (keep records while needed; then dispose). + /// INVARIANTS: + /// 1. Without confirmed=true: a preview, changing nothing. + /// 2. Confirmed: the party's vault records (TIN and W-9 file) are + /// deleted, needing no key; when the party has TIN facts, + /// `party.tin_set` journals them with the vault record cleared + /// and `purged` saying when and why, and when it has W-9 facts, + /// `party.w9_set` likewise in kept_at (the last four stay). + /// 3. The new facts are checked before anything is deleted; a refusal + /// changes nothing. + /// SCENARIOS: + /// 1. IF the reason is blank: SHOULD refuse. + /// 2. IF the vault holds nothing for the party and its facts name no + /// vault record: SHOULD refuse (nothing to purge). + /// ``` + #[tool( + name = "books.purge_tin", + title = "Purge a TIN from the vault", + description = "Delete a party's full TIN and W-9 file from the TIN vault (after the retention period, at the payee's request, or to replace a wrong one), with the reason. Without confirmed=true it previews what the vault holds and changes nothing. Confirmed, the records are deleted from the vault (backups made before keep their copies) and the books journal it: the party's TIN facts (party.tin_set: purged says when and why) and W-9 facts (party.w9_set: kept_at says so) keep the last four and drop the vault record. Works without the vault key. The next CPA package then lacks that TIN (and says so).", + annotations( + read_only_hint = false, + destructive_hint = true, + open_world_hint = false + ) + )] + async fn purge_tin( + &self, + params: Parameters, + ) -> Result, String> { + let p = params.0; + let reason = p.reason.trim(); + if reason.is_empty() { + return Err("give the reason for purging".into()); + } + let mut store = self.lock()?; + let id = resolve_party(&store, &p.party)?; + let party = store.ledger().get_party(id).expect("resolved").clone(); + let vault_dir = self.vault_dir(&store); + let (held_tin, held_w9) = + ledger_vault::records_held(&vault_dir, id).map_err(|e| e.to_string())?; + let recorded = party.w9.as_ref().is_some_and(|f| f.vault_record.is_some()) + || party.tin.as_ref().is_some_and(|t| t.vault_record.is_some()); + if !held_tin && !held_w9 && !recorded { + return Err(format!( + "the TIN vault holds nothing for party {id} ({}) and its W-9 and TIN facts name no vault record", + party.name + )); + } + let why = format!("purged from the wanderbooks vault on {}: {reason}", today()); + let new_facts = party.w9.clone().map(|f| ledger_core::W9Facts { + vault_record: None, + kept_at: format!("not kept: {why}"), + ..f + }); + let new_tin = party.tin.clone().map(|t| ledger_core::TinFacts { + vault_record: None, + purged: Some(why.clone()), + ..t + }); + // Both checked before anything is deleted (INVARIANT 3). + let mut rehearsal = store.ledger().clone(); + if let Some(f) = &new_facts { + rehearsal + .set_party_w9(id, Some(f.clone())) + .map_err(|e| e.to_string())?; + } + if let Some(t) = &new_tin { + rehearsal + .set_party_tin(id, Some(t.clone())) + .map_err(|e| e.to_string())?; + } + let mut out = PurgeTinOut { + status: "preview".into(), + party_id: id, + name: party.name.clone(), + tin_in_vault: held_tin, + w9_file_in_vault: held_w9, + tin_last4: party.tin_last4().map(str::to_string), + journaled: false, + note: format!( + "Confirming deletes {}{}; backups made before keep their copies. Make sure the CPA has what they need first.", + match (held_tin, held_w9) { + (true, true) => "the TIN and the W-9 file", + (true, false) => "the TIN", + (false, true) => "the W-9 file", + (false, false) => "nothing in the vault (the books' record of it is cleared)", + }, + match (new_facts.is_some(), new_tin.is_some()) { + (true, true) => + " and journals the purge in the TIN and W-9 facts (party.tin_set, party.w9_set; the last four stay)", + (false, true) => + " and journals the purge in the TIN facts (party.tin_set; the last four stay)", + (true, false) => + " and journals the purge in the W-9 facts (party.w9_set; the last four stay)", + (false, false) => "", + } + ), + }; + if !p.confirmed.unwrap_or(false) { + return Ok(Json(out)); + } + ledger_vault::purge_records(&vault_dir, id).map_err(|e| e.to_string())?; + if let Some(f) = new_facts { + store.set_party_w9(id, Some(f)).map_err(|e| e.to_string())?; + out.journaled = true; + } + if let Some(t) = new_tin { + store + .set_party_tin(id, Some(t)) + .map_err(|e| e.to_string())?; + out.journaled = true; + } + out.status = "purged".into(); + out.note = format!( + "Purged from the vault{}.", + if out.journaled { + "; the books record when and why" + } else { + "" + } + ); + Ok(Json(out)) + } + + #[tool( + name = "books.set_w4", + title = "Record W-4 facts", + description = "Record what an employee's W-4 says, or clear it (clear: true alone): the date received (received_on), the work state (two letters), and where the form is kept. Never the SSN. The party needs the employee role. Record it before their first payroll. Journaled as party.w4_set. Returns the party.", + annotations( + read_only_hint = false, + destructive_hint = false, + open_world_hint = false + ) + )] + async fn set_w4(&self, params: Parameters) -> Result, String> { + let p = params.0; + let facts = if p.clear { + if p.received_on.is_some() || p.state.is_some() || p.kept_at.is_some() { + return Err("clear: true takes no other W-4 fact".into()); + } + None + } else { + let need = |v: &Option, name: &str| { + v.as_deref() + .map(|s| s.trim().to_string()) + .ok_or_else(|| format!("{name} is required (or pass clear: true)")) + }; + Some(ledger_core::W4Facts { + received: parse_date(&need(&p.received_on, "received_on")?)?, + state: need(&p.state, "state")?, + kept_at: need(&p.kept_at, "kept_at")?, + }) + }; + let mut store = self.lock()?; + let id = resolve_party(&store, &p.party)?; + store.set_party_w4(id, facts).map_err(|e| e.to_string())?; + let party = store.ledger().get_party(id).expect("resolved"); + Ok(Json(party_out(store.ledger(), party))) + } + + #[tool( + name = "books.parties", + title = "List parties", + description = described( + "The party directory, by id: each party's name, label, roles (vendor and customer included), vendor and customer record ids, whether W-9 and W-4 facts are on file, the TIN's last four when the books know it (TIN or W-9 facts), archived, and what was paid to and received from it in the year on the basis asked: cash by default (money), or all (adds property contributed or distributed in kind); defined as in books.party, the basis echoed. Filter by role; archived parties only with include_archived.", + ledger_core::calendar::PeriodKind::CalendarYear + ), + annotations(read_only_hint = true, open_world_hint = false) + )] + async fn parties( + &self, + params: Parameters, + ) -> Result, String> { + let p = params.0; + let role = p + .role + .as_deref() + .map(|r| parse_roles(&[r.to_string()]).map(|v| v[0])) + .transpose()?; + let year = p.year.unwrap_or_else(|| self.periods().calendar_year()); + let basis = parse_basis(p.basis.as_deref())?; + let settings = self.settings(); + let store = self.lock()?; + let mut parties = Vec::new(); + for party in store.ledger().parties() { + if party.archived && !p.include_archived { + continue; + } + if role.is_some_and(|r| !party.all_roles().contains(&r)) { + continue; + } + let act = party_activity(&store, party, &settings, year, basis)?; + parties.push(PartySummaryOut { + party_id: party.id, + name: party.name.clone(), + label: party.label().to_string(), + roles: party_roles(party), + vendor_id: party.vendor, + customer_id: party.customer, + w9_on_file: party.w9.is_some(), + tin_last4: party.tin_last4().map(str::to_string), + w4_on_file: party.w4.is_some(), + archived: party.archived, + paid: money(act.paid), + received: money(act.received), + }); + } + Ok(Json(ListPartiesOut { + year, + basis: basis.key().to_string(), + parties, + })) + } + + #[tool( + name = "books.party", + title = "Get a party", + description = described( + "One party, by id or name: its facts and roles, vendor and customer record ids, the year's totals (paid to it and received from it on the basis asked, cash by default or all with property in kind, the basis echoed; invoiced, billed, the 1099 amount for a vendor, payroll gross and net), what it is owed and owes now (open bills and invoices), every entry it is on (tagged with it or its vendor record, its invoices and their payments, and their reversals), its invoices and bills, its projects, and the payroll lines naming it. Totals come from the journal: the output's schema says exactly how.", + ledger_core::calendar::PeriodKind::CalendarYear + ), + annotations(read_only_hint = true, open_world_hint = false) + )] + async fn party(&self, params: Parameters) -> Result, String> { + let p = params.0; + let year = p.year.unwrap_or_else(|| self.periods().calendar_year()); + let basis = parse_basis(p.basis.as_deref())?; + let settings = self.settings(); + let store = self.lock()?; + let ledger = store.ledger(); + let id = resolve_party(&store, &p.party)?; + let party = ledger.get_party(id).expect("resolved"); + let act = party_activity(&store, party, &settings, year, basis)?; + let in_year = |d: NaiveDate| chrono::Datelike::year(&d) == year; + let mut entries: Vec = act + .entries + .iter() + .filter_map(|&e| ledger.get_entry(e)) + .map(|e| PartyEntryOut { + entry_id: e.id(), + date: e.date().to_string(), + description: e.description().to_string(), + status: match e.status() { + ledger_core::EntryStatus::Draft => "draft", + ledger_core::EntryStatus::Posted => "posted", + } + .to_string(), + total: ReceiptPolicy::entry_debits(e).ok().map(money), + reversal_of: e.reversal_of(), + }) + .collect(); + entries.sort_by(|a, b| b.date.cmp(&a.date).then(b.entry_id.cmp(&a.entry_id))); + let invoices = party_invoices(ledger, party); + let bills = party_bills(ledger, party); + // The worksheet's figure; none when it can't be made (no cash + // accounts named), never a failed answer. + let amount_1099 = party.vendor.and_then(|v| { + let r = vendor_1099_report(ledger, &self.settings(), year, Some(Decimal::ZERO)).ok()?; + Some(money( + r.vendors + .iter() + .find(|l| l.vendor_id == v) + .map(|l| l.paid) + .unwrap_or_default(), + )) + }); + let decided_now = party + .vendor + .and_then(|v| ledger.get_vendor(v)) + .map(|v| v.decision_on(today()).cloned().unwrap_or_default()); + // Documents and runs are restored as journaled (rules.md B-28): a + // total past exact arithmetic refuses the answer, never a panic. + let mut payroll = Vec::new(); + let (mut gross, mut net) = (Decimal::ZERO, Decimal::ZERO); + for run in ledger.list_payroll_runs() { + for line in run.lines.iter().filter(|l| l.employee == Some(id)) { + let line_net = line.net_pay().map_err(|e| e.to_string())?; + if in_year(run.date) { + gross = checked_sum([Ok(gross), Ok(line.gross_pay)], "gross pay")?; + net = checked_sum([Ok(net), Ok(line_net)], "net pay")?; + } + payroll.push(PartyPayrollOut { + run_id: run.id, + date: run.date.to_string(), + entry_id: run.entry_id, + gross_pay: money(line.gross_pay), + net_pay: money(line_net), + }); + } + } + let totals = PartyTotalsOut { + basis: basis.key().to_string(), + paid: money(act.paid), + received: money(act.received), + invoiced: money(checked_sum( + invoices + .iter() + .filter(|i| in_year(i.date)) + .map(|i| i.total()), + "the year's invoices", + )?), + billed: money(checked_sum( + bills.iter().filter(|b| in_year(b.date)).map(|b| b.total()), + "the year's bills", + )?), + owed: money(checked_sum( + bills.iter().map(|b| b.balance()), + "what is owed", + )?), + receivable: money(checked_sum( + invoices.iter().map(|i| i.balance()), + "what is receivable", + )?), + amount_1099, + form_1099: form_1099_label(decided_now.as_ref().and_then(|d| d.form())), + decision_1099: decided_now.as_ref().map(|d| d.key().to_string()), + gross_pay: money(gross), + net_pay: money(net), + }; + Ok(Json(PartyDetailOut { + party: party_out(ledger, party), + year, + totals, + entries, + invoices: invoices + .iter() + .map(|i| { + party_document( + i.id, + (i.date, i.due_date), + (i.total(), i.balance(), i.amount_paid), + i.status, + i.entry_id, + ) + }) + .collect::>()?, + bills: bills + .iter() + .map(|b| { + party_document( + b.id, + (b.date, b.due_date), + (b.total(), b.balance(), b.amount_paid), + b.status, + b.entry_id, + ) + }) + .collect::>()?, + payroll, + projects: ledger + .projects_of_party(id) + .into_iter() + .map(|c| projects::project_ref(ledger, c)) + .collect(), + })) + } + + #[tool( + name = "books.set_entry_party", + title = "Tag an entry's parties", + description = "Tag an entry (draft or posted) with the party it was with (id or name), or pass party null (or omit it) to clear the tag. A party with a vendor record also tags the entry's vendor, so a payment to a contractor or landlord counts in books.vendor_1099; a party without one, or clearing, clears the vendor tag. For an entry with several counterparties pass parties instead: [{party, amount?|percent?}], each share an exact amount (cents), a percent (summing to 100; leftover cents to the first party), or neither (derived: a sole party gets all; members named together get what their capital or distributions lines say, else their [[company.members]] share; once the entry is posted, a derived split among several parties is recorded as the exact amounts it resolves to, so a later change to [[company.members]] never re-splits posted history). Name both members on a joint member transaction (a reimbursement on 2100, a joint contribution) and let the shares derive; give amounts when a split is uneven. Refused: a party twice, mixed kinds of share, two parties with vendor records, and, once the entry has balanced lines, shares that don't add up to what it moved. Metadata only: amounts never change. An entry and its reversal carry the same parties: tagging either tags both, so each party's part nets to zero. Journaled as entry.party_set (one party) or entry.parties_set (parties). Returns the entry (as books.get_entry), each party's share resolved, with its direction and what of it was money or property in kind.", + annotations( + read_only_hint = false, + destructive_hint = false, + open_world_hint = false + ) + )] + async fn set_entry_party( + &self, + params: Parameters, + ) -> Result, String> { + let p = params.0; + { + let settings = self.settings(); + let mut store = self.lock()?; + let party = p + .party + .as_deref() + .map(|r| resolve_party(&store, r)) + .transpose()?; + let shares = p + .parties + .as_deref() + .map(|ps| parse_shares(&store, ps)) + .transpose()?; + let reason = p + .no_party_reason + .as_deref() + .map(str::trim) + .filter(|r| !r.is_empty()); + match (party, reason, shares) { + (Some(_), _, Some(_)) => { + return Err( + "pass party (one) or parties (several, with shares), not both".into(), + ); + } + (_, Some(_), Some(_)) => { + return Err("pass parties or a no_party_reason, not both".into()); + } + (Some(_), Some(_), None) => { + return Err("pass a party or a no_party_reason, not both".into()); + } + (None, Some(r), _) => { + if !store.ledger().entry_parties(p.entry_id).is_empty() { + // Check the mark before clearing, so a refusal + // journals nothing. + if r.trim().is_empty() { + return Err("say why the entry has no counterparty".into()); + } + ledger_core::parties::check_text("reason", r)?; + store + .set_entry_party(p.entry_id, None) + .map_err(|e| e.to_string())?; + } + store + .set_entry_no_party(p.entry_id, r) + .map_err(|e| e.to_string())?; + } + (None, None, Some(sh)) => { + store + .ledger() + .check_set_entry_parties(p.entry_id, &sh) + .map_err(|e| e.to_string())?; + check_shares_resolve(&store, &settings, p.entry_id, &sh)?; + store + .set_entry_parties(p.entry_id, &sh) + .map_err(|e| e.to_string())?; + fix_derived_shares(&mut store, &settings, p.entry_id)?; + } + (party, None, None) => { + store + .set_entry_party(p.entry_id, party) + .map_err(|e| e.to_string())?; + } + } + } + self.get_entry(Parameters(GetEntryParams { + entry_id: p.entry_id, + })) + .await + } +} diff --git a/crates/ledger-mcp/src/tools/payouts.rs b/crates/ledger-mcp/src/tools/payouts.rs new file mode 100644 index 0000000..b2aa34b --- /dev/null +++ b/crates/ledger-mcp/src/tools/payouts.rs @@ -0,0 +1,99 @@ +//! Payouts through the server (`books.record_payout`). Split whole out +//! of `server.rs` (plan/served.md 8.2 U0a). + +use super::*; + +#[tool_router(router = payouts_router, vis = "pub(super)")] +impl BooksServer { + #[tool( + name = "books.record_payout", + title = "Record a platform payout", + description = "Record a platform payout (app store, payment processor, marketplace) grossed up: Dr the deposit account the net deposit, Dr fee accounts the fees withheld, Dr/Cr adjustments (refunds, chargebacks, reserves, taxes withheld), Cr revenue the gross sales (several lines and classes allowed). Refuses unless gross - fees +/- adjustments equals the deposit to the cent, and says by how much it's off. Creates a draft: attach the payout statement as its receipt, preview, and post.", + annotations( + read_only_hint = false, + destructive_hint = false, + open_world_hint = false + ) + )] + async fn record_payout( + &self, + params: Parameters, + ) -> Result, String> { + let p = params.0; + let mut store = self.lock()?; + let class = |store: &Store, c: &Option| -> Result, String> { + c.as_deref().map(|c| resolve_class(store, c)).transpose() + }; + let lines = |store: &Store, ls: &[PayoutLineParam]| { + ls.iter() + .map(|l| { + Ok(ledger_core::PayoutLine { + account: resolve_account(store, &l.account)?, + amount: parse_amount(&l.amount)?, + class: class(store, &l.class)?, + }) + }) + .collect::, String>>() + }; + let payout = ledger_core::Payout { + deposit_account: resolve_account(&store, &p.deposit_account)?, + deposit: parse_amount(&p.deposit)?, + revenue: lines(&store, &p.revenue)?, + fees: lines(&store, &p.fees)?, + adjustments: p + .adjustments + .iter() + .map(|a| { + Ok(ledger_core::PayoutAdjustment { + account: resolve_account(&store, &a.account)?, + side: parse_side(&a.side)?, + amount: parse_amount(&a.amount)?, + class: class(&store, &a.class)?, + }) + }) + .collect::, String>>()?, + }; + let (plan, totals) = + ledger_core::payout_plan(&payout, store.ledger().chart()).map_err(|e| e.to_string())?; + let currency = store.base_currency(); + let entry_id = store + .create_entry(parse_date(&p.date)?, &p.description) + .map_err(|e| e.to_string())?; + let mut described = Vec::new(); + let mut tagged: Vec = Vec::new(); + for (index, (account, side, amount, class)) in plan.into_iter().enumerate() { + store + .add_line(entry_id, account, side, Money::new(amount, currency)) + .map_err(|e| e.to_string())?; + if let Some(class) = class { + store + .set_line_class(entry_id, index, Some(class)) + .map_err(|e| e.to_string())?; + if !tagged.contains(&class) { + tagged.push(class); + } + } + let code = store + .ledger() + .chart() + .get(account) + .map(|a| a.code.clone()) + .unwrap_or_default(); + described.push(format!("{side:?} {code} {}", money(amount))); + } + Ok(Json(RecordPayoutOut { + entry_id, + status: "draft".to_string(), + gross: money(totals.gross), + fees: money(totals.fees), + adjustments: money(totals.adjustments), + deposit: money(totals.deposit), + lines: described, + notes: tagged + .into_iter() + .filter_map(|c| projects::late_tag_note(store.ledger(), c)) + .collect(), + next: "Attach the payout statement (books.attach_receipt), then preview and post with books.post_entry.".to_string(), + })) + } +} diff --git a/crates/ledger-mcp/src/tools/payroll.rs b/crates/ledger-mcp/src/tools/payroll.rs new file mode 100644 index 0000000..b8aa7ae --- /dev/null +++ b/crates/ledger-mcp/src/tools/payroll.rs @@ -0,0 +1,164 @@ +//! Payroll through the server: accounting only, the caller supplies every +//! number (no tax tables). Split whole out of `server.rs` +//! (plan/served.md 8.2 U0a). + +use super::*; + +#[tool_router(router = payroll_router, vis = "pub(super)")] +impl BooksServer { + #[tool( + name = "books.run_payroll", + title = "Run payroll", + description = "Run payroll for a pay date. Each line carries one employee's gross pay and every withholding/deduction as decimal strings — the engine cuts one balanced entry (Dr Salary & Wage Expense, Dr Payroll Tax Expense for the employer share, Cr the withholding payables, Cr cash for net pay). Auto-creates the 61xx/21xx accounts. All amounts are decimal strings, never floats. Name each line's employee party (employee: id or name, a party with the employee role; add one with books.add_party and record its W-4 with books.set_w4 first): new runs should, so the run shows on the employee's books.party.", + annotations( + read_only_hint = false, + destructive_hint = true, + open_world_hint = false + ) + )] + async fn run_payroll( + &self, + params: Parameters, + ) -> Result, String> { + let p = params.0; + let mut store = self.lock()?; + let cash = resolve_account(&store, &p.cash_account)?; + let lines: Vec = p + .lines + .into_iter() + .map(|l| { + let employee = l + .employee + .as_deref() + .map(|r| resolve_party(&store, r)) + .transpose()?; + let employee_name = match (l.employee_name, employee) { + (Some(n), _) => n, + (None, Some(id)) => store + .ledger() + .get_party(id) + .map(|p| p.name.clone()) + .unwrap_or_default(), + (None, None) => { + return Err( + "each payroll line names its employee: employee (a party) or employee_name" + .to_string(), + ); + } + }; + Ok(ledger_core::PayrollLine { + employee_name, + employee, + gross_pay: parse_amount(&l.gross_pay)?, + federal_withholding: parse_amount(&l.federal_withholding)?, + state_withholding: parse_amount(&l.state_withholding)?, + ss_employee: parse_amount(&l.ss_employee)?, + medicare_employee: parse_amount(&l.medicare_employee)?, + ss_employer: parse_amount(&l.ss_employer)?, + medicare_employer: parse_amount(&l.medicare_employer)?, + other_deductions: parse_amount(&l.other_deductions)?, + }) + }) + .collect::>()?; + if self.settings().parties == ledger_config::PartyPolicy::Require + && lines + .iter() + .any(|l| l.employee.is_none() && l.net_pay().is_ok_and(|n| !n.is_zero())) + { + return Err(party_required( + "name each payroll line's employee party (employee), so the entry names who was paid", + )); + } + let first_new = store.ledger().next_account_id(); + let run_id = store + .run_payroll(parse_date(&p.date)?, lines, cash) + .map_err(|e| e.to_string())?; + let run = store + .ledger() + .get_payroll_run(run_id) + .ok_or_else(|| format!("payroll run {run_id} not found after creation"))?; + let total_gross = checked_sum(run.lines.iter().map(|l| Ok(l.gross_pay)), "gross pay")?; + let total_net = checked_sum(run.lines.iter().map(|l| l.net_pay()), "net pay")?; + Ok(Json(RunPayrollOut { + run_id, + entry_id: run.entry_id, + total_gross: money(total_gross), + total_net: money(total_net), + warnings: created_uncategorized(store.ledger(), first_new), + })) + } + + #[tool( + name = "books.remit_payroll_taxes", + title = "Remit payroll taxes", + description = "Remit all outstanding payroll tax payables (federal/state withholding, SS, Medicare, other deductions) to cash in one balanced entry. Name the tax agency paid (agency: a party id or name, e.g. the IRS): it is the entry's party, and [books] parties = \"require\" refuses without it.", + annotations( + read_only_hint = false, + destructive_hint = true, + open_world_hint = false + ) + )] + async fn remit_payroll_taxes( + &self, + params: Parameters, + ) -> Result, String> { + let p = params.0; + let mut store = self.lock()?; + let cash = resolve_account(&store, &p.cash_account)?; + let agency = p + .agency + .as_deref() + .map(|r| resolve_party(&store, r)) + .transpose()?; + if agency.is_none() && self.settings().parties == ledger_config::PartyPolicy::Require { + return Err(party_required("pass agency: the tax agency paid")); + } + // Total before remitting, for the response. + let total: Decimal = store + .ledger() + .payroll_tax_payables() + .iter() + .map(|(_, b)| *b) + .sum(); + let entry_id = store + .remit_payroll_taxes(parse_date(&p.date)?, cash) + .map_err(|e| e.to_string())?; + if let Some(a) = agency { + store + .set_entry_party(entry_id, Some(a)) + .map_err(|e| e.to_string())?; + } + Ok(Json(RemitPayrollTaxesOut { + entry_id, + total: money(total), + })) + } + + #[tool( + name = "books.list_payroll_runs", + title = "List payroll runs", + description = "List payroll runs in id order: date, employee count, totals, and the posted entry id.", + annotations(read_only_hint = true, open_world_hint = false) + )] + async fn list_payroll_runs(&self) -> Result, String> { + let store = self.lock()?; + let runs = store + .list_payroll_runs() + .into_iter() + .map(|r| { + // A run is as journaled (rules.md B-28): a total past exact + // arithmetic refuses the list, never a panic. + let total_gross = + checked_sum(r.lines.iter().map(|l| Ok(l.gross_pay)), "gross pay")?; + Ok(PayrollRunOut { + id: r.id, + date: r.date.to_string(), + employee_count: r.lines.len(), + total_gross: money(total_gross), + entry_id: r.entry_id, + }) + }) + .collect::>()?; + Ok(Json(ListPayrollRunsOut { runs })) + } +} diff --git a/crates/ledger-mcp/src/tools/periods.rs b/crates/ledger-mcp/src/tools/periods.rs new file mode 100644 index 0000000..b9f5e44 --- /dev/null +++ b/crates/ledger-mcp/src/tools/periods.rs @@ -0,0 +1,159 @@ +//! Calendar periods through the server: `books.create_period`, +//! `books.close_period` and `books.list_periods`. Split whole out of +//! `server.rs` (plan/served.md 8.2 U0a). + +use super::*; + +#[tool_router(router = periods_router, vis = "pub(super)")] +impl BooksServer { + #[tool( + name = "books.create_period", + title = "Create a period", + description = "Create an accounting period (month, quarter, year). Dates are YYYY-MM-DD, inclusive.", + annotations( + read_only_hint = false, + destructive_hint = false, + open_world_hint = false + ) + )] + async fn create_period( + &self, + params: Parameters, + ) -> Result, String> { + let p = params.0; + let mut store = self.lock()?; + let id = store + .create_period(&p.name, parse_date(&p.start)?, parse_date(&p.end)?) + .map_err(|e| e.to_string())?; + Ok(Json(CreatePeriodOut { period_id: id })) + } + + #[tool( + name = "books.close_period", + title = "Close a period", + description = "Close an accounting period. The closing entry, dated at period end, zeroes revenue and expense balances into Retained Earnings or, for a multi-member LLC (members in the settings), into each member's capital account by share, cumulatively: each close gives a member their share of everything closed to the members so far, less what earlier closes gave them (odd cents by largest remainder), so no member is ever a cent off their exact share; a close may carry a one-time correction of earlier rounding. Periods close in order. Without confirmed=true, returns the exact closing entry and members' split, closing nothing; with confirmed=true, posts it and closes the period to new entries.", + annotations( + read_only_hint = false, + destructive_hint = true, + open_world_hint = false + ) + )] + async fn close_period( + &self, + params: Parameters, + ) -> Result, String> { + let p = params.0; + let mut store = self.lock()?; + // Verify the period exists. + if store.ledger().get_period(p.period_id).is_none() { + return Err(format!("unknown period {}", p.period_id)); + } + let to = self.close_target(store.ledger())?; + let describe = |ledger: &Ledger, eid: Option| -> Vec { + let Some(entry) = eid.and_then(|e| ledger.get_entry(e)) else { + return Vec::new(); + }; + entry + .lines() + .iter() + .map(|line| { + let code = ledger + .chart() + .get(line.account) + .map(|a| a.code.clone()) + .unwrap_or_default(); + format!("{:?} {} {}", line.side, code, money(line.amount.amount)) + }) + .collect() + }; + // The members' split, from the closed ledger (rules.md B-13). + let split = |ledger: &Ledger| -> Option { + let (since_close, parts) = ledger.member_close_split(p.period_id)?; + let names = self.member_capitals(ledger).unwrap_or_default(); + let members = parts + .iter() + .map(|m| MemberCloseOut { + name: names + .iter() + .find(|c| c.capital_account == m.capital) + .map(|c| c.name.clone()) + .unwrap_or_default(), + capital_account: ledger + .chart() + .get(m.capital) + .map(|a| a.code.clone()) + .unwrap_or_default(), + share: ledger_package::share_text(m.share), + this_close: money(m.part), + running: money(m.running), + exact: money(m.exact.normalize()), + }) + .collect(); + Some(MembersCloseOut { + since_close, + members, + }) + }; + if !p.confirmed { + // Preview: run the real close on a copy of the ledger, so the + // preview is exactly what confirming would post. + let mut scratch = store.ledger().clone(); + let closing = scratch + .close_period_to(p.period_id, &to) + .map_err(|e| e.to_string())?; + return Ok(Json(ClosePeriodOut { + status: "preview".to_string(), + period_id: p.period_id, + closing_entry: None, + lines: describe(&scratch, closing), + members: split(&scratch), + })); + } + let closing = store + .close_period_to(p.period_id, &to) + .map_err(|e| e.to_string())?; + let lines = describe(store.ledger(), closing); + Ok(Json(ClosePeriodOut { + status: "closed".to_string(), + period_id: p.period_id, + closing_entry: closing, + lines, + members: split(store.ledger()), + })) + } + + #[tool( + name = "books.list_periods", + title = "List periods", + description = "List all accounting periods with their status, and for a period closed to the members, the rounding rule it was closed under.", + annotations(read_only_hint = true, open_world_hint = false) + )] + async fn list_periods(&self) -> Result, String> { + let store = self.lock()?; + let periods = store + .ledger() + .list_periods() + .into_iter() + .map(|p| PeriodOut { + id: p.id, + name: p.name.clone(), + start: p.start.to_string(), + end: p.end.to_string(), + status: match p.status { + PeriodStatus::Open => "open".to_string(), + PeriodStatus::Closed => "closed".to_string(), + }, + closing_entry: p.closing_entry, + close_rule: p.members_closed.as_ref().map(|m| match m.rule { + ledger_core::MemberRule::Cumulative { since_seq } => { + format!("members_cumulative_since:{since_seq}") + } + ledger_core::MemberRule::Run => "members_run".to_string(), + ledger_core::MemberRule::PerClose => "members_per_close".to_string(), + ledger_core::MemberRule::LastAbsorbs => "members_last_absorbs".to_string(), + }), + }) + .collect(); + Ok(Json(ListPeriodsOut { periods })) + } +} diff --git a/crates/ledger-mcp/src/tools/projects.rs b/crates/ledger-mcp/src/tools/projects.rs index c4918b4..fdc7693 100644 --- a/crates/ledger-mcp/src/tools/projects.rs +++ b/crates/ledger-mcp/src/tools/projects.rs @@ -419,4 +419,43 @@ impl BooksServer { decisions, })) } + + #[tool( + name = "books.add_class", + title = "Add a class", + description = "Add a class: a project with only its name (books.add_project records its facts too; books.update_project adds them later). Names are unique, ignoring capitals. Tag journal lines with it via books.add_line's class param.", + annotations( + read_only_hint = false, + destructive_hint = false, + open_world_hint = false + ) + )] + async fn add_class( + &self, + params: Parameters, + ) -> Result, String> { + let p = params.0; + let mut store = self.lock()?; + let id = store.add_class(&p.name).map_err(|e| e.to_string())?; + Ok(Json(AddClassOut { id })) + } + + #[tool( + name = "books.list_classes", + title = "List classes", + description = "List classes (projects) in id order; books.projects gives their facts and figures.", + annotations(read_only_hint = true, open_world_hint = false) + )] + async fn list_classes(&self) -> Result, String> { + let store = self.lock()?; + let classes = store + .list_classes() + .into_iter() + .map(|c| ClassOut { + id: c.id, + name: c.name.clone(), + }) + .collect(); + Ok(Json(ListClassesOut { classes })) + } } diff --git a/crates/ledger-mcp/src/tools/reconciliation.rs b/crates/ledger-mcp/src/tools/reconciliation.rs new file mode 100644 index 0000000..96872d4 --- /dev/null +++ b/crates/ledger-mcp/src/tools/reconciliation.rs @@ -0,0 +1,456 @@ +//! Bank statements and reconciliations through the server: recording a +//! statement, clearing lines, reconciling, and voiding either. Split +//! whole out of `server.rs` (plan/served.md 8.2 U0a). + +use super::*; + +/// A recorded statement for a tool's output. +fn statement_out(store: &Store, st: &ledger_store::Statement) -> Result { + let (account_code, account_name) = code_name(store, st.account_id); + let file_name = find_attachment(store, st.attachment_id)?.file_name; + let reconciliation_id = store + .reconciliations(Some(st.account_id)) + .map_err(|e| e.to_string())? + .into_iter() + .find(|r| r.attachment_id == st.attachment_id && r.voided.is_none()) + .map(|r| r.id); + Ok(StatementOut { + attachment_id: st.attachment_id, + file_name, + account_code, + account_name, + period_start: st.period_start.to_string(), + period_end: st.period_end.to_string(), + opening_balance: money(st.opening_balance), + closing_balance: money(st.closing_balance), + reconciliation_id, + }) +} + +/// A recorded reconciliation for a tool's output. +fn reconciliation_out(store: &Store, r: &ledger_store::Reconciliation) -> ReconciliationOut { + let (account_code, account_name) = code_name(store, r.account_id); + ReconciliationOut { + reconciliation_id: r.id, + account_code, + account_name, + statement_date: r.statement_date.to_string(), + statement_balance: money(r.statement_balance), + statement_attachment: r.attachment_id, + cleared: r + .cleared + .iter() + .map(|&(entry_id, line_index)| ClearedRefOut { + entry_id, + line_index, + }) + .collect(), + voided: r.voided.is_some(), + voided_reason: r.voided.clone(), + } +} + +/// A line of a reconciliation report. +fn reconcile_line( + entry_id: u64, + line_index: usize, + date: NaiveDate, + description: &str, + side: Side, + amount: Decimal, +) -> ReconcileLineOut { + ReconcileLineOut { + entry_id, + line_index, + date: date.to_string(), + description: description.to_string(), + side: match side { + Side::Debit => "debit", + Side::Credit => "credit", + } + .to_string(), + amount: money(amount), + } +} + +#[tool_router(router = reconciliation_router, vis = "pub(super)")] +impl BooksServer { + #[tool( + name = "books.mark_cleared", + title = "Mark a line cleared", + description = "Mark a posted entry's line as cleared (or uncleared) for bank reconciliation. Only posted lines can be marked.", + annotations( + read_only_hint = false, + destructive_hint = false, + open_world_hint = false + ) + )] + async fn mark_cleared( + &self, + params: Parameters, + ) -> Result, String> { + let p = params.0; + let mut store = self.lock()?; + store + .mark_cleared(p.entry_id, p.line_index, p.cleared) + .map_err(|e| e.to_string())?; + Ok(Json(MarkClearedOut { ok: true })) + } + + /// ```text + /// GOAL: a monthly reconciliation that leaves proof: which statement, + /// which balance, which lines it cleared. + /// SOURCE: CONVENTION (bank reconciliation); docs/parties-and- + /// attachments.md "Reconciliations"; Store::complete_reconciliation. + /// INVARIANTS: + /// 1. Without confirmed=true nothing is recorded: the preview reports + /// the difference, the cleared and uncleared lines, and the + /// problems a confirmation would hit. + /// 2. A reconciliation is recorded only against a recorded statement + /// (statement_attachment), only at a difference of exactly zero, + /// and only after the account's last reconciliation not voided. + /// 3. A refusal journals nothing. + /// SCENARIOS: + /// 1. IF statement_attachment is given: the account, date, and + /// balance are the statement's; any given that disagree: SHOULD + /// refuse. IF its attachment is withdrawn (the statement retired) + /// or its statement voided: SHOULD refuse, preview or not. + /// 2. IF it isn't: account, statement_date, and statement_balance are + /// required, and confirmed=true SHOULD refuse. + /// 3. IF confirmed=true and problems is not empty: SHOULD refuse, + /// naming them. + /// ``` + #[tool( + name = "books.reconcile", + title = "Reconcile an account", + description = "Reconcile a bank, cash, or card account against a statement. Give statement_attachment (a statement recorded with books.record_statement: its account, period end, and closing balance), or account, statement_date, and statement_balance for a quick check against any balance. Returns the book balance, cleared balance, statement balance, the difference (cleared minus statement; zero means reconciled), the cleared and uncleared lines through the statement date, and the problems that would stop it being recorded. Without confirmed=true it is a preview and records nothing. With confirmed=true and a recorded statement it records the reconciliation (journaled as reconciliation.completed, linked to the statement), refused unless the difference is exactly zero and it comes after the account's last reconciliation. Mark lines cleared with books.mark_cleared first.", + annotations( + read_only_hint = false, + destructive_hint = true, + open_world_hint = false + ) + )] + async fn reconcile( + &self, + params: Parameters, + ) -> Result, String> { + let p = params.0; + let confirmed = p.confirmed.unwrap_or(false); + let mut store = self.lock()?; + let given_account = p + .account + .as_deref() + .map(|a| resolve_account(&store, a)) + .transpose()?; + let given_date = p.statement_date.as_deref().map(parse_date).transpose()?; + let given_balance = p + .statement_balance + .as_deref() + .map(parse_amount) + .transpose()?; + let (account, date, balance) = match p.statement_attachment { + Some(att) => { + if find_attachment(&store, att)?.withdrawn_at.is_some() { + return Err(format!( + "attachment {att} is withdrawn, so its statement is retired: file the statement again and record it (books.record_statement)" + )); + } + let st = store + .statement(att) + .map_err(|e| e.to_string())? + .ok_or_else(|| { + format!( + "attachment {att} records no statement: record it with books.record_statement first" + ) + })?; + if let Some(a) = given_account.filter(|a| *a != st.account_id) { + return Err(format!( + "statement {att} is for account {}, not {}", + code_name(&store, st.account_id).0, + code_name(&store, a).0 + )); + } + if let Some(d) = given_date.filter(|d| *d != st.period_end) { + return Err(format!("statement {att} ends {}, not {d}", st.period_end)); + } + if let Some(b) = given_balance.filter(|b| *b != st.closing_balance) { + return Err(format!( + "statement {att} closes at {}, not {}", + money(st.closing_balance), + money(b) + )); + } + (st.account_id, st.period_end, st.closing_balance) + } + None => { + if confirmed { + return Err("a reconciliation is recorded only against a recorded statement: file it (books.file_document category statement), record what it says (books.record_statement), then pass statement_attachment".into()); + } + match (given_account, given_date, given_balance) { + (Some(a), Some(d), Some(b)) => (a, d, b), + _ => { + return Err("give statement_attachment, or account, statement_date, and statement_balance".into()); + } + } + } + }; + let report = store + .ledger() + .reconciliation_report(account, date, balance) + .map_err(|e| e.to_string())?; + let ledger = store.ledger(); + let mut cleared_lines = Vec::new(); + for e in ledger.posted_entries() { + if e.date() > date { + continue; + } + for (i, l) in e.lines().iter().enumerate() { + if l.account == account && ledger.is_cleared(e.id(), i) { + cleared_lines.push(reconcile_line( + e.id(), + i, + e.date(), + e.description(), + l.side, + l.amount.amount, + )); + } + } + } + cleared_lines.sort_by(|a, b| { + a.date + .cmp(&b.date) + .then(a.entry_id.cmp(&b.entry_id)) + .then(a.line_index.cmp(&b.line_index)) + }); + let mut problems = Vec::new(); + if p.statement_attachment.is_none() { + problems.push("no recorded statement: file it, books.record_statement, then pass statement_attachment".to_string()); + } + if !report.difference.is_zero() { + problems.push(format!( + "the cleared balance differs from the statement by {}: mark the lines the statement shows with books.mark_cleared (or find what's missing)", + money(report.difference) + )); + } + if let Some(last) = reconciled_through(&store, account)? + && last >= date + { + problems.push(format!( + "already reconciled through {last}: void that reconciliation (books.void_reconciliation) to redo it" + )); + } + let mut out = ReconcileOut { + status: "preview".to_string(), + account_code: code_name(&store, account).0, + statement_date: date.to_string(), + statement_attachment: p.statement_attachment, + book_balance: money(report.book_balance), + cleared_balance: money(report.cleared_balance), + statement_balance: money(report.statement_balance), + difference: money(report.difference), + uncleared_lines: report + .uncleared_lines + .into_iter() + .map(|l| { + reconcile_line( + l.entry_id, + l.line_index, + l.date, + &l.description, + l.side, + l.amount, + ) + }) + .collect(), + cleared_lines, + problems, + reconciliation: None, + }; + if !confirmed { + return Ok(Json(out)); + } + if !out.problems.is_empty() { + return Err(format!("not recorded: {}", out.problems.join("; "))); + } + let att = p.statement_attachment.expect("required when confirmed"); + let r = store + .complete_reconciliation(att) + .map_err(|e| e.to_string())?; + out.status = "reconciled".to_string(); + out.reconciliation = Some(reconciliation_out(&store, &r)); + Ok(Json(out)) + } + + /// ```text + /// GOAL: the bank and card statements are in the books, so a + /// reconciliation names the one it agrees with. + /// SOURCE: Store::record_statement. + /// INVARIANTS: + /// 1. Journaled as `statement.recorded`, then `attachment.linked` to + /// the account (unless linked already). A refusal journals nothing. + /// SCENARIOS: + /// 1. IF the attachment is unknown, withdrawn, or already records a + /// statement; the account is unknown or not an asset or liability; + /// the period runs backwards; or a balance isn't a decimal string + /// in cents: SHOULD refuse. + /// ``` + #[tool( + name = "books.record_statement", + title = "Record a statement", + description = "Record what a filed bank or card statement says (file it first: books.file_document category statement): the account it is for (code or id; an asset or liability), the period it covers (period_start, period_end: the statement date), and its opening and closing balances as printed, in the account's normal direction (what the bank holds; what the card is owed), decimal strings. Links the statement to the account. Then reconcile to it: books.reconcile statement_attachment. Journaled as statement.recorded. Returns the statement.", + annotations( + read_only_hint = false, + destructive_hint = false, + open_world_hint = false + ) + )] + async fn record_statement( + &self, + params: Parameters, + ) -> Result, String> { + let p = params.0; + let mut store = self.lock()?; + find_attachment(&store, p.attachment_id)?; + let st = ledger_store::Statement { + attachment_id: p.attachment_id, + account_id: resolve_account(&store, &p.account)?, + period_start: parse_date(&p.period_start)?, + period_end: parse_date(&p.period_end)?, + opening_balance: parse_amount(&p.opening_balance)?, + closing_balance: parse_amount(&p.closing_balance)?, + }; + store + .record_statement(st.clone()) + .map_err(|e| e.to_string())?; + Ok(Json(statement_out(&store, &st)?)) + } + + #[tool( + name = "books.statements", + title = "List statements", + description = "Recorded bank and card statements (books.record_statement), optionally one account's (code or id), by statement date: the attachment, account, period, opening and closing balances, and the reconciliation made against it, if any.", + annotations(read_only_hint = true, open_world_hint = false) + )] + async fn statements( + &self, + params: Parameters, + ) -> Result, String> { + let store = self.lock()?; + let account = params + .0 + .account + .as_deref() + .map(|a| resolve_account(&store, a)) + .transpose()?; + let statements = store + .statements(account) + .map_err(|e| e.to_string())? + .iter() + .map(|st| statement_out(&store, st)) + .collect::>()?; + Ok(Json(StatementsOut { statements })) + } + + #[tool( + name = "books.reconciliations", + title = "List reconciliations", + description = "Recorded reconciliations (books.reconcile confirmed), optionally one account's (code or id), by statement date: the account, the statement date and balance, the statement's attachment, the lines it found cleared, and whether it was voided (and why).", + annotations(read_only_hint = true, open_world_hint = false) + )] + async fn reconciliations( + &self, + params: Parameters, + ) -> Result, String> { + let store = self.lock()?; + let account = params + .0 + .account + .as_deref() + .map(|a| resolve_account(&store, a)) + .transpose()?; + let reconciliations = store + .reconciliations(account) + .map_err(|e| e.to_string())? + .iter() + .map(|r| reconciliation_out(&store, r)) + .collect(); + Ok(Json(ReconciliationsOut { reconciliations })) + } + + /// ```text + /// GOAL: correct a mis-recorded statement on the record. + /// SOURCE: Store::void_statement. + /// SCENARIOS: + /// 1. IF the attachment records no statement, a reconciliation not + /// voided rests on it, or the reason is blank: SHOULD refuse, + /// journaling nothing. + /// 2. Otherwise: journaled as `statement.voided`; books.statements no + /// longer lists it, and the same attachment can record the + /// statement again (books.record_statement). + /// ``` + #[tool( + name = "books.void_statement", + title = "Void a statement", + description = "Void a recorded statement that was recorded wrong (a mistyped balance or period), with the reason recorded in the journal, so the same filed statement can be recorded again with books.record_statement. Refused while a reconciliation not voided rests on it (books.void_reconciliation first). Nothing is erased: the journal keeps what it said. Journaled as statement.voided. Returns the statement as it was recorded.", + annotations( + read_only_hint = false, + destructive_hint = true, + open_world_hint = false + ) + )] + async fn void_statement( + &self, + params: Parameters, + ) -> Result, String> { + let p = params.0; + let mut store = self.lock()?; + let st = store + .statement(p.attachment_id) + .map_err(|e| e.to_string())? + .ok_or_else(|| format!("attachment {} records no statement", p.attachment_id))?; + let out = statement_out(&store, &st)?; + store + .void_statement(p.attachment_id, &p.reason) + .map_err(|e| e.to_string())?; + Ok(Json(out)) + } + + /// ```text + /// GOAL: undo a mistaken reconciliation on the record, to redo it. + /// SOURCE: Store::void_reconciliation. + /// SCENARIOS: + /// 1. IF it is unknown or already voided, or the reason is blank: + /// SHOULD refuse, journaling nothing. + /// 2. Otherwise: journaled as `reconciliation.voided`; it stays + /// listed, marked voided, and the account can be reconciled + /// through that date again. + /// ``` + #[tool( + name = "books.void_reconciliation", + title = "Void a reconciliation", + description = "Void a recorded reconciliation that was a mistake, with the reason recorded in the journal, so the account can be reconciled through that date again. Nothing is erased: it stays listed in books.reconciliations, marked voided. Journaled as reconciliation.voided. Returns it.", + annotations( + read_only_hint = false, + destructive_hint = true, + open_world_hint = false + ) + )] + async fn void_reconciliation( + &self, + params: Parameters, + ) -> Result, String> { + let p = params.0; + let mut store = self.lock()?; + store + .void_reconciliation(p.reconciliation_id, &p.reason) + .map_err(|e| e.to_string())?; + let r = store + .reconciliations(None) + .map_err(|e| e.to_string())? + .into_iter() + .find(|r| r.id == p.reconciliation_id) + .ok_or_else(|| format!("unknown reconciliation {}", p.reconciliation_id))?; + Ok(Json(reconciliation_out(&store, &r))) + } +} diff --git a/crates/ledger-mcp/src/tools/reports.rs b/crates/ledger-mcp/src/tools/reports.rs new file mode 100644 index 0000000..3d9fe83 --- /dev/null +++ b/crates/ledger-mcp/src/tools/reports.rs @@ -0,0 +1,1997 @@ +//! The books' reports through the server: the statements, +//! `books.show`, `books.verify`, `books.digest`, `books.export`, the CPA +//! package and the P&L by class, with the export builders they call. +//! Split whole out of `server.rs` (plan/served.md 8.2 U0a). + +use super::*; + +/// What the digest lists from the control check: each line on a control +/// account that is no document's, and each document needing attention. +fn control_problems(out: &ControlCheckOut) -> Vec { + let mut v = Vec::new(); + for a in &out.accounts { + for u in &a.unattached { + v.push(format!( + "{} {}: entry {} line {} ({}, {}) is no invoice's or bill's", + a.code, a.name, u.entry_id, u.line, u.date, u.amount + )); + } + for d in &a.attention { + v.push(format!( + "{} {}: {} {} is {} (total {}, owes {})", + a.code, a.name, d.kind, d.id, d.status, d.total, d.outstanding + )); + } + } + v +} + +/// Net movement of one account over entries in `[from, to]`, normal side. +fn movement_in(ledger: &Ledger, account: AccountId, from: NaiveDate, to: NaiveDate) -> Decimal { + let movement: Decimal = ledger + .posted_entries() + .filter(|e| e.date() >= from && e.date() <= to) + .flat_map(|e| e.lines()) + .filter(|l| l.account == account) + .map(|l| match l.side { + Side::Debit => l.amount.amount, + Side::Credit => -l.amount.amount, + }) + .sum(); + let account = ledger.chart().get(account).expect("account exists"); + match account.account_type.normal_side() { + Side::Debit => movement, + Side::Credit => -movement, + } +} + +fn lines_out(lines: &[ledger_core::StatementLine]) -> Vec { + lines + .iter() + .map(|l| StatementLineOut { + code: l.code.clone(), + name: l.name.clone(), + amount: money(l.amount), + }) + .collect() +} + +/// `books.cash_flow`'s answer, and `books.export`'s cash flow JSON. +fn cash_flow_out(stmt: &ledger_core::CashFlowStatement) -> CashFlowOut { + use ledger_core::CashFlowSection as S; + let line = |l: &ledger_core::CashFlowLine| CashFlowLineOut { + entry: l.entry, + date: l.date.to_string(), + label: l.label.clone(), + kind: l.kind.clone(), + amount: money(l.amount), + on_behalf: (!l.on_behalf.is_zero()).then(|| money(l.on_behalf)), + accounts: l.accounts.clone(), + reverses: l.reverses, + class: l.class.map(|c| c.key().to_string()), + }; + let kinds = |sec: S| { + stmt.kinds(sec) + .into_iter() + .map(|k| CashFlowKindOut { + kind: k.kind, + class: k.class.map(|c| c.key().to_string()), + amount: money(k.amount), + entries: k.entries, + }) + .collect() + }; + let r = &stmt.operating_reconciliation; + CashFlowOut { + method: "direct".to_string(), + start: stmt.start.to_string(), + end: stmt.end.to_string(), + operating_by_class: kinds(S::Operating), + operating: stmt.operating.iter().map(line).collect(), + cash_from_operations: money(stmt.cash_from_operations), + investing_by_kind: kinds(S::Investing), + investing: stmt.investing.iter().map(line).collect(), + cash_from_investing: money(stmt.cash_from_investing), + financing_by_kind: kinds(S::Financing), + financing: stmt.financing.iter().map(line).collect(), + cash_from_financing: money(stmt.cash_from_financing), + net_change_in_cash: money(stmt.net_change_in_cash), + cash_beginning: money(stmt.cash_beginning), + cash_ending: money(stmt.cash_ending), + noncash_disclosures: stmt + .noncash_disclosures + .iter() + .map(|n| NoncashDisclosureOut { + entry: n.entry, + date: n.date.to_string(), + label: n.label.clone(), + amount: money(n.amount), + operating: money(n.operating), + investing: money(n.investing), + financing: money(n.financing), + exchanged: money(n.exchanged), + reverses: n.reverses, + }) + .collect(), + operating_reconciliation: OperatingReconciliationOut { + net_income: money(r.net_income), + lines: r + .lines + .iter() + .map(|l| ReconcilingLineOut { + kind: l.kind.key().to_string(), + label: l.label.clone(), + amount: money(l.amount), + account: l.account.clone(), + entries: l.entries.clone(), + }) + .collect(), + net_cash_from_operating: money(r.net_cash_from_operating), + }, + reconciles: CashFlowFootingOut { + sections_sum: money(stmt.reconciles.sections_sum), + net_change: money(stmt.reconciles.net_change), + difference: money(stmt.reconciles.difference), + }, + warnings: stmt + .warnings + .iter() + .map(|w| CashFlowWarningOut { + entry: w.entry, + kind: w.kind.key().to_string(), + message: w.message.clone(), + }) + .collect(), + } +} + +/// `books.export`'s cash flow CSV: +/// `section,kind,class,entry,date,label,amount,on_behalf`, every line of +/// every section (`on_behalf`: of its amount, what a member or lender +/// paid or received for the company, D-42), the reconciliation, the +/// noncash disclosures, the footing and the warnings (`warning`, its +/// kind, its entry and its message; no amount). Only a section's lines +/// have an `on_behalf`. +fn cash_flow_csv(stmt: &ledger_core::CashFlowStatement) -> String { + use ledger_core::CashFlowSection as S; + let mut csv = String::from("section,kind,class,entry,date,label,amount,on_behalf\n"); + let mut row = |section: &str, + kind: &str, + class: &str, + entry: Option, + date: Option, + label: &str, + amount: rust_decimal::Decimal, + on_behalf: Option| { + csv.push_str(&format!( + "{section},{},{class},{},{},{},{},{}\n", + csv_escape(kind), + entry.map_or(String::new(), |e| e.to_string()), + date.map_or(String::new(), |d| d.to_string()), + csv_escape(label), + money(amount), + on_behalf.map_or(String::new(), money) + )); + }; + for (sec, total) in [ + (S::Operating, "Net cash from operating activities"), + (S::Investing, "Net cash from investing activities"), + (S::Financing, "Net cash from financing activities"), + ] { + for l in stmt.lines(sec) { + let class = l.class.map_or("", |c| c.key()); + row( + sec.key(), + &l.kind, + class, + Some(l.entry), + Some(l.date), + &l.label, + l.amount, + Some(l.on_behalf), + ); + } + row(sec.key(), "", "", None, None, total, stmt.total(sec), None); + } + let r = &stmt.operating_reconciliation; + row( + "reconciliation", + "", + "", + None, + None, + "Net income (loss)", + r.net_income, + None, + ); + for l in &r.lines { + row( + "reconciliation", + l.kind.key(), + "", + None, + None, + &l.label, + l.amount, + None, + ); + } + row( + "reconciliation", + "", + "", + None, + None, + "Net cash from operating activities", + r.net_cash_from_operating, + None, + ); + for n in &stmt.noncash_disclosures { + row( + "noncash", + "", + "", + Some(n.entry), + Some(n.date), + &n.label, + n.amount, + None, + ); + } + let f = &stmt.reconciles; + for (label, amount) in [ + ("Net increase (decrease) in cash", stmt.net_change_in_cash), + ("Cash at beginning of period", stmt.cash_beginning), + ("Cash at end of period", stmt.cash_ending), + ("Sections sum", f.sections_sum), + ("Difference", f.difference), + ] { + row("footing", "", "", None, None, label, amount, None); + } + // The warnings, as the tool gives them: no amount (figures are + // unaffected). + for w in &stmt.warnings { + csv.push_str(&format!( + "warning,{},,{},,{},,\n", + w.kind.key(), + w.entry, + csv_escape(&w.message) + )); + } + csv +} + +#[tool_router(router = reports_router, vis = "pub(super)")] +impl BooksServer { + #[tool( + name = "books.trial_balance", + title = "Trial balance", + description = described( + "The trial balance as of a date: every account with a non-zero balance, debit and credit columns and totals. Debits must equal credits.", + ledger_core::calendar::PeriodKind::AsOf + ), + annotations(read_only_hint = true, open_world_hint = false) + )] + pub(super) async fn trial_balance( + &self, + params: Parameters, + ) -> Result, String> { + let store = self.lock()?; + let as_of = self.as_of(params.0.as_of.as_deref())?; + let tb = reports::trial_balance_as_of(store.ledger(), as_of); + Ok(Json(TrialBalanceOut { + as_of: as_of.to_string(), + lines: tb + .lines + .iter() + .map(|l| TrialBalanceLineOut { + code: l.code.clone(), + name: l.name.clone(), + debit: money(l.debit), + credit: money(l.credit), + }) + .collect(), + total_debits: money(tb.total_debits), + total_credits: money(tb.total_credits), + balanced: tb.balanced(), + })) + } + + #[tool( + name = "books.profit_and_loss", + title = "Profit and loss", + description = described( + "Profit and loss for a period: revenue, expenses, net income. Pass class (a project: name or id; see books.projects) to filter to one project.", + ledger_core::calendar::PeriodKind::Statement + ), + annotations(read_only_hint = true, open_world_hint = false) + )] + pub(super) async fn profit_and_loss( + &self, + params: Parameters, + ) -> Result, String> { + let p = params.0; + let store = self.lock()?; + let class_id = match &p.class { + Some(c) => Some(resolve_class(&store, c)?), + None => None, + }; + let pl = self.pnl_statement( + store.ledger(), + p.start.as_deref(), + p.end.as_deref(), + class_id, + )?; + Ok(Json(ProfitAndLossOut { + start: pl.start.to_string(), + end: pl.end.to_string(), + revenue: money(pl.total_revenue), + expenses: money(pl.total_expenses), + net_income: money(pl.net_income()), + revenue_accounts: lines_out(&pl.revenue), + expense_accounts: lines_out(&pl.expenses), + })) + } + + #[tool( + name = "books.balance_sheet", + title = "Balance sheet", + description = described( + "The balance sheet as of a date: assets, liabilities, equity (including current earnings).", + ledger_core::calendar::PeriodKind::AsOf + ), + annotations(read_only_hint = true, open_world_hint = false) + )] + pub(super) async fn balance_sheet( + &self, + params: Parameters, + ) -> Result, String> { + let p = params.0; + let store = self.lock()?; + let bs = self.bs_statement(store.ledger(), p.as_of.as_deref())?; + Ok(Json(BalanceSheetOut { + as_of: bs.as_of.to_string(), + assets: money(bs.total_assets), + liabilities: money(bs.total_liabilities), + equity: money(bs.total_equity), + current_earnings: money(bs.current_earnings), + balanced: bs.balanced(), + asset_accounts: lines_out(&bs.assets), + liability_accounts: lines_out(&bs.liabilities), + equity_accounts: lines_out(&bs.equity), + })) + } + + #[tool( + name = "books.cash_flow", + title = "Cash flow statement", + description = described( + "Cash flows, direct method (ASC 230): each entry that moved cash, gross, by section and kind (operating by ASC 230-10-45-25 class; investing and financing by kind, such as a member's contributions); an entry and its reversal in one period drop out. What a member or lender paid for the company is borrowed and spent that day (on_behalf). Also noncash activities, net income reconciled to operating cash, and warnings. Sections sum to the change in cash. Roles default to the settings.", + ledger_core::calendar::PeriodKind::Statement + ), + annotations(read_only_hint = true, open_world_hint = false) + )] + async fn cash_flow( + &self, + params: Parameters, + ) -> Result, String> { + let p = params.0; + let store = self.lock()?; + let ledger = store.ledger(); + let stmt = self.cash_flow_for( + ledger, + p.start.as_deref(), + p.end.as_deref(), + &p.cash_accounts, + &p.financing_accounts, + &p.investing_accounts, + )?; + Ok(Json(cash_flow_out(&stmt))) + } + + #[tool( + name = "books.show", + title = "Show in the viewer", + description = "Show something in the human's viewer (the read-only desktop app): an entry (with its lines, receipt, and history), an account's register (optionally for start/end dates), a report (optionally for start/end dates, split into month/quarter/year columns, compared with the prior period or prior year), a stored receipt or document, a fixed asset (by tag or id), a party (by id or name: who they are and everything the company did with them), a project (by id or name), a reconciliation (by id: its account with its statements and reconciliations), a search of the transactions, or a page. Give exactly one target; work out dates yourself (\"last quarter\" means its first and last day). Use it whenever the human asks to see something, and after posting so they can look at what you did. Changes nothing in the books.", + annotations(read_only_hint = true, open_world_hint = false) + )] + async fn show(&self, params: Parameters) -> Result, String> { + use ledger_store::ops::{ViewerTarget, request, viewer_open}; + let p = params.0; + let store = self.lock()?; + let ledger = store.ledger(); + let given = [ + p.entry_id.is_some(), + p.account.is_some(), + p.report.is_some(), + p.attachment_id.is_some(), + p.asset.is_some(), + p.party.is_some(), + p.project.is_some(), + p.reconciliation.is_some(), + p.search.is_some(), + p.page.is_some(), + ] + .iter() + .filter(|x| **x) + .count(); + if given != 1 { + return Err( + "give exactly one of entry_id, account, report, attachment_id, asset, party, project, reconciliation, search, page" + .into(), + ); + } + let start = p.start.as_deref().map(parse_date).transpose()?; + let end = p.end.as_deref().map(parse_date).transpose()?; + // Options only an account or a report takes are refused anywhere + // else (the viewer would ignore them, and the agent believe it + // showed what it didn't). + let dated = p.account.is_some() || p.report.is_some(); + let options: Vec<&str> = [ + ("start", p.start.is_some()), + ("end", p.end.is_some()), + ("columns", p.columns.is_some()), + ("compare", p.compare.is_some()), + ] + .into_iter() + .filter(|(_, given)| *given) + .map(|(name, _)| name) + .collect(); + if !dated && !options.is_empty() { + return Err(format!( + "{} {} for an account or a report; this target takes none", + options.join(" and "), + if options.len() == 1 { "is" } else { "are" } + )); + } + if p.account.is_some() && (p.columns.is_some() || p.compare.is_some()) { + return Err( + "columns and compare are for a report; an account's register takes only start and end" + .into(), + ); + } + const REPORTS: [&str; 11] = [ + "profit_and_loss", + "balance_sheet", + "cash_flow", + "members_equity", + "trial_balance", + "general_ledger", + "tax_summary", + "vendor_1099", + "ar_aging", + "ap_aging", + "fixed_asset_register", + ]; + const PAGES: [&str; 12] = [ + "overview", + "cash", + "profit", + "transactions", + "reports", + "accounts", + "assets", + "parties", + "projects", + "documents", + "company", + "journal", + ]; + let (target, shown) = if let Some(r) = &p.asset { + let id = resolve_asset_ref(&store, r)?; + let a = ledger.get_asset(id).expect("resolved"); + ( + ViewerTarget::Asset { asset_id: id }, + format!( + "asset {}{}", + a.tag + .as_deref() + .map(|t| format!("{t}, ")) + .unwrap_or_default(), + a.description + ), + ) + } else if let Some(r) = &p.party { + let id = resolve_party(&store, r)?; + let party = ledger.get_party(id).expect("resolved"); + ( + ViewerTarget::Party { party_id: id }, + format!("party {id}, {}", party.label()), + ) + } else if let Some(r) = &p.project { + let id = resolve_class(&store, r)?; + let c = ledger.get_class(id).expect("resolved"); + ( + ViewerTarget::Project { class_id: id }, + format!("project {id}, {}", c.name), + ) + } else if let Some(id) = p.reconciliation { + let r = store + .reconciliations(None) + .map_err(|e| e.to_string())? + .into_iter() + .find(|r| r.id == id) + .ok_or_else(|| format!("unknown reconciliation {id}"))?; + let (code, name) = code_name(&store, r.account_id); + ( + ViewerTarget::Reconciliation { id }, + format!( + "reconciliation {id}, {code} {name} through {}", + r.statement_date + ), + ) + } else if let Some(id) = p.entry_id { + let e = ledger + .get_entry(id) + .ok_or_else(|| format!("unknown entry {id}"))?; + ( + ViewerTarget::Entry { entry_id: id }, + format!("entry #{id}, {}", e.description()), + ) + } else if let Some(code) = p.account { + let a = ledger + .chart() + .find_by_code(&code) + .ok_or_else(|| format!("unknown account {code}"))?; + // The dates resolved (rules.md B-30), sent as resolved, so what + // the agent is told and what the viewer shows can't differ. + let (s, e) = self + .periods() + .statement(start, end) + .map_err(|e| e.to_string())?; + ( + ViewerTarget::Account { + code: code.clone(), + start: Some(s.to_string()), + end: Some(e.to_string()), + }, + format!("the register of {} {}, {s} to {e}", a.code, a.name), + ) + } else if let Some(report) = p.report { + if !REPORTS.contains(&report.as_str()) { + return Err(format!( + "report {report:?}: expected one of {}", + REPORTS.join(", ") + )); + } + if let Some(c) = &p.columns + && !["total", "month", "quarter", "year"].contains(&c.as_str()) + { + return Err(format!( + "columns {c:?}: expected total, month, quarter, or year" + )); + } + if let Some(c) = &p.compare + && !["prior_period", "prior_year"].contains(&c.as_str()) + { + return Err(format!( + "compare {c:?}: expected prior_period or prior_year" + )); + } + let dates = show_report_dates(&report, p.columns.as_deref(), p.compare.as_deref())?; + if p.start.is_some() { + match dates { + ShownDates::Period => {} + ShownDates::AsOf => { + return Err(format!( + "{report}{} is as of a day: pass end (the day), not start", + if report == "balance_sheet" { + " in one total column" + } else { + "" + } + )); + } + ShownDates::Year => { + return Err(format!( + "{report} is for a calendar year: pass end (a day in that year), not start" + )); + } + } + } + let (s, e) = self + .periods() + .statement(start, end) + .map_err(|e| e.to_string())?; + // A split past the viewer's columns is shown as one total (the + // viewer says so): a split it would ignore. + let per = match p.columns.as_deref() { + Some("month") => Some(1), + Some("quarter") => Some(3), + Some("year") => Some(12), + _ => None, + }; + if let Some(per) = per { + let n = ledger_store::ops::report_columns(s, e, per); + let max = ledger_store::ops::MAX_REPORT_COLUMNS; + if n > max { + return Err(format!( + "columns {} from {s} to {e} is {n} columns, and the viewer splits a report into at most {max} (past that it shows the total): pass a shorter range or a longer split", + p.columns.as_deref().unwrap_or_default() + )); + } + } + let mut what = format!("the {} report", report.replace('_', " ")); + what.push_str(&match dates { + ShownDates::Period => format!(", {s} to {e}"), + ShownDates::AsOf => format!(", as of {e}"), + ShownDates::Year => format!(", for {}", chrono::Datelike::year(&e)), + }); + if let Some(c) = p.columns.as_deref().filter(|c| *c != "total") { + what.push_str(&format!(", by {c}")); + } + if let Some(c) = &p.compare { + what.push_str(&format!(", compared with the {}", c.replace('_', " "))); + } + ( + ViewerTarget::Report { + report: report.clone(), + start: Some(s.to_string()), + end: Some(e.to_string()), + columns: p.columns.clone(), + compare: p.compare.clone(), + }, + what, + ) + } else if let Some(id) = p.attachment_id { + let a = store + .list_attachments(None) + .map_err(|e| e.to_string())? + .into_iter() + .find(|a| a.id == id) + .ok_or_else(|| format!("unknown attachment {id}"))?; + ( + ViewerTarget::Document { attachment_id: id }, + format!("document {id}, {}", a.file_name), + ) + } else if let Some(query) = p.search { + ( + ViewerTarget::Search { + query: query.clone(), + }, + format!("transactions matching {query:?}"), + ) + } else { + let page = p.page.unwrap_or_default(); + if !PAGES.contains(&page.as_str()) { + return Err(format!( + "page {page:?}: expected one of {}", + PAGES.join(", ") + )); + } + ( + ViewerTarget::Page { page: page.clone() }, + format!("the {page} page"), + ) + }; + request(store.data_dir(), target).map_err(|e| format!("couldn't reach the viewer: {e}"))?; + // Whether a viewer has these books open (ledger_store::ops + // INVARIANT 3): it shows it now; else it will when opened soon. + let open = viewer_open(store.data_dir()); + Ok(Json(ShowOut { + shown: shown.clone(), + viewer_open: open, + note: if open { + format!("The viewer shows {shown}.") + } else { + format!( + "No viewer has these books open: ask the human to open it (opened within a couple of minutes, it shows {shown})." + ) + }, + })) + } + + #[tool( + name = "books.verify", + title = "Verify the books", + description = "Check the books end to end: the journal's hash chain; a fresh replay against the live ledger; every table of the books' database, row for row, against a fresh projection of the journal (rebuilding the database from the journal if it drifted, keeping the old copy); and, by default, every stored receipt and document against its SHA-256. Run it whenever a number looks wrong or a human asks whether the books are intact.", + annotations( + read_only_hint = false, + destructive_hint = false, + idempotent_hint = true, + open_world_hint = false + ) + )] + async fn verify(&self, params: Parameters) -> Result, String> { + let mut store = self.lock()?; + let report = store.verify_and_heal().map_err(|e| e.to_string())?; + let blobs = if params.0.blobs { + Some(store.verify_blobs().map_err(|e| e.to_string())?) + } else { + None + }; + let mut checked = vec![ + format!("journal hash chain ({} events)", report.events), + "fresh replay matches the live ledger (every statement, assets, classes, payroll)" + .to_string(), + format!( + "SQLite database ({}) matches a fresh projection of the journal, every table, row for row", + report.database + ), + ]; + if let Some(n) = blobs { + checked.push(format!("{n} stored files match their SHA-256")); + } + // Receivables and payables equal their documents (rules.md B-38 + // INVARIANT 1): an identity, so a difference is an engine error. + let checks = store + .ledger() + .control_check(chrono::NaiveDate::MAX) + .map_err(|e| e.to_string())?; + if let Some(c) = checks.iter().find(|c| !c.difference.is_zero()) { + return Err(format!( + "{}: its balance {} is not its documents' {} plus its other lines: an engine error, report it", + c.role, + money(c.balance), + money(c.documents_total) + )); + } + checked.push(format!( + "receivables and payables equal their documents and other lines ({} control accounts)", + checks.len() + )); + Ok(Json(VerifyOut { + status: if report.healed.is_some() { + "healed" + } else { + "ok" + } + .to_string(), + events: report.events, + head: report.head, + healed: report.healed, + blobs_verified: blobs, + database: report.database, + other_databases: report.other_databases, + checked, + })) + } + + #[tool( + name = "books.digest", + title = "Activity digest", + description = "What changed since a date: entries posted, per-account movement, and receipt coverage; plus what needs doing: rules due for review, settings problems, cash and card accounts not reconciled through the end of last month (unreconciled_accounts), revenue and expense accounts with no tax category (uncategorized_accounts), and receivables or payables lines that are no document's or documents needing attention (control_problems, books.control_check). Built for human review.", + annotations(read_only_hint = true, open_world_hint = false) + )] + pub(super) async fn digest( + &self, + params: Parameters, + ) -> Result, String> { + let p = params.0; + let since = parse_date(&p.since)?; + let today = today(); + let store = self.lock()?; + let ledger = store.ledger(); + let mut entries = Vec::new(); + for e in ledger.posted_entries() { + if e.date() < since { + continue; + } + let receipts = store + .list_attachments(Some(e.id())) + .map_err(|e| e.to_string())?; + let required = self.settings().policy.receipt_required(ledger, e); + entries.push(DigestEntry { + entry_id: e.id(), + date: e.date().to_string(), + description: e.description().to_string(), + total: money(ReceiptPolicy::entry_debits(e).map_err(|e| e.to_string())?), + reversal_of: e.reversal_of(), + receipt_count: receipts.len() as u64, + receipt_required: required, + receipt_missing: required && receipts.is_empty(), + }); + } + entries.sort_by(|a, b| b.date.cmp(&a.date).then(b.entry_id.cmp(&a.entry_id))); + let mut totals = Vec::new(); + for a in ledger.chart().all() { + if a.placeholder { + continue; + } + let m = movement_in(ledger, a.id, since, today); + if !m.is_zero() { + totals.push(DigestTotal { + code: a.code.clone(), + name: a.name.clone(), + movement: money(m), + }); + } + } + totals.sort_by(|a, b| a.code.cmp(&b.code)); + let stale_rules = ledger_core::rules::stale_rules(today) + .into_iter() + .map(|r| StaleRule { + name: r.name.to_string(), + known_through: r.known_through, + source: r.authority.to_string(), + why: r.review_why.to_string(), + }) + .collect(); + let journal_repairs = store + .tail_repairs() + .map_err(|e| e.to_string())? + .into_iter() + .filter(|(at, _, _)| at.get(..10).is_some_and(|d| d >= p.since.as_str())) + // The stamp only: the file's name would name the journal. + .map(|(at, bytes, saved_as)| JournalRepair { + at, + bytes, + set_aside: saved_as + .rsplit_once(".torn.") + .map(|(_, stamp)| stamp.to_string()) + .unwrap_or_default(), + }) + .collect(); + let unreconciled_accounts = self.unreconciled_accounts(&store, today)?; + let uncategorized_accounts = ledger_core::tax::uncategorized_accounts(ledger) + .into_iter() + .map(|a| format!("{} {}", a.code, a.name)) + .collect(); + let control_problems = control_problems(&control_check_out( + ledger, + today, + &ledger.control_check(today).map_err(|e| e.to_string())?, + )); + Ok(Json(DigestOut { + unreconciled_accounts, + uncategorized_accounts, + control_problems, + since: since.to_string(), + entries, + totals_by_account: totals, + stale_rules, + journal_repairs, + settings_problems: settings_problems(store.ledger(), &self.settings()), + })) + } + + #[tool( + name = "books.export", + title = "Export a report", + description = "Export a report. Reports: profit_and_loss, balance_sheet, cash_flow (direct method, with its reconciliation and noncash schedules; needs cash_accounts), trial_balance (as_of), members_equity (multi-member LLC; members from the settings), tax_summary, general_ledger (by account: running balances, splits), general_journal (each entry posted in the period; alias journal), fixed_asset_register (as_of: every asset with tag, serial, cost, depreciation, book value, reconciled to the books). The file is handed over (run its download to save it; never its bytes). Formats: pdf and xlsx for the statements, tax_summary and fixed_asset_register; xlsx, csv, json for every report; iif for general_journal or journal (QuickBooks Desktop: plain ASCII text, CR LF; every posted entry from the first through as_of (start and end are refused), period closes left out, DOCNUM the entry id; names, numbers and memos fit QuickBooks' limits; warnings says what is written differently from the books, memos grouped, to tell the human). Dates: start/end for period reports (omitted: the fiscal year to date), as_of for balance_sheet, trial_balance, fixed_asset_register and iif (omitted: today).", + annotations(read_only_hint = true, open_world_hint = false) + )] + async fn export(&self, params: Parameters) -> Result, String> { + let p = params.0; + let format = p.format.to_lowercase(); + if !["csv", "json", "xlsx", "pdf", "iif"].contains(&format.as_str()) { + return Err(format!( + "bad format {:?}: expected \"csv\", \"json\", \"xlsx\", \"pdf\", or \"iif\"", + p.format + )); + } + let report = p.report.to_lowercase(); + if format == "iif" { + if !matches!(report.as_str(), "journal" | "general_journal") { + return Err( + "iif format is only valid for the general_journal report (alias journal)" + .to_string(), + ); + } + if p.start.is_some() || p.end.is_some() { + return Err("iif takes as_of, not start or end: the file holds every posted entry from the first through as_of, so QuickBooks' balances match the books'".to_string()); + } + // As of today by default, like every other as-of export + // (rules.md B-30); never a period, so the general journal's + // default period never shortens it (D-36, B-34). + let as_of = self.as_of(p.as_of.as_deref())?; + let iif = self.export_iif(as_of, &p.cash_accounts)?; + let warnings = iif.warnings(); + let file = self.hand_over_export(&report, &format, iif.text.into_bytes())?; + return Ok(Json(ExportOut { + format, + report, + file, + warnings, + })); + } + if format == "xlsx" || format == "pdf" { + let bytes = if format == "xlsx" { + self.export_xlsx(&report, &p)? + } else { + self.export_pdf(&report, &p)? + }; + let file = self.hand_over_export(&report, &format, bytes)?; + return Ok(Json(ExportOut { + format, + report, + file, + warnings: Vec::new(), + })); + } + let content = match report.as_str() { + "journal" | "general_journal" => self.export_journal(&format, &p)?, + "trial_balance" => self.export_trial_balance(&format, p.as_of.as_deref())?, + "general_ledger" => self.export_general_ledger(&format, &p)?, + "members_equity" => self.export_members_equity(&format, &p)?, + "tax_summary" => self.export_tax_summary(&format, &p)?, + "profit_and_loss" => self.export_pnl(&format, p.start.as_deref(), p.end.as_deref())?, + "balance_sheet" => self.export_balance_sheet(&format, p.as_of.as_deref())?, + "cash_flow" => self.export_cash_flow(&format, &p)?, + "fixed_asset_register" => { + self.export_fixed_asset_register(&format, p.as_of.as_deref())? + } + _ => { + return Err(format!( + "bad report {:?}: expected general_journal, general_ledger, trial_balance, profit_and_loss, balance_sheet, cash_flow, members_equity, tax_summary, or fixed_asset_register", + p.report + )); + } + }; + let file = self.hand_over_export(&report, &format, content.into_bytes())?; + Ok(Json(ExportOut { + format, + report, + file, + warnings: Vec::new(), + })) + } + + #[tool( + name = "books.profit_and_loss_by_class", + title = "Profit and loss by class", + description = described( + "P&L broken down by class (a project: see books.projects) for a period, plus an unclassified row for untagged lines.", + ledger_core::calendar::PeriodKind::Statement + ), + annotations(read_only_hint = true, open_world_hint = false) + )] + async fn profit_and_loss_by_class( + &self, + params: Parameters, + ) -> Result, String> { + let p = params.0; + let store = self.lock()?; + let ledger = store.ledger(); + let (start, end) = self.statement_range(p.start.as_deref(), p.end.as_deref())?; + // One class, if asked (validated like every reference). + let only = match &p.class { + Some(c) => Some(resolve_class(&store, c)?), + None => None, + }; + // Per-class P&L over the date range: accumulate line by line. + use std::collections::HashMap; + #[derive(Default)] + struct Acc { + revenue: Decimal, + expenses: Decimal, + } + let mut by_class: HashMap, Acc> = HashMap::new(); + // A closing entry isn't income or spending (rules.md B-11): left + // out, as profit_and_loss_for_period leaves it out (found by + // pnl_by_class_excludes_closing_entries, 2026-10-14). + let closings = ledger.closing_entry_ids(); + for entry in ledger.posted_entries() { + if entry.date() < start || entry.date() > end || closings.contains(&entry.id()) { + continue; + } + for line in entry.lines() { + let account = match ledger.chart().get(line.account) { + Some(a) => a, + _ => continue, + }; + let signed = match line.side { + Side::Debit => line.amount.amount, + Side::Credit => -line.amount.amount, + }; + // Normal-side positive. + let normal = match account.account_type.normal_side() { + Side::Debit => signed, + Side::Credit => -signed, + }; + let acc = by_class.entry(line.class_id).or_default(); + match account.account_type { + AccountType::Revenue => acc.revenue += normal, + AccountType::Expense => acc.expenses += normal, + _ => {} + } + } + } + // Sort: named classes by name, unclassified last. + let mut rows: Vec<(Option, Acc)> = by_class + .into_iter() + .filter(|(cid, _)| only.is_none() || *cid == only) + .map(|(cid, acc)| { + let name = cid + .and_then(|id| ledger.get_class(id)) + .map(|c| c.name.clone()); + (name, acc) + }) + // Drop rows with no P&L movement. + .filter(|(_, acc)| !acc.revenue.is_zero() || !acc.expenses.is_zero()) + .collect(); + rows.sort_by(|a, b| match (&a.0, &b.0) { + (Some(x), Some(y)) => x.cmp(y), + (Some(_), None) => std::cmp::Ordering::Less, + (None, Some(_)) => std::cmp::Ordering::Greater, + (None, None) => std::cmp::Ordering::Equal, + }); + let classes = rows + .into_iter() + .map(|(class, acc)| ClassPnlOut { + class, + revenue: money(acc.revenue), + expenses: money(acc.expenses), + net_income: money(acc.revenue - acc.expenses), + }) + .collect(); + Ok(Json(ProfitAndLossByClassOut { + start: start.to_string(), + end: end.to_string(), + classes, + })) + } + + /// ```text + /// GOAL: everything the CPA needs for the return in one zip, the K-1s' + /// and 1099s' TINs included, without a full TIN ever landing in + /// the books (a git repository) or the conversation. + /// SOURCE: docs/mcp-interface.md; B-26; DECISION (owner, + /// 2026-10-15): "it needs to be in the damn CPA package." + /// INVARIANTS: + /// 1. The zip is handed over (tools/files.rs INVARIANT 6: an export + /// the caller fetches), never written to a path; with include_tins + /// (the default) it and each file in it holding a TIN are recorded + /// (tin_files.rs), so none is filed back in. include_tins false: + /// last fours only. + /// 2. With members, k1-members.csv has one row per [[company.members]] + /// member: name, party, share, capital account, TIN and type, and + /// the K-1 worksheet's capital figures (ledger_core::k1) for the + /// fiscal year to date; k1-tax-basis-capital.csv the same capital on + /// the tax basis beside them (ledger_core::tax_capital). The 1099 + /// worksheet adds each payee's party, last four, TIN, and type. + /// 3. A locked vault or a missing TIN never fails the package: the + /// row says why (vault_tin), the README lists it, and so does + /// tin_gaps (members, and 1099 payees that are reportable). + /// 4. Read-only: nothing is journaled. + /// ``` + #[tool( + name = "books.export_cpa_package", + title = "CPA handoff package", + description = "Build the CPA handoff package as a zip: the profit and loss, balance sheet, cash flow (direct method, with its reconciliation and noncash schedules), statement of changes in members' equity (multi-member LLCs), tax summary, and trial balance, each as PDF and XLSX; the fixed asset register as PDF and XLSX when an asset was acquired by as_of; the general ledger (by account) and general journal (entry by entry) as XLSX; the journal through as_of as IIF (imports into QuickBooks Desktop; plain ASCII within its limits: warnings says what is written differently from the books, and so does the README); the 1099 worksheet as CSV (payments from January 1 through as_of); with members, the K-1 worksheet (k1-members.csv: per member their party, share, capital account, TIN, and beginning capital, contributions in cash and in property, share of income, distributions, and ending capital for the fiscal year to date) and its tax-basis capital beside the book figures (k1-tax-basis-capital.csv: Schedule K-1 Item L on the tax basis, property at its §723 basis, with the reconciliation; what only the CPA can supply, like tax depreciation, left open and named); every receipt behind the year's entries, filed on them or linked to them (receipts/, with an index.csv tying each file to its entry) and the company's documents (documents/), unless include_receipts is false; and a README with the journal's verified hash. By default (include_tins) the K-1 and 1099 worksheets carry every member's and payee's full TIN from the TIN vault; include_tins: false, last fours only. The zip is handed over: run its download command to save it in a folder. Never file it into the books. A locked vault or a missing TIN doesn't stop the package: tin_gaps lists each one (collect them with books.set_tin or books.set_w9 and export again). cash_accounts (e.g. [\"1000\"]) is required: the cash flow statement reconciles to them. as_of defaults to today; period reports cover the fiscal year to date.", + annotations( + read_only_hint = false, + destructive_hint = false, + open_world_hint = false + ) + )] + async fn export_cpa_package( + &self, + params: Parameters, + ) -> Result, String> { + let p = params.0; + let as_of = self.as_of(p.as_of.as_deref())?; + let store = self.lock()?; + let book = book_name(&store); + // The vault, opened once and never creating a key: locked, each + // TIN row says so and the package is built anyway (INVARIANT 3). + let vault = p.include_tins.then(|| self.open_vault(&store, false)); + let ledger = store.ledger(); + let tins = vault + .as_ref() + .map(|vault| move |id| vault_tin(ledger, vault, id)); + // Built from the books under the lock, written nowhere + // (ledger_package INVARIANT 1). + let package = ledger_package::build_package( + &*store, + &package_settings(&self.settings()), + &ledger_package::PackageRequest { + as_of, + prepared_on: today(), + cash_accounts: &p.cash_accounts, + financing_accounts: &p.financing_accounts, + investing_accounts: &p.investing_accounts, + include_receipts: p.include_receipts, + tins: tins.as_ref().map(|f| f as ledger_package::TinSource<'_>), + }, + )?; + let vault_dir = if p.include_tins { + self.tin_record_dir(&store)? + } else { + self.vault_dir(&store) + }; + drop(store); + + if p.include_tins { + // The zip and each file in it that holds a TIN (the K-1 and + // 1099 worksheets): one given back to be filed is refused + // (tin_files.rs INVARIANT 1). + for (name, bytes) in &package.files { + let filed = name.starts_with("receipts/") || name.starts_with("documents/"); + if !filed && ledger_core::parties::has_full_tin(&String::from_utf8_lossy(bytes)) { + tin_files::record(&vault_dir, bytes)?; + } + } + tin_files::record(&vault_dir, &package.zip)?; + } + let tin_gaps = package.tin_gaps; + let mut note = match (p.include_tins, tin_gaps.len()) { + (false, _) => "The package, with last fours only.".to_string(), + (true, 0) => "The package, with full TINs, for the CPA.".to_string(), + (true, n) => format!( + "The package, with full TINs, for the CPA. {n} TIN(s) are missing (tin_gaps): collect them with books.set_tin or books.set_w9 and export again." + ), + }; + let warnings = package.warnings; + if package.iif_changes > 0 { + note.push_str(&format!( + " {} account or class name(s), number(s), place(s) or memo(s) are written differently in general-journal.iif to fit QuickBooks Desktop (warnings says how; so does the README): tell the human.", + package.iif_changes + )); + } + let file = self.hand_over(&book, &package.file_name, "application/zip", package.zip)?; + Ok(Json(ExportCpaPackageOut { + file, + tins_included: p.include_tins, + tin_gaps, + warnings, + note, + })) + } +} + +// The export builders the export tools above call: the journal, the +// statements laid out for PDF and XLSX, and the IIF. +impl BooksServer { + /// An export's file, handed over (files.rs INVARIANT 6, D-46): named + /// for its report and today, typed by its format. + fn hand_over_export( + &self, + report: &str, + format: &str, + bytes: Vec, + ) -> Result { + let mime = match format { + "csv" => "text/csv", + "json" => "application/json", + "xlsx" => "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", + "pdf" => "application/pdf", + // IIF is tab-separated ASCII text. + _ => "text/plain", + }; + let book = book_name(&*self.lock()?); + self.hand_over( + &book, + &format!("{report}-{}.{format}", today()), + mime, + bytes, + ) + } + + /// Lay out a financial statement for rendering, from export params. + fn layout_statement( + &self, + ledger: &Ledger, + report: &str, + p: &ExportParams, + ) -> Result { + use ledger_import::report; + let members = self.report_members(); + Ok(match report { + "profit_and_loss" => report::profit_and_loss(&self.pnl_statement( + ledger, + p.start.as_deref(), + p.end.as_deref(), + None, + )?), + "balance_sheet" => { + let bs = self.bs_statement(ledger, p.as_of.as_deref())?; + let earnings = self.year_earnings(ledger, bs.as_of)?; + report::balance_sheet(&bs, earnings, &members) + } + "cash_flow" => report::cash_flow(&self.cash_flow_for( + ledger, + p.start.as_deref(), + p.end.as_deref(), + &p.cash_accounts, + &p.financing_accounts, + &p.investing_accounts, + )?), + "trial_balance" => { + let as_of = self.as_of(p.as_of.as_deref())?; + report::trial_balance(&reports::trial_balance_as_of(ledger, as_of), as_of) + } + "tax_summary" => { + let (start, end) = self.statement_range(p.start.as_deref(), p.end.as_deref())?; + report::tax_summary( + &ledger_core::tax_summary(ledger, start, end, self.settings().company.tax_form) + .map_err(|e| e.to_string())?, + ) + } + "members_equity" => { + let (start, end) = self.statement_range(p.start.as_deref(), p.end.as_deref())?; + let me = + ledger_core::members_equity(ledger, start, end, &self.member_capitals(ledger)?) + .map_err(|e| e.to_string())?; + report::members_equity(&me) + } + _ => { + return Err(format!( + "bad report {report:?}: expected profit_and_loss, balance_sheet, cash_flow, trial_balance, members_equity, or tax_summary" + )); + } + }) + } + + /// Render a report to XLSX bytes via ledger-import. + fn export_xlsx(&self, report: &str, p: &ExportParams) -> Result, String> { + use ledger_import::{XlsxReport, report_to_xlsx}; + let store = self.lock()?; + let meta = self.report_meta(&store); + let ledger = store.ledger(); + if report == "fixed_asset_register" { + let as_of = self.as_of(p.as_of.as_deref())?; + let r = ledger_core::fixed_asset_register(ledger, as_of).map_err(|e| e.to_string())?; + return ledger_import::asset_register_xlsx(&r, &meta).map_err(|e| e.to_string()); + } + let xlsx = |r: XlsxReport<'_>| report_to_xlsx(&r, &meta).map_err(|e| e.to_string()); + match report { + "general_ledger" => { + let (start, end) = self.statement_range(p.start.as_deref(), p.end.as_deref())?; + let gl = + ledger_core::general_ledger(ledger, start, end).map_err(|e| e.to_string())?; + xlsx(XlsxReport::GeneralLedger(&gl)) + } + "general_journal" | "journal" => { + let (start, end) = self.statement_range(p.start.as_deref(), p.end.as_deref())?; + xlsx(XlsxReport::GeneralJournal(ledger, start, end)) + } + _ => xlsx(XlsxReport::Statement( + &self.layout_statement(ledger, report, p)?, + )), + } + } + + /// Render a financial statement to PDF bytes via ledger-import. + fn export_pdf(&self, report: &str, p: &ExportParams) -> Result, String> { + if matches!(report, "general_ledger" | "general_journal" | "journal") { + return Err(format!("{report} exports as xlsx, csv, or json, not pdf")); + } + let store = self.lock()?; + let meta = self.report_meta(&store); + if report == "fixed_asset_register" { + let as_of = self.as_of(p.as_of.as_deref())?; + let r = ledger_core::fixed_asset_register(store.ledger(), as_of) + .map_err(|e| e.to_string())?; + return ledger_import::asset_register_pdf(&r, &meta).map_err(|e| e.to_string()); + } + let st = self.layout_statement(store.ledger(), report, p)?; + ledger_import::statement_pdf(&st, &meta).map_err(|e| e.to_string()) + } + + /// The journal through `as_of` as IIF via ledger-package (the CPA + /// package's own general-journal.iif): the cash accounts (`cash`, else + /// the settings') BANK, the receivable and payable control accounts AR + /// and AP, the accounts of an asset bought by `as_of` FIXASSET, the + /// rest by their wanderbooks type; every posted entry from the first, + /// closes left out. + fn export_iif( + &self, + as_of: chrono::NaiveDate, + cash: &[String], + ) -> Result { + let store = self.lock()?; + let settings = self.settings(); + let cash = if cash.is_empty() { + settings.roles.cash.as_slice() + } else { + cash + }; + ledger_package::export_iif(store.ledger(), cash, as_of) + } + /// The general journal as JSON or CSV: each entry posted in the + /// period (rules.md B-30, as the XLSX journal), in posting order. + fn export_journal(&self, format: &str, p: &ExportParams) -> Result { + let (start, end) = self.statement_range(p.start.as_deref(), p.end.as_deref())?; + let store = self.lock()?; + let ledger = store.ledger(); + let in_period = |e: &&ledger_core::JournalEntry| (start..=end).contains(&e.date()); + if format == "json" { + let rows: Vec = ledger + .posted_entries() + .filter(in_period) + .map(|e| { + serde_json::json!({ + "entry_id": e.id(), + "date": e.date().to_string(), + "description": e.description(), + "reversal_of": e.reversal_of(), + "lines": e.lines().iter().map(|l| { + let a = ledger.chart().get(l.account).unwrap(); + serde_json::json!({ + "account_code": a.code, + "account_name": a.name, + "side": match l.side { Side::Debit => "debit", Side::Credit => "credit" }, + "amount": money(l.amount.amount), + "currency": l.amount.currency.code(), + }) + }).collect::>(), + }) + }) + .collect(); + return serde_json::to_string_pretty(&rows).map_err(|e| e.to_string()); + } + let mut csv = String::from( + "entry_id,date,description,reversal_of,account_code,account_name,side,amount,currency\n", + ); + for e in ledger.posted_entries().filter(in_period) { + for l in e.lines() { + let a = ledger.chart().get(l.account).unwrap(); + csv.push_str(&format!( + "{},{},{},{},{},{},{},{},{}\n", + e.id(), + e.date(), + csv_escape(e.description()), + e.reversal_of().map(|v| v.to_string()).unwrap_or_default(), + a.code, + csv_escape(&a.name), + match l.side { + Side::Debit => "debit", + Side::Credit => "credit", + }, + money(l.amount.amount), + l.amount.currency.code(), + )); + } + } + Ok(csv) + } + + fn export_trial_balance(&self, format: &str, as_of: Option<&str>) -> Result { + let store = self.lock()?; + let as_of = self.as_of(as_of)?; + let tb = reports::trial_balance_as_of(store.ledger(), as_of); + if format == "json" { + return serde_json::to_string_pretty(&serde_json::json!({ + "as_of": as_of.to_string(), + "lines": tb.lines.iter().map(|l| serde_json::json!({ + "code": l.code, "name": l.name, + "debit": money(l.debit), "credit": money(l.credit), + })).collect::>(), + "total_debits": money(tb.total_debits), + "total_credits": money(tb.total_credits), + "balanced": tb.balanced(), + })) + .map_err(|e| e.to_string()); + } + let mut csv = String::from("code,name,debit,credit\n"); + for l in &tb.lines { + csv.push_str(&format!( + "{},{},{},{}\n", + l.code, + csv_escape(&l.name), + money(l.debit), + money(l.credit) + )); + } + csv.push_str(&format!( + "TOTAL,,{},{}", + money(tb.total_debits), + money(tb.total_credits) + )); + Ok(csv) + } + + /// The fixed asset register (ledger_core::fixed_asset_register) as + /// JSON or CSV, reconciled to the books. + fn export_fixed_asset_register( + &self, + format: &str, + as_of: Option<&str>, + ) -> Result { + let store = self.lock()?; + let as_of = self.as_of(as_of)?; + let r = + ledger_core::fixed_asset_register(store.ledger(), as_of).map_err(|e| e.to_string())?; + // The same words as the XLSX and PDF register. + let status = |row: &ledger_core::AssetRegisterRow| match row.disposed { + Some((d, p)) => format!("Disposed {d}, proceeds {}", money(p)), + None => "Active".to_string(), + }; + let source = |row: &ledger_core::AssetRegisterRow| { + row.basis_source.as_ref().map(|s| { + let mut t = format!("{}, {}", s.decided_by, s.decided_on); + if let Some(c) = &s.citation { + t.push_str(&format!(": {c}")); + } + t + }) + }; + if format == "json" { + return serde_json::to_string_pretty(&serde_json::json!({ + "as_of": as_of.to_string(), + "tax_figures_are": "at contribution (or purchase): the CPA adjusts them for tax depreciation, and the §704(c) gain or loss as it is reduced after (Treas. Reg. §1.704-3(a)(3)(ii))", + "assets": r.rows.iter().map(|a| serde_json::json!({ + "asset_id": a.asset_id, + "tag": a.tag, + "description": a.description, + "serial": a.serial, + "acquired_date": a.acquired_date.to_string(), + "in_service_date": a.in_service_date.to_string(), + "cost": money(a.cost), + "salvage": money(a.salvage), + "useful_life_months": a.useful_life_months, + "accumulated_scheduled": money(a.accumulated_scheduled), + "accumulated_posted": money(a.accumulated_posted), + "book_value": money(a.book_value), + "status": status(a), + "proceeds": a.disposed.map(|(_, p)| money(p)), + "basis_rule": a.basis_rule.map(|r| r.key()), + "contributor_adjusted_basis": a.contributor_adjusted_basis.map(money), + "fmv_at_contribution": a.fmv_at_contribution.map(money), + "fmv_recorded": a.fmv_recorded, + "tax_basis_723": a.tax_basis_723.map(money), + "depreciable_basis": a.depreciable_basis.map(money), + "built_in_gain_704c": a.built_in_gain_704c.map(money), + "sec704c_1c": a.sec704c_1c, + "sec704c_method": a.sec704c_method.map(|m| m.key()), + "basis_source": source(a), + })).collect::>(), + "total_cost": money(r.total_cost), + "total_accumulated_scheduled": money(r.total_accumulated_scheduled), + "total_accumulated_posted": money(r.total_accumulated_posted), + "total_book_value": money(r.total_book_value), + "total_depreciable_basis": money(r.total_depreciable_basis), + "total_tax_basis_723": money(r.total_tax_basis_723), + "missing_basis": r.missing_basis, + "ledger_cost": money(r.ledger_cost), + "ledger_accumulated": money(r.ledger_accumulated), + "unposted_depreciation": money(r.unposted_depreciation()), + "unattributed_accumulated": money(r.unattributed_accumulated()), + "unregistered_cost": money(r.unregistered_cost()), + })) + .map_err(|e| e.to_string()); + } + let mut csv = String::from( + "tag,description,serial,acquired,in_service,cost,salvage,useful_life_months,accumulated_scheduled,accumulated_posted,book_value,status,proceeds,basis_rule,contributor_adjusted_basis,fmv_at_contribution,fmv_recorded,tax_basis_723,depreciable_basis,built_in_gain_704c,sec704c_1c,sec704c_method,basis_source\n", + ); + for a in &r.rows { + let cells = [ + csv_escape(a.tag.as_deref().unwrap_or("")), + csv_escape(&a.description), + csv_escape(a.serial.as_deref().unwrap_or("")), + a.acquired_date.to_string(), + a.in_service_date.to_string(), + money(a.cost), + money(a.salvage), + a.useful_life_months.to_string(), + money(a.accumulated_scheduled), + money(a.accumulated_posted), + money(a.book_value), + status(a), + a.disposed.map(|(_, p)| money(p)).unwrap_or_default(), + a.basis_rule + .map(|r| r.key()) + .unwrap_or_default() + .to_string(), + a.contributor_adjusted_basis.map(money).unwrap_or_default(), + a.fmv_at_contribution.map(money).unwrap_or_default(), + if a.basis_rule.is_some_and(|r| r.is_contribution()) { + a.fmv_recorded.to_string() + } else { + String::new() + }, + a.tax_basis_723.map(money).unwrap_or_default(), + a.depreciable_basis.map(money).unwrap_or_default(), + a.built_in_gain_704c.map(money).unwrap_or_default(), + // IRC §704(c)(1)(C): a built-in loss counts only for the + // contributor; blank for a purchase or no basis. + a.built_in_gain_704c + .map(|g| (g < Decimal::ZERO).to_string()) + .unwrap_or_default(), + a.sec704c_method + .map(|m| m.key()) + .unwrap_or_default() + .to_string(), + csv_escape(&source(a).unwrap_or_default()), + ]; + csv.push_str(&cells.join(",")); + csv.push('\n'); + } + csv.push_str(&format!( + "TOTAL,,,,,{},,,{},{},{},,,,,,,{},{},,,,", + money(r.total_cost), + money(r.total_accumulated_scheduled), + money(r.total_accumulated_posted), + money(r.total_book_value), + money(r.total_tax_basis_723), + money(r.total_depreciable_basis) + )); + Ok(csv) + } + + fn export_general_ledger(&self, format: &str, p: &ExportParams) -> Result { + let store = self.lock()?; + let ledger = store.ledger(); + let (start, end) = self.statement_range(p.start.as_deref(), p.end.as_deref())?; + let gl = ledger_core::general_ledger(ledger, start, end).map_err(|e| e.to_string())?; + if format == "json" { + return serde_json::to_string_pretty(&serde_json::json!({ + "start": gl.start.to_string(), "end": gl.end.to_string(), + "accounts": gl.accounts.iter().map(|a| serde_json::json!({ + "code": a.code, "name": a.name, + "beginning_balance": money(a.beginning_balance), + "postings": a.postings.iter().map(|p| serde_json::json!({ + "date": p.date.to_string(), "entry_id": p.entry_id, + "description": p.description, "split": p.split, + "debit": money(p.debit), "credit": money(p.credit), + "balance": money(p.balance), + })).collect::>(), + "total_debits": money(a.total_debits), + "total_credits": money(a.total_credits), + "ending_balance": money(a.ending_balance), + })).collect::>(), + })) + .map_err(|e| e.to_string()); + } + let mut csv = String::from("account,date,entry,description,split,debit,credit,balance\n"); + for a in &gl.accounts { + let account = csv_escape(&format!("{} {}", a.code, a.name)); + csv.push_str(&format!( + "{account},,,Beginning balance,,,,{}\n", + money(a.beginning_balance) + )); + for p in &a.postings { + csv.push_str(&format!( + "{account},{},{},{},{},{},{},{}\n", + p.date, + p.entry_id, + csv_escape(&p.description), + csv_escape(&p.split), + money(p.debit), + money(p.credit), + money(p.balance) + )); + } + csv.push_str(&format!( + "{account},,,Total,,{},{},{}\n", + money(a.total_debits), + money(a.total_credits), + money(a.ending_balance) + )); + } + Ok(csv) + } + + fn export_tax_summary(&self, format: &str, p: &ExportParams) -> Result { + let store = self.lock()?; + let ledger = store.ledger(); + let (start, end) = self.statement_range(p.start.as_deref(), p.end.as_deref())?; + let ts = ledger_core::tax_summary(ledger, start, end, self.settings().company.tax_form) + .map_err(|e| e.to_string())?; + let line = |l: &ledger_core::TaxSummaryLine| { + serde_json::json!({ + "category": l.category, "label": l.label, "accounts": l.accounts, + "book": money(l.book), "tax": money(l.tax), + }) + }; + if format == "json" { + return serde_json::to_string_pretty(&serde_json::json!({ + "start": ts.start.to_string(), "end": ts.end.to_string(), + "form": ts.form.map(|f| f.label()), + "income": ts.income.iter().map(line).collect::>(), + "cost_of_goods": ts.cost_of_goods.iter().map(line).collect::>(), + "deductions": ts.deductions.iter().map(line).collect::>(), + "separately_stated": ts.separately_stated.iter().map(line).collect::>(), + // Book amounts only: Form 4797 figures the tax gain. + "form_4797": ts.form_4797.iter().map(|l| serde_json::json!({ + "category": l.category, "label": l.label, "accounts": l.accounts, + "book": money(l.book), + })).collect::>(), + "uncategorized": ts.uncategorized.iter().map(|u| serde_json::json!({ + "account": u.account, "book": money(u.book), + })).collect::>(), + "book_net": money(ts.book_net()), + "tax_net": money(ts.tax_net()), + })) + .map_err(|e| e.to_string()); + } + let mut csv = String::from("section,category,label,accounts,book,tax\n"); + for (section, lines) in [ + ("income", &ts.income), + ("cost_of_goods", &ts.cost_of_goods), + ("deductions", &ts.deductions), + ("separately_stated", &ts.separately_stated), + ] { + for l in lines { + csv.push_str(&format!( + "{section},{},{},{},{},{}\n", + l.category, + csv_escape(&l.label), + csv_escape(&l.accounts.join("; ")), + money(l.book), + money(l.tax) + )); + } + } + for l in &ts.form_4797 { + csv.push_str(&format!( + "form_4797,{},{},{},{},\n", + l.category, + csv_escape(&l.label), + csv_escape(&l.accounts.join("; ")), + money(l.book) + )); + } + for u in &ts.uncategorized { + csv.push_str(&format!( + "uncategorized,,,{},{},\n", + csv_escape(&u.account), + money(u.book) + )); + } + Ok(csv) + } + + fn export_members_equity(&self, format: &str, p: &ExportParams) -> Result { + let store = self.lock()?; + let ledger = store.ledger(); + let (start, end) = self.statement_range(p.start.as_deref(), p.end.as_deref())?; + let me = ledger_core::members_equity(ledger, start, end, &self.member_capitals(ledger)?) + .map_err(|e| e.to_string())?; + let col = |c: &ledger_core::MemberEquityColumn| { + serde_json::json!({ + "name": c.name, "beginning": money(c.beginning), + "contributions": money(c.contributions), "net_income": money(c.net_income), + "distributions": money(c.distributions), "other": money(c.other), + "ending": money(c.ending), + }) + }; + if format == "json" { + return serde_json::to_string_pretty(&serde_json::json!({ + "start": me.start.to_string(), "end": me.end.to_string(), + "members": me.members.iter().map(col).collect::>(), + "total": col(&me.total), + })) + .map_err(|e| e.to_string()); + } + let mut csv = + String::from("member,beginning,contributions,net_income,distributions,other,ending\n"); + for c in me.members.iter().chain(std::iter::once(&me.total)) { + csv.push_str(&format!( + "{},{},{},{},{},{},{}\n", + csv_escape(&c.name), + money(c.beginning), + money(c.contributions), + money(c.net_income), + money(c.distributions), + money(c.other), + money(c.ending) + )); + } + Ok(csv) + } + + fn export_pnl( + &self, + format: &str, + start: Option<&str>, + end: Option<&str>, + ) -> Result { + let store = self.lock()?; + let pl = self.pnl_statement(store.ledger(), start, end, None)?; + if format == "json" { + return serde_json::to_string_pretty(&serde_json::json!({ + "start": pl.start.to_string(), "end": pl.end.to_string(), + "revenue_accounts": lines_out(&pl.revenue), + "expense_accounts": lines_out(&pl.expenses), + "revenue": money(pl.total_revenue), "expenses": money(pl.total_expenses), + "net_income": money(pl.net_income()), + })) + .map_err(|e| e.to_string()); + } + let mut csv = String::from("section,code,name,amount\n"); + for (section, lines) in [("revenue", &pl.revenue), ("expenses", &pl.expenses)] { + for l in lines { + csv.push_str(&format!( + "{section},{},{},{}\n", + csv_escape(&l.code), + csv_escape(&l.name), + money(l.amount) + )); + } + } + csv.push_str(&format!( + "total,,revenue,{}\ntotal,,expenses,{}\ntotal,,net_income,{}\n", + money(pl.total_revenue), + money(pl.total_expenses), + money(pl.net_income()) + )); + Ok(csv) + } + + fn export_balance_sheet(&self, format: &str, as_of: Option<&str>) -> Result { + let store = self.lock()?; + let bs = self.bs_statement(store.ledger(), as_of)?; + if format == "json" { + return serde_json::to_string_pretty(&serde_json::json!({ + "as_of": bs.as_of.to_string(), + "asset_accounts": lines_out(&bs.assets), + "liability_accounts": lines_out(&bs.liabilities), + "equity_accounts": lines_out(&bs.equity), + "assets": money(bs.total_assets), "liabilities": money(bs.total_liabilities), + "equity": money(bs.total_equity), "current_earnings": money(bs.current_earnings), + "balanced": bs.balanced(), + })) + .map_err(|e| e.to_string()); + } + let mut csv = String::from("section,code,name,amount\n"); + for (section, lines) in [ + ("assets", &bs.assets), + ("liabilities", &bs.liabilities), + ("equity", &bs.equity), + ] { + for l in lines { + csv.push_str(&format!( + "{section},{},{},{}\n", + csv_escape(&l.code), + csv_escape(&l.name), + money(l.amount) + )); + } + } + csv.push_str(&format!( + "equity,,current_earnings,{}\ntotal,,assets,{}\ntotal,,liabilities,{}\ntotal,,equity,{}\n", + money(bs.current_earnings), + money(bs.total_assets), + money(bs.total_liabilities), + money(bs.total_equity) + )); + Ok(csv) + } + + fn export_cash_flow(&self, format: &str, p: &ExportParams) -> Result { + let store = self.lock()?; + let ledger = store.ledger(); + let stmt = self.cash_flow_for( + ledger, + p.start.as_deref(), + p.end.as_deref(), + &p.cash_accounts, + &p.financing_accounts, + &p.investing_accounts, + )?; + if format == "json" { + // The tool's own answer, from the one function (they can't + // drift apart). + return serde_json::to_string_pretty(&cash_flow_out(&stmt)).map_err(|e| e.to_string()); + } + Ok(cash_flow_csv(&stmt)) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::server::test_support::*; + use std::str::FromStr; + + /// A period is "YYYY-MM": a year of the calendar the books keep + /// (1-9999), month 1-12, nothing more; its last day is a date, never a + /// panic (P0 review round 3, S1: "999999-01" and "262142-12" once + /// stopped the server). + #[test] + fn periods_are_a_year_and_a_month() { + assert_eq!(parse_period_mcp("2026-01"), Ok((2026, 1))); + assert_eq!(parse_period_mcp("2026-12"), Ok((2026, 12))); + assert_eq!(parse_period_mcp("0001-01"), Ok((1, 1))); + assert_eq!(parse_period_mcp("9999-12"), Ok((9999, 12))); + for bad in [ + "2026-13", + "2026-00", + "2026-01-05", + "0-05", + "2026", + "x-01", + "2026-", + "", + "10000-01", + "999999-01", + "262142-12", + "262143-01", + "-5-01", + ] { + assert!(parse_period_mcp(bad).is_err(), "{bad:?}"); + } + assert_eq!(last_day_of_month(2028, 2), Ok(d(2028, 2, 29))); + assert_eq!(last_day_of_month(9999, 12), Ok(d(9999, 12, 31))); + assert_eq!(last_day_of_month(1, 1), Ok(d(1, 1, 31))); + for (y, m) in [(262142, 12), (999999, 1), (2026, 13), (2026, 0)] { + assert!(last_day_of_month(y, m).is_err(), "{y}-{m}"); + } + } + + /// Reports name each member with their accounts, in order. + #[test] + fn report_members_carry_names_and_accounts() { + let books = unit_dir("members"); + let server = served(settled(store(&books), &[members()])); + let m = server.report_members(); + assert_eq!(m.len(), 2); + assert_eq!( + (m[0].name.as_str(), m[0].capital_code.as_str()), + ("Ada", "3000") + ); + assert_eq!(m[0].distribution_code.as_deref(), Some("3100")); + assert_eq!( + (m[1].name.as_str(), m[1].capital_code.as_str()), + ("Bo", "3001") + ); + assert_eq!(m[1].distribution_code, None); + } + + /// The balance sheet's year earnings: net income for the fiscal year + /// to date, and the part not yet closed into equity (closing entries + /// included, so a closed month drops out). + #[test] + fn year_earnings_split_closed_from_unclosed() { + let books = unit_dir("earnings"); + let mut s = store(&books); + let cash = s + .add_account("1000", "Cash", AccountType::Asset, None, false) + .unwrap(); + let sales = s + .add_account("4000", "Sales", AccountType::Revenue, None, false) + .unwrap(); + let rent = s + .add_account("6000", "Rent", AccountType::Expense, None, false) + .unwrap(); + post(&mut s, d(2025, 12, 15), cash, sales, "999.00"); // last fiscal year + post(&mut s, d(2026, 1, 10), cash, sales, "1000.00"); + post(&mut s, d(2026, 1, 20), rent, cash, "300.00"); + post(&mut s, d(2026, 2, 5), cash, sales, "50.00"); + post(&mut s, d(2026, 2, 6), rent, cash, "20.00"); + let server = served(s); + let as_of = d(2026, 2, 28); + { + let store = server.lock().unwrap(); + let e = server.year_earnings(store.ledger(), as_of).unwrap(); + assert_eq!(e.net_income_ytd, Decimal::from_str("730.00").unwrap()); + assert_eq!(e.unclosed, Decimal::from_str("730.00").unwrap()); + } + { + let mut store = server.lock().unwrap(); + let jan = store + .create_period("2026-01", d(2026, 1, 1), d(2026, 1, 31)) + .unwrap(); + store.close_period(jan).unwrap(); + } + let store = server.lock().unwrap(); + let e = server.year_earnings(store.ledger(), as_of).unwrap(); + assert_eq!( + e.net_income_ytd, + Decimal::from_str("730.00").unwrap(), + "still the year's income" + ); + assert_eq!( + e.unclosed, + Decimal::from_str("30.00").unwrap(), + "January's 700 is closed" + ); + } + + /// Only a closed period moves where the unclosed part starts: an open + /// one dated before as_of doesn't, and a close that ended before the + /// fiscal year began leaves the year starting on its first day (last + /// December's income is never this year's). + #[test] + fn year_earnings_count_only_closes_and_never_before_the_year() { + let books = unit_dir("earnings-open"); + let mut s = store(&books); + let cash = s + .add_account("1000", "Cash", AccountType::Asset, None, false) + .unwrap(); + let sales = s + .add_account("4000", "Sales", AccountType::Revenue, None, false) + .unwrap(); + post(&mut s, d(2025, 12, 15), cash, sales, "999.00"); + post(&mut s, d(2026, 1, 10), cash, sales, "1000.00"); + s.create_period("2026-01", d(2026, 1, 1), d(2026, 1, 31)) + .unwrap(); + let server = served(s); + let as_of = d(2026, 2, 28); + let thousand = Decimal::from_str("1000.00").unwrap(); + { + let store = server.lock().unwrap(); + let e = server.year_earnings(store.ledger(), as_of).unwrap(); + assert_eq!(e.unclosed, thousand, "an open January closes nothing"); + } + { + let mut store = server.lock().unwrap(); + let to_nov = store + .create_period("2025-01..11", d(2025, 1, 1), d(2025, 11, 30)) + .unwrap(); + store.close_period(to_nov).unwrap(); + } + let store = server.lock().unwrap(); + let e = server.year_earnings(store.ledger(), as_of).unwrap(); + assert_eq!(e.net_income_ytd, thousand); + assert_eq!(e.unclosed, thousand, "December 2025 is not this year's"); + } + + /// books.show (defect 15): how the viewer reads each report's dates, + /// and the options a report ignores, refused (one match, so each arm + /// is pinned). + #[test] + fn show_reads_each_reports_dates_as_the_viewer_does() { + for (report, columns, compare, want) in [ + ("profit_and_loss", None, None, Ok(ShownDates::Period)), + ("cash_flow", Some("month"), None, Ok(ShownDates::Period)), + ("members_equity", None, None, Ok(ShownDates::Period)), + ("general_ledger", None, None, Ok(ShownDates::Period)), + ( + "tax_summary", + None, + Some("prior_year"), + Ok(ShownDates::Period), + ), + ("trial_balance", None, None, Ok(ShownDates::AsOf)), + ("ar_aging", None, None, Ok(ShownDates::AsOf)), + ("ap_aging", None, None, Ok(ShownDates::AsOf)), + ("fixed_asset_register", None, None, Ok(ShownDates::AsOf)), + ("vendor_1099", None, None, Ok(ShownDates::Year)), + ("balance_sheet", None, None, Ok(ShownDates::AsOf)), + ("balance_sheet", Some("total"), None, Ok(ShownDates::AsOf)), + ( + "balance_sheet", + None, + Some("prior_year"), + Ok(ShownDates::AsOf), + ), + ( + "balance_sheet", + None, + Some("prior_period"), + Ok(ShownDates::Period), + ), + ( + "balance_sheet", + Some("quarter"), + None, + Ok(ShownDates::Period), + ), + ("trial_balance", Some("total"), None, Err("columns")), + ("tax_summary", Some("month"), None, Err("columns")), + ("vendor_1099", None, Some("prior_year"), Err("compare")), + ("general_ledger", None, Some("prior_period"), Err("compare")), + ( + "profit_and_loss", + Some("year"), + Some("prior_year"), + Err("split"), + ), + ] { + let got = show_report_dates(report, columns, compare); + match want { + Ok(dates) => assert_eq!(got, Ok(dates), "{report} {columns:?} {compare:?}"), + Err(word) => { + let e = got.unwrap_err(); + assert!(e.contains(word), "{report} {columns:?} {compare:?}: {e}"); + } + } + } + } + + /// A bare books://digest, or one with an empty since=, covers this + /// month so far: since is the 1st of today's month, on any day of it + /// (not today, which only agrees on the 1st). A given since is kept. + #[test] + fn a_bare_digest_covers_this_month_so_far() { + for (day, want) in [(16, "2026-10-01"), (1, "2026-10-01"), (31, "2026-10-01")] { + let t = d(2026, 10, day); + for q in ["", "since=", "since=%20", "x=1", "x=1&since="] { + let q = q.replace("%20", " "); + assert_eq!(digest_since(&q, t), want, "{q:?} on {t}"); + } + } + assert_eq!( + digest_since("since=2026-01-05", d(2026, 10, 16)), + "2026-01-05" + ); + assert_eq!( + digest_since("x=1&since=2026-01-05", d(2026, 10, 16)), + "2026-01-05" + ); + assert_eq!(digest_since("", d(2027, 1, 9)), "2027-01-01"); + } +} diff --git a/crates/ledger-mcp/src/tools/schedules.rs b/crates/ledger-mcp/src/tools/schedules.rs new file mode 100644 index 0000000..4205d17 --- /dev/null +++ b/crates/ledger-mcp/src/tools/schedules.rs @@ -0,0 +1,346 @@ +//! Prepaid schedules and recurring entries through the server: create, +//! list and run each. Split whole out of `server.rs` (plan/served.md 8.2 +//! U0a). + +use super::*; + +fn schedule_out(ledger: &Ledger, s: &ledger_core::AmortizationSchedule) -> ScheduleOut { + // A schedule an earlier version journaled may run past the last date + // chrono holds (rules.md B-28): shown, with why its plan can't be. + let plan = s.plan(); + let code = |id| { + ledger + .chart() + .get(id) + .map(|a| a.code.clone()) + .unwrap_or_default() + }; + ScheduleOut { + schedule_id: s.id, + description: s.description.clone(), + kind: match s.kind { + ledger_core::ScheduleKind::PrepaidExpense => "prepaid_expense", + ledger_core::ScheduleKind::DeferredRevenue => "deferred_revenue", + } + .to_string(), + balance_account: code(s.balance_account), + pl_account: code(s.pl_account), + total: money(s.total.amount), + convention: s.convention.key().to_string(), + start: s.first_day().to_string(), + end: s.last_day().map(|d| d.to_string()), + next: s.next().ok().flatten().map(|d| d.to_string()), + remaining: plan.as_ref().ok().and(s.remaining().ok()).map(money), + months: plan + .as_ref() + .map(|plan| { + plan.iter() + .map(|(d, a)| ScheduleMonthOut { + date: d.to_string(), + amount: money(*a), + posted: s.posted.contains(d), + }) + .collect() + }) + .unwrap_or_default(), + // A null remaining is always explained (ScheduleOut::plan_error). + plan_error: plan + .err() + .or_else(|| s.remaining().err()) + .map(|e| e.to_string()), + } +} + +fn recurring_out(r: &ledger_core::RecurringEntry) -> RecurringOut { + RecurringOut { + recurring_id: r.id, + description: r.description.clone(), + frequency: format!("{:?}", r.frequency).to_lowercase(), + start: r.start.to_string(), + end: r.end.map(|e| e.to_string()), + generated: r.generated.iter().map(|d| d.to_string()).collect(), + } +} + +#[tool_router(router = schedules_router, vis = "pub(super)")] +impl BooksServer { + #[tool( + name = "books.create_schedule", + title = "Create an amortization schedule", + description = "Spread a prepaid expense (an asset used up as time passes, e.g. a year of insurance paid up front) or deferred revenue (a liability earned as time passes, e.g. an annual subscription collected up front) over the time it covers, to the cent. convention: daily (by the day from start: a mid-month start posts a small first and last month, one month end more than its months) or monthly_even (equal months; starts on the 1st); omitted, the schedule_convention setting, else daily. Record the original payment first (Dr prepaid / Cr cash, with its receipt; or Dr cash / Cr deferred revenue). Returns the plan; post it with books.run_schedules.", + annotations( + read_only_hint = false, + destructive_hint = false, + open_world_hint = false + ) + )] + async fn create_schedule( + &self, + params: Parameters, + ) -> Result, String> { + let p = params.0; + let mut store = self.lock()?; + let kind = match p.kind.as_str() { + "prepaid_expense" => ledger_core::ScheduleKind::PrepaidExpense, + "deferred_revenue" => ledger_core::ScheduleKind::DeferredRevenue, + other => { + return Err(format!( + "kind {other:?}: expected prepaid_expense or deferred_revenue" + )); + } + }; + // The books' setting, else daily (D-11, D-37). + let convention = match p.convention.as_deref() { + None => store.ledger().settings().schedule_convention(), + Some(c) => ledger_core::ScheduleConvention::parse(c) + .ok_or_else(|| format!("convention {c:?}: expected daily or monthly_even"))?, + }; + let currency = store.ledger().base_currency(); + let balance_account = resolve_account(&store, &p.balance_account)?; + let pl_account = resolve_account(&store, &p.pl_account)?; + let id = store + .register_schedule(ledger_core::NewSchedule { + description: p.description, + kind, + total: Money::new(parse_amount(&p.total)?, currency), + start: parse_date(&p.start)?, + months: p.months, + balance_account, + pl_account, + convention, + }) + .map_err(|e| e.to_string())?; + Ok(Json(schedule_out( + store.ledger(), + store.ledger().get_schedule(id).expect("registered"), + ))) + } + + #[tool( + name = "books.list_schedules", + title = "List amortization schedules", + description = "Amortization schedules (prepaid expenses, deferred revenue): each one's convention (daily or monthly_even), the first and last day it covers, each month's amount, what has posted, the next month to post, and what remains.", + annotations(read_only_hint = true, open_world_hint = false) + )] + async fn list_schedules(&self) -> Result, String> { + let store = self.lock()?; + let ledger = store.ledger(); + Ok(Json(ListSchedulesOut { + schedules: ledger + .schedules() + .map(|s| schedule_out(ledger, s)) + .collect(), + })) + } + + #[tool( + name = "books.run_schedules", + title = "Post scheduled amortization", + description = "Post every amortization schedule's months ending on or before `through` (default today) that haven't posted. Idempotent: a month posts once; a month reversed in its own month is due again. A month in a closed period, or reversed after it ended, is listed under skipped, not posted. Without confirmed=true, returns the postings it would make. Exempt from the receipt policy: the schedule is the evidence (the original payment carries the receipt).", + annotations( + read_only_hint = false, + destructive_hint = true, + idempotent_hint = true, + open_world_hint = false + ) + )] + async fn run_schedules( + &self, + params: Parameters, + ) -> Result, String> { + let p = params.0; + let through = match &p.through { + Some(s) => parse_date(s)?, + None => today(), + }; + let mut store = self.lock()?; + // A schedule whose plan can't be computed refuses the run, naming + // it, before any month posts. + let mut due: Vec<(NaiveDate, u64, Decimal)> = Vec::new(); + for s in store.ledger().schedules() { + let months = s + .due(through) + .map_err(|e| format!("schedule {}: {e}", s.id))?; + due.extend(months.into_iter().map(|(d, a)| (d, s.id, a))); + } + due.sort(); + // A month dated in a closed period can't post, nor one whose + // posting was reversed after the month ended (it would count twice + // until then): left for a human, and said, never a failed run. + let (held, due): (Vec<_>, Vec<_>) = due + .into_iter() + .partition(|(d, id, _)| store.ledger().schedule_month_hold(*id, *d).is_some()); + let skipped: Vec = held + .into_iter() + .map(|(d, id, _)| ScheduleSkippedOut { + schedule_id: id, + date: d.to_string(), + reason: store + .ledger() + .schedule_month_hold(id, d) + .unwrap_or_default(), + }) + .collect(); + let describe = |store: &Store, id: u64, date: NaiveDate| -> Result { + Ok(store + .ledger() + .preview_schedule_month(id, date) + .map_err(|e| e.to_string())? + .1) + }; + if !p.confirmed { + let postings = due + .into_iter() + .map(|(date, id, amount)| { + Ok(SchedulePostingOut { + schedule_id: id, + date: date.to_string(), + description: describe(&store, id, date)?, + amount: money(amount), + entry_id: None, + }) + }) + .collect::>()?; + return Ok(Json(RunSchedulesOut { + status: "preview".to_string(), + postings, + skipped, + })); + } + let mut postings = Vec::new(); + for (date, id, amount) in due { + let description = describe(&store, id, date)?; + let entry = store + .post_schedule_month(id, date) + .map_err(|e| e.to_string())?; + postings.push(SchedulePostingOut { + schedule_id: id, + date: date.to_string(), + description, + amount: money(amount), + entry_id: Some(entry), + }); + } + Ok(Json(RunSchedulesOut { + status: "posted".to_string(), + postings, + skipped, + })) + } + + #[tool( + name = "books.create_recurring", + title = "Create a recurring entry", + description = "A recurring entry (rent, a subscription, a loan payment): balanced lines and a frequency (weekly, monthly, quarterly, yearly) from a start date, optionally ending. books.run_recurring turns due occurrences into drafts, never posted entries: attach each actual receipt and post through the normal preview.", + annotations( + read_only_hint = false, + destructive_hint = false, + open_world_hint = false + ) + )] + async fn create_recurring( + &self, + params: Parameters, + ) -> Result, String> { + let p = params.0; + let mut store = self.lock()?; + let currency = store.ledger().base_currency(); + let frequency = ledger_core::Frequency::parse(&p.frequency).ok_or_else(|| { + format!( + "frequency {:?}: expected weekly, monthly, quarterly, or yearly", + p.frequency + ) + })?; + let lines = p + .lines + .iter() + .map(|l| { + Ok(( + resolve_account(&store, &l.account)?, + parse_side(&l.side)?, + Money::new(parse_amount(&l.amount)?, currency), + )) + }) + .collect::, String>>()?; + let id = store + .register_recurring(ledger_core::NewRecurring { + description: p.description, + lines, + frequency, + start: parse_date(&p.start)?, + end: match &p.end { + Some(e) => Some(parse_date(e)?), + None => None, + }, + }) + .map_err(|e| e.to_string())?; + let r = store + .ledger() + .recurring_entries() + .find(|r| r.id == id) + .expect("registered"); + Ok(Json(recurring_out(r))) + } + + #[tool( + name = "books.list_recurring", + title = "List recurring entries", + description = "Recurring entries and the occurrences generated so far.", + annotations(read_only_hint = true, open_world_hint = false) + )] + async fn list_recurring(&self) -> Result, String> { + let store = self.lock()?; + Ok(Json(ListRecurringOut { + recurring: store + .ledger() + .recurring_entries() + .map(recurring_out) + .collect(), + })) + } + + #[tool( + name = "books.run_recurring", + title = "Draft recurring entries", + description = "Turn every recurring entry's occurrences on or before `through` (default today) into drafts. Idempotent: an occurrence generates once. Occurrences dated in a closed period are not generated; they come back in `skipped_closed_period` for the human to decide. Each draft then goes through the normal flow: attach the receipt, preview, post.", + annotations( + read_only_hint = false, + destructive_hint = true, + idempotent_hint = true, + open_world_hint = false + ) + )] + async fn run_recurring( + &self, + params: Parameters, + ) -> Result, String> { + let through = match ¶ms.0.through { + Some(s) => parse_date(s)?, + None => today(), + }; + let mut store = self.lock()?; + let drafts = store + .run_recurring(through) + .map_err(|e| e.to_string())? + .into_iter() + .map(|(id, date, entry)| RecurringDraftOut { + recurring_id: id, + date: date.to_string(), + entry_id: entry, + }) + .collect(); + let skipped_closed_period = store + .ledger() + .recurring_in_closed_periods(through) + .into_iter() + .map(|(id, date)| RecurringSkippedOut { + recurring_id: id, + date: date.to_string(), + }) + .collect(); + Ok(Json(RunRecurringOut { + drafts, + skipped_closed_period, + })) + } +} diff --git a/crates/ledger-mcp/src/tools/settings.rs b/crates/ledger-mcp/src/tools/settings.rs new file mode 100644 index 0000000..a7241b1 --- /dev/null +++ b/crates/ledger-mcp/src/tools/settings.rs @@ -0,0 +1,177 @@ +//! The books' own settings and guidance through the server: +//! `books.handbook`, `books.settings` and the recorded FX rates. Split +//! whole out of `server.rs` (plan/served.md 8.2 U0a). + +use super::*; + +#[tool_router(router = settings_router, vis = "pub(super)")] +impl BooksServer { + #[tool( + name = "books.handbook", + title = "Bookkeeping handbook", + description = "The guide to keeping these books: every procedure and rule, by section. Read the contract section first, then each section you need, before bookkeeping. Without section: the index; with section: that section's text (also at books://handbook/{id}); section \"all\": the whole handbook.", + annotations(read_only_hint = true, open_world_hint = false) + )] + pub(super) async fn handbook( + &self, + params: Parameters, + ) -> Result, String> { + let settings = self.settings(); + let declared = self.document_line_projects(); + let sections = handbook::SECTIONS + .iter() + .map(|(id, _)| { + let text = + handbook::section(id, &settings, self.currency(), declared).unwrap_or_default(); + HandbookSectionOut { + id: id.to_string(), + title: handbook::title(&text).to_string(), + uri: format!("{}/{id}", handbook::URI), + chars: text.chars().count(), + } + }) + .collect(); + let section = match params.0.section.as_deref().map(str::trim) { + None => None, + Some(id) => { + let text = handbook::section(id, &settings, self.currency(), declared).ok_or_else(|| { + let ids: Vec<&str> = handbook::SECTIONS.iter().map(|(id, _)| *id).collect(); + format!( + "no handbook section {id:?}: the sections are {}, or \"{}\" for all of them", + ids.join(", "), + handbook::ALL + ) + })?; + let title = if id == handbook::ALL { + "The whole handbook".to_string() + } else { + handbook::title(&text).to_string() + }; + Some(HandbookTextOut { + id: id.to_string(), + title, + text, + }) + } + }; + Ok(Json(HandbookOut { + read_first: "Read the contract section first: the rules every entry follows. Then each section you need, or section \"all\" for the whole handbook.".to_string(), + sections, + section, + })) + } + + #[tool( + name = "books.settings", + title = "Settings in force", + description = "The settings in force (company, fiscal year, tax form, members, basis, receipt rule with every account it requires or exempts, cash flow roles, close target, time zone, approval rules), every key the books know with its value and who set it (change them with books.set_setting), and problems: settings or conventions that disagree with the chart, each saying what to change.", + annotations(read_only_hint = true, open_world_hint = false) + )] + pub(super) async fn settings_tool(&self) -> Result, String> { + // The books first: their settings sync as they lock, so the + // settings read after are theirs. + let store = self.lock()?; + let cfg = self.settings(); + let c = &cfg.company; + Ok(Json(SettingsOut { + timezone: cfg.timezone.clone(), + today: today().to_string(), + approval_rules: match store.ledger().settings().approval_rules() { + ledger_core::settings::ApprovalMode::Off => "off", + ledger_core::settings::ApprovalMode::Enforce => "enforce", + } + .to_string(), + keys: kept::keys_out(&store.ledger().settings().clone()), + company: c.name.clone(), + fiscal_year_start: format!("{:02}-{:02}", c.fiscal_year_start.0, c.fiscal_year_start.1), + tax_form: c.tax_form.map(|f| f.label().to_string()), + basis: c.basis.clone(), + receipt_rule: cfg.policy.rule(self.currency()), + receipt_required_accounts: cfg.policy.receipt_required_accounts.clone(), + receipt_exempt_accounts: cfg.policy.receipt_exempt_accounts.clone(), + parties: cfg.parties.key().to_string(), + members: c + .members + .iter() + .map(|m| MemberSettingOut { + name: m.name.clone(), + share: m.share.normalize().to_string(), + capital_account: m.capital_account.clone(), + distribution_account: m.distribution_account.clone(), + files_jointly_with: m.files_jointly_with.clone(), + }) + .collect(), + cash_accounts: cfg.roles.cash.clone(), + financing_accounts: cfg.roles.financing.clone(), + investing_accounts: cfg.roles.investing.clone(), + close_into: c.close_into.clone(), + close_sweep: c.close_sweep.clone(), + problems: settings_problems(store.ledger(), &cfg), + })) + } + + #[tool( + name = "books.set_fx_rate", + title = "Set an exchange rate", + description = "Record a historical FX rate: how much base currency one unit of the foreign currency buys on the given date. For reference; lines store their own rate at entry time.", + annotations( + read_only_hint = false, + destructive_hint = false, + open_world_hint = false + ) + )] + async fn set_fx_rate( + &self, + params: Parameters, + ) -> Result, String> { + let p = params.0; + let mut store = self.lock()?; + let currency = Currency::new(&p.currency).map_err(|e| e.to_string())?; + store + .set_fx_rate(currency, parse_date(&p.date)?, parse_amount(&p.rate)?) + .map_err(|e| e.to_string())?; + Ok(Json(SetFxRateOut { ok: true })) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::server::test_support::*; + + /// A client that connects after the settings changed is told the rule + /// now in force (plan P0): the connect text is composed from the + /// books' settings as they are at initialize, not as they were when + /// the server started. + #[test] + fn a_new_session_is_told_the_settings_in_force() { + let books = unit_dir("info"); + let server = served(store(&books)); + let told = |s: &BooksServer| rmcp::ServerHandler::get_info(s).instructions.unwrap(); + let before = told(&server); + assert!(before.contains("more than 25.00 USD in debits"), "{before}"); + { + let mut store = server.lock().unwrap(); + store + .change_settings( + &[ledger_core::settings::Change { + key: "policy.evidence.threshold".into(), + value: Some(serde_json::json!("0.00")), + }], + &ledger_core::settings::Source { + decided_by: "Test Owner".into(), + decided_on: d(2026, 10, 23), + citation: None, + note: None, + }, + ) + .unwrap(); + } + let after = told(&server); + assert!( + after.contains("every entry needs at least one receipt"), + "{after}" + ); + assert!(!after.contains("25.00"), "{after}"); + } +} diff --git a/crates/ledger-mcp/src/tools/tax.rs b/crates/ledger-mcp/src/tools/tax.rs new file mode 100644 index 0000000..76e3aa5 --- /dev/null +++ b/crates/ledger-mcp/src/tools/tax.rs @@ -0,0 +1,75 @@ +//! Tax categories through the server: `books.tax_categories` and +//! `books.set_tax_category`. Split whole out of `server.rs` +//! (plan/served.md 8.2 U0a). + +use super::*; + +#[tool_router(router = tax_router, vis = "pub(super)")] +impl BooksServer { + #[tool( + name = "books.tax_categories", + title = "Tax categories", + description = "The tax categories an account can map to (where it lands on the income tax return), filtered to the business's return when the settings say which (tax_form or tax_classification). Each has a default deductible percent (meals 50, nondeductible 0) and guidance. Categories are stable keys, not form line numbers.", + annotations(read_only_hint = true, open_world_hint = false) + )] + async fn tax_categories(&self) -> Result, String> { + let form = self.settings().company.tax_form; + let categories = ledger_core::tax::CATEGORIES + .iter() + .filter(|c| form.is_none_or(|f| c.forms.contains(&f))) + .map(|c| TaxCategoryOut { + key: c.key.to_string(), + label: c.label.to_string(), + kind: match c.kind { + ledger_core::TaxKind::Income => "income", + ledger_core::TaxKind::CostOfGoods => "cost_of_goods", + ledger_core::TaxKind::Deduction => "deduction", + } + .to_string(), + default_deductible_percent: c.default_deductible_percent, + note: c.note.to_string(), + }) + .collect(); + Ok(Json(TaxCategoriesOut { + form: form.map(|f| f.label().to_string()), + categories, + })) + } + + #[tool( + name = "books.set_tax_category", + title = "Set an account's tax category", + description = "Map a revenue or expense account to a tax category (see books.tax_categories), optionally overriding its deductible percent; category null clears it (refused under policy.require_tax_category = refuse, for a revenue or expense account that isn't a header). Drives the tax_summary report.", + annotations( + read_only_hint = false, + destructive_hint = false, + open_world_hint = false + ) + )] + async fn set_tax_category( + &self, + params: Parameters, + ) -> Result, String> { + let p = params.0; + let mut store = self.lock()?; + let id = resolve_account(&store, &p.account)?; + let pct = match &p.deductible_percent { + Some(s) => Some(parse_amount(s)?), + None => None, + }; + if let Some(cat) = &p.category { + let t = store + .ledger() + .chart() + .get(id) + .expect("resolved") + .account_type; + ledger_core::tax::check_category(cat, t, self.settings().company.tax_form) + .map_err(|e| e.to_string())?; + } + store + .set_account_tax(id, p.category.as_deref(), pct) + .map_err(|e| e.to_string())?; + Ok(Json(SetTaxCategoryOut { ok: true })) + } +}