From 1771d87e0e839a87c8722752947ec00df35e62b1 Mon Sep 17 00:00:00 2001 From: Stephen Waits Date: Thu, 15 Oct 2026 10:30:16 -0600 Subject: [PATCH] Control roles in the journal (books.assign_role, books.roles); the MCP reloads wanderbooks.toml on change and reports settings problems (books.settings, digest); new writes a full commented wanderbooks.toml - core: ControlAccount keys, Ledger::assign_role/check_assign_role/role_account (rules.md B-23): one account per role, of the role's type, balance sheet roles move only when empty; invoices, bills, payroll, remittance, and closes post to the assigned account; an assigned role frees its default code for renumbering - store: role.assigned journaled and replayed (strict account_id) - mcp: settings.rs reloads by content at the start of each request, keeps the last good settings on a broken edit; books.settings with chart problems; digest settings_problems; instructions say when to edit the toml - viewer: journal text for role.assigned - cli: full example wanderbooks.toml, tested to load with every example uncommented - tests: spec_roles.rs (25, spec-blind), store roles.rs (6), MCP reload/problems/roles, settings unit tests Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01GWVo7x3UHw5kwCUaWxiejX --- Cargo.lock | 2 + crates/ledger-cli/Cargo.toml | 2 + crates/ledger-cli/src/main.rs | 79 +- crates/ledger-cli/tests/init.rs | 162 ++++ crates/ledger-core/src/error.rs | 42 +- crates/ledger-core/src/ledger.rs | 181 +++- crates/ledger-core/src/lib.rs | 2 +- crates/ledger-core/tests/spec_roles.rs | 1203 ++++++++++++++++++++++++ crates/ledger-gui/src/pages/journal.rs | 22 + crates/ledger-mcp/src/main.rs | 49 +- crates/ledger-mcp/src/server.rs | 367 ++++++-- crates/ledger-mcp/src/settings.rs | 283 ++++++ crates/ledger-mcp/src/types.rs | 102 +- crates/ledger-mcp/tests/mcp_stdio.rs | 4 +- crates/ledger-mcp/tests/spec_mcp.rs | 209 ++++ crates/ledger-store/src/store.rs | 39 +- crates/ledger-store/tests/roles.rs | 366 +++++++ docs/mcp-interface.md | 36 +- docs/spec/rules.md | 26 + 19 files changed, 3044 insertions(+), 132 deletions(-) create mode 100644 crates/ledger-core/tests/spec_roles.rs create mode 100644 crates/ledger-mcp/src/settings.rs create mode 100644 crates/ledger-store/tests/roles.rs diff --git a/Cargo.lock b/Cargo.lock index d81ebc4..d9fad58 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3597,6 +3597,8 @@ checksum = "20870f649af7073d53e38067b2a84312175d56ea15217e1b15bc83506ec50afb" name = "ledger-cli" version = "0.1.0" dependencies = [ + "ledger-config", + "ledger-core", "serde_json", "tempfile", "toml 1.1.8+spec-1.1.0", diff --git a/crates/ledger-cli/Cargo.toml b/crates/ledger-cli/Cargo.toml index 7a0fcd5..11cd89b 100644 --- a/crates/ledger-cli/Cargo.toml +++ b/crates/ledger-cli/Cargo.toml @@ -11,6 +11,8 @@ name = "wanderbooks" path = "src/main.rs" [dev-dependencies] +ledger-config = { path = "../ledger-config" } +ledger-core = { path = "../ledger-core" } serde_json = "1" toml = "1" tempfile = "3" diff --git a/crates/ledger-cli/src/main.rs b/crates/ledger-cli/src/main.rs index 574e52b..2f9a788 100644 --- a/crates/ledger-cli/src/main.rs +++ b/crates/ledger-cli/src/main.rs @@ -143,7 +143,12 @@ anything. Keep it conversational; ask a few things at a time. tax form, close target) in `wanderbooks.toml`. 3. Fill in `wanderbooks.toml` `[company]`: name, entity type, tax classification, formation state and date, EIN, address. Don't store - SSNs or full account numbers anywhere in this directory. + SSNs or full account numbers anywhere in this directory. The server + reloads the file on every change: check `books.settings` after each + edit (no `reload_error`, no `problems`). + If the imported chart numbers receivables, payables, revenue, payroll, + or retained earnings differently from the defaults (`books.roles`), + assign those roles to its accounts with `books.assign_role`. 4. Record opening balances as one balanced opening entry (or the owners' first contributions). Verify the trial balance before moving on. 5. File the formation documents they have (articles, EIN letter, @@ -231,16 +236,86 @@ const CLAUDE_MD: &str = "@AGENTS.md\n"; const WANDERBOOKS_TOML: &str = r#"# wanderbooks books dir config. [company] is filled in during onboarding — # the agent asks, the human answers, the agent writes it here. +# +# Every setting is optional. A commented-out line is an example to fill in +# (uncomment it); a live line is the default. The MCP server and the +# viewer reload this file when it changes: no restart needed. [company] -name = "" +# Legal name, for report headers. +# name = "Acme Widgets LLC" +# Shown in the profile. The journal's currency is fixed when the books are +# created (books-data/config.json base_currency, default USD). base_currency = "USD" +# "MM-01": a fiscal year starts on the first of a month and ends the day +# before the next start (it ends on the last day of a month: IRS Pub. 538). fiscal_year_start = "01-01" +# The return the business files: "schedule_c", "1065", "1120s", or "1120". +# tax_form = "1065" +# Or the federal classification, and the return follows from it: +# "partnership", "S corporation", "C corporation", or "disregarded entity" +# (Treas. Reg. §301.7701-3). tax_form wins when both are set. +# tax_classification = "partnership" +# Identity facts the CPA needs, each from a primary document (EIN letter, +# certificate of organization). Any simple key = value line here shows in +# the viewer's profile, labelled from its key. +# entity_type = "LLC, multi-member, member-managed" +# formation_state = "UT" +# formed = "2026-01-15" +# ein = "00-0000000" +# address = "123 Main St, Salt Lake City, UT 84101" +# Never store owners' SSNs (or any TIN) in this directory: its git history +# keeps everything. The CPA collects them through their own channel. + +# Members of a multi-member LLC, one block each: shares come from the +# operating agreement (IRC §704(a)), "50%" or "0.5", summing to 100%. Each +# names a capital account in the chart, and optionally a distributions +# account that a close zeroes into capital. +# [[company.members]] +# name = "Alex Rivera" +# share = "50%" +# capital_account = "3000" +# distribution_account = "3100" +# +# [[company.members]] +# name = "Sam Chen" +# share = "50%" +# capital_account = "3001" +# distribution_account = "3101" [books] +# The books' data, relative to this file. The tools take it from --data-dir +# (the justfile, .mcp.json), not from here: move them together. data_dir = "books-data" +# "accrual" or "cash", for report footers. Use the same method from year +# to year (IRS Pub. 538). accounting_basis = "accrual" +# An entry whose debits are strictly over this needs a receipt; "0.00" +# means every entry (IRS Pub. 583: keep the documents that support your +# books). A receipt_policy in books-data/config.json overrides it. receipt_threshold = "25.00" +# Account codes that hold cash (bank, savings, cash on hand), for the cash +# flow statement and dashboards. Unset: cash accounts are found by name. +# cash_accounts = ["1000", "1010"] +# Liabilities the cash flow statement treats as financing: loans, and what +# owners advanced by paying company expenses (ASC 230-10-45-14(b)). +# financing_accounts = ["2100"] +# Assets it treats as investing: loans the company makes, investments it +# buys (ASC 230-10-20). Registered fixed assets need not be listed. +# investing_accounts = ["1400"] +# Single owner only (with [[company.members]] a close goes to each +# member's capital): the equity account a period close puts net income in. +# Unset: Retained Earnings (3999). +# close_into = "3000" +# Equity accounts a close zeroes into close_into (or Retained Earnings): +# draws, distributions, dividends. +# close_sweep = ["3100"] + +# Which accounts invoices, bills, payroll, and closes post to (receivables, +# payables, revenue, payroll, retained earnings) is not set here: those +# roles are part of the books, journaled with books.assign_role and listed +# by books.roles. Until assigned, each uses its default code (1200, 2000, +# 4000, 6999, 3999, 6100, 6110, 2110-2150). [viewer] # When a session starts and the viewer isn't open (`just gui-status`), the diff --git a/crates/ledger-cli/tests/init.rs b/crates/ledger-cli/tests/init.rs index e6a7426..cb79dff 100644 --- a/crates/ledger-cli/tests/init.rs +++ b/crates/ledger-cli/tests/init.rs @@ -230,3 +230,165 @@ fn codex_gets_its_own_named_server() { assert!(just.contains("codex mcp add \"wanderbooks-acme-books\"")); assert!(out.contains("just codex"), "{out}"); } + +/// The `wanderbooks.toml` a fresh `new` writes. +fn scaffolded_toml(tmp: &Path) -> (PathBuf, String) { + let books = tmp.join("b"); + assert!(new(tmp, &books, &[]).0); + let path = books.join("wanderbooks.toml"); + let text = fs::read_to_string(&path).unwrap(); + (path, text) +} + +/// The example a commented line holds, if it is one: `# key = value`, or +/// `# [section]` / `# [[section]]`. Prose comments are not examples. +fn example(line: &str) -> Option<&str> { + let rest = line.strip_prefix("# ")?; + let is_key = |k: &str| !k.is_empty() && k.bytes().all(|b| b.is_ascii_lowercase() || b == b'_'); + let header = rest + .trim_start_matches('[') + .trim_end_matches(']') + .split('.') + .all(is_key); + let assignment = rest + .split_once(" = ") + .is_some_and(|(k, v)| is_key(k) && !v.trim().is_empty()); + ((rest.starts_with('[') && rest.ends_with(']') && header) || assignment).then_some(rest) +} + +/// The template with the examples `pick` chooses uncommented. +fn uncomment(text: &str, pick: impl Fn(&str) -> bool) -> String { + text.lines() + .map(|l| match example(l) { + Some(ex) if pick(ex) => ex, + _ => l, + }) + .collect::>() + .join("\n") +} + +#[test] +fn the_scaffolded_toml_loads_with_the_defaults() { + let tmp = tempfile::tempdir().unwrap(); + let (path, text) = scaffolded_toml(tmp.path()); + let s = ledger_config::load(&path).expect("scaffolded wanderbooks.toml loads"); + let c = &s.company; + assert_eq!(c.name, "Unnamed company", "name is left for onboarding"); + assert_eq!(c.fiscal_year_start, (1, 1)); + assert_eq!(c.basis, "Accrual"); + assert!(c.members.is_empty()); + assert_eq!(c.tax_form, None); + assert_eq!(c.close_into, None); + assert!(c.close_sweep.is_empty()); + assert_eq!( + c.profile, + [ + ("base_currency".to_string(), "USD".to_string()), + ("fiscal_year_start".to_string(), "01-01".to_string()), + ] + ); + assert_eq!(s.receipt_threshold.as_deref(), Some("25.00")); + assert!(s.cash_accounts.is_empty()); + assert!(s.financing_accounts.is_empty()); + assert!(s.investing_accounts.is_empty()); + let toml: toml::Value = toml::from_str(&text).unwrap(); + assert_eq!(toml["books"]["data_dir"].as_str(), Some("books-data")); + // The justfile's session-start reads this line with sed, from column 0. + assert!( + text.lines().any(|l| l == "open_at_start = \"ask\""), + "{text}" + ); + assert!(text.contains("SSN"), "the template warns off storing SSNs"); +} + +#[test] +fn every_example_in_the_toml_is_valid() { + let tmp = tempfile::tempdir().unwrap(); + let (_, text) = scaffolded_toml(tmp.path()); + let examples = text.lines().filter_map(example).count(); + assert!(examples >= 20, "only {examples} examples found"); + // close_into is for a single owner and refused with members, so it + // gets a pass of its own; together the two passes cover every example. + let single_owner = |ex: &str| ex.starts_with("close_into "); + let all = ledger_config::parse(&uncomment(&text, |ex| !single_owner(ex))) + .unwrap_or_else(|e| panic!("examples (without close_into) parse: {e}")); + let c = &all.company; + assert_eq!(c.name, "Acme Widgets LLC"); + assert_eq!(c.tax_form, Some(ledger_core::TaxForm::Form1065)); + assert_eq!(c.members.len(), 2); + assert_eq!(c.members[1].distribution_account.as_deref(), Some("3101")); + assert_eq!(c.close_sweep, ["3100"]); + assert!(c.profile.iter().any(|(k, _)| k == "ein")); + assert_eq!(all.cash_accounts, ["1000", "1010"]); + assert_eq!(all.financing_accounts, ["2100"]); + assert_eq!(all.investing_accounts, ["1400"]); + let single = ledger_config::parse(&uncomment(&text, single_owner)) + .unwrap_or_else(|e| panic!("close_into example parses: {e}")); + assert_eq!(single.company.close_into.as_deref(), Some("3000")); + assert!( + ledger_config::parse(&uncomment(&text, |_| true)).is_err(), + "close_into with members is refused, as the template says" + ); +} + +/// The serde field names of `struct name` in ledger-config's source. +fn fields(src: &str, name: &str) -> Vec { + let start = src + .find(&format!("struct {name} {{")) + .unwrap_or_else(|| panic!("ledger-config has struct {name}")); + let body = &src[start..]; + let body = &body[..body.find("\n}").expect("struct ends")]; + let found: Vec = body + .lines() + .skip(1) + .map(str::trim) + .filter(|l| !l.starts_with("//") && !l.starts_with("#[")) + .filter_map(|l| l.split_once(':')) + .map(|(k, _)| k.trim_start_matches("pub ").to_string()) + .collect(); + assert!(!found.is_empty(), "struct {name} has fields"); + found +} + +#[test] +fn the_toml_shows_every_setting_the_config_reads() { + let tmp = tempfile::tempdir().unwrap(); + let (_, text) = scaffolded_toml(tmp.path()); + // (section, key) for every assignment, examples included. + let mut section = String::new(); + let mut present = Vec::new(); + for line in uncomment(&text, |_| true).lines() { + if line.starts_with('[') { + section = line.trim_matches(['[', ']']).to_string(); + } else if let Some((k, _)) = line.split_once(" = ") { + present.push((section.clone(), k.to_string())); + } + } + let has = |s: &str, k: &str| present.iter().any(|(ps, pk)| ps == s && pk == k); + // Read from the config crate's serde structs, so a new setting there + // fails here until the template shows it. + let src = include_str!("../../ledger-config/src/lib.rs"); + assert_eq!(fields(src, "BooksToml"), ["company", "books"]); + for (strukt, sect) in [ + ("CompanyToml", "company"), + ("MemberToml", "company.members"), + ("BooksSection", "books"), + ] { + for key in fields(src, strukt) { + if strukt == "CompanyToml" && key == "members" { + assert!(text.contains("# [[company.members]]")); + continue; + } + assert!(has(sect, &key), "[{sect}] {key} is in the template"); + } + } + // Read elsewhere: the justfile's session-start (main.rs), and the two + // the scaffold has always written live. + for (sect, key) in [ + ("viewer", "open_at_start"), + ("company", "base_currency"), + ("books", "data_dir"), + ] { + assert!(has(sect, key), "[{sect}] {key} is in the template"); + } +} diff --git a/crates/ledger-core/src/error.rs b/crates/ledger-core/src/error.rs index 3388e32..d2bc467 100644 --- a/crates/ledger-core/src/error.rs +++ b/crates/ledger-core/src/error.rs @@ -97,8 +97,8 @@ pub enum LedgerError { /// decides with the human how to handle it. #[error( "account {code} \"{name}\" is {}, but {role} needs account {code} to be a postable {expected:?} account; \ - wanderbooks won't post {role} there. Decide with the books' owner how to handle it \ - (for instance, renumber that account so {code} is free, if they agree)", + wanderbooks won't post {role} there. Decide with the books' owner how to handle it: \ + assign {role} to an account of theirs, or renumber that account so {code} is free", if *placeholder { format!("a placeholder {found:?} account") } else { format!("a {found:?} account") } )] ControlAccountMismatch { @@ -116,6 +116,44 @@ pub enum LedgerError { placeholder: bool, }, + /// No control role has that name (see `ledger_core::control_accounts`). + #[error( + "no role {0:?}; the roles are receivables, revenue, payables, general_expense, \ + retained_earnings, salary_expense, payroll_tax_expense, federal_withholding, \ + state_withholding, social_security_payable, medicare_payable, other_deductions_payable" + )] + UnknownRole(String), + + /// One account per role: an account already filling a role can't take + /// a second (its balance would mix what the two control). + #[error("account {code} \"{name}\" already holds {role}; give each role its own account")] + RoleTaken { + /// The account's code. + code: String, + /// The account's name. + name: String, + /// The role it fills, e.g. "accounts receivable". + role: String, + }, + + /// A balance sheet role (receivables, payables, withholdings, retained + /// earnings) can't move while its account holds a balance: the control + /// account must keep agreeing with what it controls. + #[error( + "{role} is in account {code} \"{name}\", which holds {balance}; move that balance to the \ + new account with a journal entry first, then assign the role" + )] + RoleHoldsBalance { + /// The role, e.g. "accounts receivable". + role: String, + /// The code of the account filling it now. + code: String, + /// That account's name. + name: String, + /// Its balance. + balance: rust_decimal::Decimal, + }, + /// A rule set by law (a dollar threshold, a rate) isn't known to /// wanderbooks for the year asked; the engine never guesses (see /// [`crate::rules`]). diff --git a/crates/ledger-core/src/ledger.rs b/crates/ledger-core/src/ledger.rs index b0967ee..c04f9d0 100644 --- a/crates/ledger-core/src/ledger.rs +++ b/crates/ledger-core/src/ledger.rs @@ -30,7 +30,10 @@ use std::collections::HashMap; /// (`LedgerError::ControlAccountMismatch`). #[derive(Clone, Copy, Debug)] pub struct ControlAccount { - /// The code the engine looks for. + /// The role's stable name, for assigning it an account + /// ([`Ledger::assign_role`]): `receivables`, `payables`, ... + pub key: &'static str, + /// The code the engine looks for when the role has no account assigned. pub code: &'static str, /// The name it gives the account when it creates it. pub name: &'static str, @@ -41,12 +44,14 @@ pub struct ControlAccount { } const fn control( + key: &'static str, code: &'static str, name: &'static str, role: &'static str, account_type: AccountType, ) -> ControlAccount { ControlAccount { + key, code, name, role, @@ -56,16 +61,23 @@ const fn control( /// A control account; see [`ControlAccount`]. pub const RECEIVABLES: ControlAccount = control( + "receivables", "1200", "Accounts Receivable", "accounts receivable", AccountType::Asset, ); /// A control account; see [`ControlAccount`]. -pub const REVENUE: ControlAccount = - control("4000", "Revenue", "invoice revenue", AccountType::Revenue); +pub const REVENUE: ControlAccount = control( + "revenue", + "4000", + "Revenue", + "invoice revenue", + AccountType::Revenue, +); /// A control account; see [`ControlAccount`]. pub const PAYABLES: ControlAccount = control( + "payables", "2000", "Accounts Payable", "accounts payable", @@ -73,6 +85,7 @@ pub const PAYABLES: ControlAccount = control( ); /// A control account; see [`ControlAccount`]. pub const GENERAL_EXPENSES: ControlAccount = control( + "general_expense", "6999", "General Expenses", "bill expense", @@ -80,6 +93,7 @@ pub const GENERAL_EXPENSES: ControlAccount = control( ); /// A control account; see [`ControlAccount`]. pub const RETAINED_EARNINGS: ControlAccount = control( + "retained_earnings", periods::RETAINED_EARNINGS_CODE, "Retained Earnings", "retained earnings", @@ -98,45 +112,57 @@ pub fn control_accounts() -> impl Iterator { .chain(PAYROLL.iter()) } +/// The control role named `key` (see [`ControlAccount::key`]). +pub fn control_account(key: &str) -> Option<&'static ControlAccount> { + control_accounts().find(|c| c.key == key) +} + /// The payroll accounts, in [`PayrollAccounts`] order. pub const PAYROLL: [ControlAccount; 7] = [ control( + "salary_expense", "6100", "Salary & Wage Expense", "salary expense", AccountType::Expense, ), control( + "payroll_tax_expense", "6110", "Payroll Tax Expense", "payroll tax expense", AccountType::Expense, ), control( + "federal_withholding", "2110", "Federal Withholding Payable", "federal withholding payable", AccountType::Liability, ), control( + "state_withholding", "2120", "State Withholding Payable", "state withholding payable", AccountType::Liability, ), control( + "social_security_payable", "2130", "SS Tax Payable", "social security tax payable", AccountType::Liability, ), control( + "medicare_payable", "2140", "Medicare Tax Payable", "Medicare tax payable", AccountType::Liability, ), control( + "other_deductions_payable", "2150", "Other Deductions Payable", "other deductions payable", @@ -229,6 +255,9 @@ pub struct Ledger { invoice_payments: HashMap>, /// Each bill's payments (entry, amount), for aging as of a date. bill_payments: HashMap>, + /// Control roles assigned an account ([`Ledger::assign_role`]), by + /// [`ControlAccount::key`]. A role not here uses its code. + roles: std::collections::BTreeMap<&'static str, AccountId>, } impl Ledger { @@ -266,6 +295,7 @@ impl Ledger { entry_vendors: HashMap::new(), invoice_payments: HashMap::new(), bill_payments: HashMap::new(), + roles: std::collections::BTreeMap::new(), } } @@ -775,7 +805,8 @@ impl Ledger { if self.chart.find_by_code(code).is_some() { return Err(LedgerError::DuplicateAccountCode(code.to_string())); } - if let Some(c) = control_accounts().find(|c| c.code == code) + if let Some(c) = + control_accounts().find(|c| c.code == code && !self.roles.contains_key(c.key)) && (a.account_type != c.account_type || a.placeholder) { return Err(LedgerError::ControlAccountMismatch { @@ -790,6 +821,124 @@ impl Ledger { Ok(()) } + /// The account that fills a control role now: the one assigned to it, + /// else the account at the role's code (`None` when there is none yet; + /// the first command that needs it creates it). + pub fn role_account(&self, c: &ControlAccount) -> Option { + match self.roles.get(c.key) { + Some(&id) => Some(id), + None => self.chart.find_by_code(c.code).map(|a| a.id), + } + } + + /// The account assigned to a control role, if any (`None`: the role + /// uses its code). + pub fn role_assignment(&self, c: &ControlAccount) -> Option { + self.roles.get(c.key).copied() + } + + /// Assign a control role an account of the books' own numbering, or + /// (`None`) return it to its code. Invoices, bills, payroll, and closes + /// then post the role there. + /// + /// ```text + /// GOAL: let a chart keep its own numbering: the engine's roles go to + /// the accounts the human picks, not only to fixed codes. + /// SOURCE: docs/spec/rules.md B-4 (the elements), B-23 (control roles); + /// DECISION (Stephen, 2026-10-15): roles are journaled, not + /// configured, so replay never depends on a file. + /// INVARIANTS: + /// 1. After an assignment, every command that posts the role posts it + /// to that account; after `None`, to the account at the role's + /// code (created when first needed, as before). + /// 2. No account fills two roles. + /// 3. A role's account is always postable and of the role's type. + /// 4. Posted entries never change: an assignment only steers what + /// later commands post. + /// 5. A refusal MUST leave the books unchanged. + /// SCENARIOS: + /// 1. IF the role is unknown, or the account is unknown: SHOULD refuse. + /// 2. IF the account (or, for `None`, the account at the role's + /// code) is of another type or a placeholder: SHOULD refuse with + /// ControlAccountMismatch. + /// 3. IF that account already fills another role: SHOULD refuse + /// with RoleTaken. + /// 4. IF the role is a balance sheet one (an asset, liability, or + /// equity role) and the account filling it now holds a balance, + /// and the role would move: SHOULD refuse with RoleHoldsBalance + /// BECAUSE the control account must keep agreeing with what it + /// controls (open invoices, bills, taxes withheld); move the + /// balance with an entry first. Revenue and expense roles move + /// freely. + /// 5. IF the role already resolves to that account: SHOULD accept + /// (the assignment is recorded). + /// ``` + pub fn assign_role( + &mut self, + key: &str, + account: Option, + ) -> Result<(), LedgerError> { + let c = self.check_assign_role(key, account)?; + match account { + Some(id) => self.roles.insert(c.key, id), + None => self.roles.remove(c.key), + }; + Ok(()) + } + + /// The validation behind [`Self::assign_role`], for stores that journal + /// before mutating. Returns the role. + pub fn check_assign_role( + &self, + key: &str, + account: Option, + ) -> Result<&'static ControlAccount, LedgerError> { + let c = control_account(key).ok_or_else(|| LedgerError::UnknownRole(key.to_string()))?; + let target = match account { + Some(id) => Some(self.chart.get(id).ok_or(LedgerError::UnknownAccount(id))?), + None => self.chart.find_by_code(c.code), + }; + if let Some(a) = target { + if a.account_type != c.account_type || a.placeholder { + return Err(LedgerError::ControlAccountMismatch { + code: a.code.clone(), + name: a.name.clone(), + role: c.role.to_string(), + expected: c.account_type, + found: a.account_type, + placeholder: a.placeholder, + }); + } + if let Some(other) = + control_accounts().find(|o| o.key != c.key && self.role_account(o) == Some(a.id)) + { + return Err(LedgerError::RoleTaken { + code: a.code.clone(), + name: a.name.clone(), + role: other.role.to_string(), + }); + } + } + let now = self.role_account(c); + let balance_sheet = !matches!(c.account_type, AccountType::Revenue | AccountType::Expense); + if let Some(current) = now + && balance_sheet + && now != target.map(|a| a.id) + { + let balance = self.account_balance(current).unwrap_or(Decimal::ZERO); + if !balance.is_zero() { + let a = self.chart.get(current).expect("a role's account exists"); + return Err(LedgerError::RoleHoldsBalance { + role: c.role.to_string(), + code: a.code.clone(), + name: a.name.clone(), + balance, + }); + } + } + Ok(c) + } + /// Set (or clear, with `None`) an account's tax category, and optionally /// override the category's deductible percent (0 to 100). The category /// must fit the account's type (see [`crate::tax::check_category`]). @@ -1676,6 +1825,9 @@ impl Ledger { } fn retained_earnings_account(&mut self) -> Result { + if let Some(&id) = self.roles.get(RETAINED_EARNINGS.key) { + return Ok(id); + } Ok( match self.chart.find_by_code(periods::RETAINED_EARNINGS_CODE) { Some(a) => a.id, @@ -1970,6 +2122,11 @@ impl Ledger { /// ``` fn check_controls(&self, controls: &[ControlAccount]) -> Result<(), LedgerError> { for c in controls { + // An assigned account was checked when it was assigned, and + // an account's type never changes. + if self.roles.contains_key(c.key) { + continue; + } if let Some(a) = self.chart.find_by_code(c.code) && (a.account_type != c.account_type || a.placeholder) { @@ -1990,7 +2147,10 @@ impl Ledger { /// run [`Self::check_controls`] first, so an existing account here is /// the right type. fn ensure_control(&mut self, c: &ControlAccount) -> AccountId { - self.ensure_account(c.code, c.name, c.account_type) + match self.roles.get(c.key) { + Some(&id) => id, + None => self.ensure_account(c.code, c.name, c.account_type), + } } /// Ensure an account exists by code, creating it with the given type @@ -2808,14 +2968,15 @@ impl Ledger { /// means owed. pub fn payroll_tax_payables(&self) -> Vec<(AccountId, Decimal)> { let mut out = Vec::new(); - for code in ["2110", "2120", "2130", "2140", "2150"] { - let Some(account) = self.chart.find_by_code(code) else { + for c in &PAYROLL[2..] { + let Some(account) = self.role_account(c) else { continue; }; - // The code resolved, so the balance lookup cannot fail. - let balance = self.account_balance(account.id).unwrap_or(Decimal::ZERO); + // The role resolved to an account, so the balance lookup + // cannot fail. + let balance = self.account_balance(account).unwrap_or(Decimal::ZERO); if !balance.is_zero() { - out.push((account.id, balance)); + out.push((account, balance)); } } out diff --git a/crates/ledger-core/src/lib.rs b/crates/ledger-core/src/lib.rs index 3f109cb..173cf50 100644 --- a/crates/ledger-core/src/lib.rs +++ b/crates/ledger-core/src/lib.rs @@ -52,7 +52,7 @@ pub use form1099::{Form1099, Vendor1099Line, Vendor1099Report, vendor_1099_repor pub use journal::{EntryId, EntryStatus, JournalEntry, JournalLine}; pub use ledger::{ ControlAccount, GENERAL_EXPENSES, Ledger, PAYABLES, PAYROLL, RECEIVABLES, RETAINED_EARNINGS, - REVENUE, ReconciliationReport, UnclearedLine, + REVENUE, ReconciliationReport, UnclearedLine, control_account, control_accounts, }; pub use money::{Currency, Money}; pub use payouts::{Payout, PayoutAdjustment, PayoutLine, PayoutTotals, payout_plan}; diff --git a/crates/ledger-core/tests/spec_roles.rs b/crates/ledger-core/tests/spec_roles.rs new file mode 100644 index 0000000..53ba7db --- /dev/null +++ b/crates/ledger-core/tests/spec_roles.rs @@ -0,0 +1,1203 @@ +//! Spec-derived tests for control-role assignment: the engine's roles +//! (receivables, revenue, payables, general expense, retained earnings, the +//! seven payroll accounts) assigned to the chart's own accounts. +//! +//! Oracles are `docs/spec/rules.md` B-4 (the five elements) and B-23 +//! (control roles), the spec block on `Ledger::assign_role`, the doc +//! comments of `Ledger::role_account`, `role_assignment`, +//! `check_assign_role`, `control_account`, `ControlAccount::key`, the spec +//! on `Ledger::check_controls` and `renumber_account`, and the error +//! variants `UnknownRole`, `RoleTaken`, `RoleHoldsBalance`, +//! `ControlAccountMismatch`. Expected values are worked by hand from those, +//! never read back from the code under test. + +use std::collections::BTreeSet; +use std::str::FromStr; + +use chrono::NaiveDate; +use ledger_core::{ + AccountId, AccountType, CloseTo, ControlAccount, Currency, DocumentLine, EntryId, + GENERAL_EXPENSES, Ledger, LedgerError, Money, PAYABLES, PAYROLL, PayrollLine, RECEIVABLES, + RETAINED_EARNINGS, REVENUE, Side, control_account, control_accounts, +}; +use proptest::prelude::*; +use rust_decimal::Decimal; + +// ---------------------------------------------------------------- helpers + +fn dec(s: &str) -> Decimal { + Decimal::from_str(s).expect("decimal literal") +} + +fn usd(s: &str) -> Money { + Money::new(dec(s), Currency::USD) +} + +fn d(y: i32, m: u32, day: u32) -> NaiveDate { + NaiveDate::from_ymd_opt(y, m, day).expect("valid date") +} + +fn line(desc: &str, amount: &str) -> DocumentLine { + DocumentLine { + description: desc.to_string(), + amount: dec(amount), + } +} + +/// The twelve role keys, as the spec lists them (B-23 and `UnknownRole`). +const KEYS: [&str; 12] = [ + "receivables", + "revenue", + "payables", + "general_expense", + "retained_earnings", + "salary_expense", + "payroll_tax_expense", + "federal_withholding", + "state_withholding", + "social_security_payable", + "medicare_payable", + "other_deductions_payable", +]; + +fn role(key: &str) -> &'static ControlAccount { + control_account(key).expect("known role") +} + +/// A role is a balance sheet one when its type is asset, liability, or +/// equity (assign_role SCENARIO 4). +fn balance_sheet(c: &ControlAccount) -> bool { + matches!( + c.account_type, + AccountType::Asset | AccountType::Liability | AccountType::Equity + ) +} + +/// A ledger with only a cash account (1000). +fn books() -> (Ledger, AccountId) { + let mut l = Ledger::new(Currency::USD); + let cash = l + .add_account("1000", "Cash", AccountType::Asset, None, false) + .expect("cash account"); + (l, cash) +} + +fn add(l: &mut Ledger, code: &str, name: &str, t: AccountType) -> AccountId { + l.add_account(code, name, t, None, false).expect("account") +} + +fn acct(l: &Ledger, code: &str) -> Option { + l.chart().find_by_code(code).map(|a| a.id) +} + +fn code_of(l: &Ledger, id: AccountId) -> String { + l.chart().get(id).expect("known account").code.clone() +} + +fn bal(l: &Ledger, id: AccountId) -> Decimal { + l.account_balance(id).expect("known account") +} + +/// Draft, add lines, post. Panics on any refusal (setup only). +fn post(l: &mut Ledger, date: NaiveDate, lines: &[(AccountId, Side, &str)]) -> EntryId { + let id = l.try_create_entry(date, "test entry").unwrap(); + for (a, s, amt) in lines { + l.add_line(id, *a, *s, usd(amt)).unwrap(); + } + l.post_entry(id).unwrap(); + id +} + +/// The (code, side, amount) lines of an entry, sorted. +fn lines_by_code(l: &Ledger, e: EntryId) -> Vec<(String, Side, Decimal)> { + let mut v: Vec<_> = l + .get_entry(e) + .expect("entry exists") + .lines() + .iter() + .map(|x| (code_of(l, x.account), x.side, x.amount.amount)) + .collect(); + v.sort_by(|a, b| format!("{a:?}").cmp(&format!("{b:?}"))); + v +} + +fn ent(code: &str, side: Side, amt: &str) -> (String, Side, Decimal) { + (code.to_string(), side, dec(amt)) +} + +fn sorted(mut v: Vec<(String, Side, Decimal)>) -> Vec<(String, Side, Decimal)> { + v.sort_by(|a, b| format!("{a:?}").cmp(&format!("{b:?}"))); + v +} + +/// Every posted entry, rendered, for "posted entries never change". +fn entries(l: &Ledger) -> Vec { + (0..l.next_entry_id() + 2) + .filter_map(|id| l.get_entry(id)) + .map(|e| { + format!( + "E{} {} {:?} {:?} {:?}", + e.id(), + e.date(), + e.description(), + e.status(), + e.lines() + .iter() + .map(|x| (x.account, x.side, x.amount.amount)) + .collect::>() + ) + }) + .collect() +} + +/// Everything observable about the books, deterministically ordered, so a +/// refusal can be checked to leave them unchanged. Includes every role's +/// assignment and resolution. +fn fingerprint(l: &Ledger) -> String { + let mut out = String::new(); + let mut accts: Vec<_> = l + .chart() + .all() + .map(|a| { + format!( + "{}|{}|{}|{:?}|{}|{:?}", + a.id, a.code, a.name, a.account_type, a.placeholder, a.parent + ) + }) + .collect(); + accts.sort(); + out.push_str(&accts.join("\n")); + out.push_str(&format!( + "\nnext: acct {} entry {} inv {} bill {} cust {} vend {} period {} run {}\n", + l.next_account_id(), + l.next_entry_id(), + l.next_invoice_id(), + l.next_bill_id(), + l.next_customer_id(), + l.next_vendor_id(), + l.next_period_id(), + l.next_payroll_run_id() + )); + for e in entries(l) { + out.push_str(&e); + out.push('\n'); + } + for id in 0..l.next_invoice_id() + 2 { + if let Some(i) = l.get_invoice(id) { + out.push_str(&format!("{i:?}\n")); + } + } + for id in 0..l.next_bill_id() + 2 { + if let Some(b) = l.get_bill(id) { + out.push_str(&format!("{b:?}\n")); + } + } + for p in l.list_periods() { + out.push_str(&format!("{p:?}\n")); + } + for r in l.list_payroll_runs() { + out.push_str(&format!("{r:?}\n")); + } + for c in control_accounts() { + out.push_str(&format!( + "role {} = {:?} -> {:?}\n", + c.key, + l.role_assignment(c), + l.role_account(c) + )); + } + out +} + +/// Assert `f` refuses and leaves the books unchanged; return the error. +fn refuses(l: &mut Ledger, key: &str, account: Option) -> LedgerError { + let before = fingerprint(l); + let err = l + .assign_role(key, account) + .expect_err("assignment should be refused"); + assert_eq!(fingerprint(l), before, "a refusal changed the books"); + err +} + +/// $5,000 gross; FICA at the 2026 rates (SS 6.2% = 310.00 each share, +/// Medicare 1.45% = 72.50 each share). +fn alice() -> PayrollLine { + PayrollLine { + employee_name: "Alice".to_string(), + gross_pay: dec("5000.00"), + federal_withholding: dec("600.00"), + state_withholding: dec("250.00"), + ss_employee: dec("310.00"), + medicare_employee: dec("72.50"), + ss_employer: dec("310.00"), + medicare_employer: dec("72.50"), + other_deductions: dec("200.00"), + } +} + +// ------------------------------------------------------------ the roles + +/// Oracle: B-23 names the roles and their default codes; `control_account` +/// finds a role by its `key`; `UnknownRole` lists the twelve keys. +#[test] +fn every_role_has_its_key_default_code_and_type() { + use AccountType::*; + let expected = [ + ("receivables", "1200", Asset), + ("revenue", "4000", Revenue), + ("payables", "2000", Liability), + ("general_expense", "6999", Expense), + ("retained_earnings", "3999", Equity), + ("salary_expense", "6100", Expense), + ("payroll_tax_expense", "6110", Expense), + ("federal_withholding", "2110", Liability), + ("state_withholding", "2120", Liability), + ("social_security_payable", "2130", Liability), + ("medicare_payable", "2140", Liability), + ("other_deductions_payable", "2150", Liability), + ]; + for (key, code, t) in expected { + let c = role(key); + assert_eq!(c.key, key); + assert_eq!(c.code, code, "{key}"); + assert_eq!(c.account_type, t, "{key}"); + } + let keys: BTreeSet<_> = control_accounts().map(|c| c.key).collect(); + assert_eq!(keys, KEYS.into_iter().collect::>()); + assert_eq!(RECEIVABLES.key, "receivables"); + assert_eq!(REVENUE.key, "revenue"); + assert_eq!(PAYABLES.key, "payables"); + assert_eq!(GENERAL_EXPENSES.key, "general_expense"); + assert_eq!(RETAINED_EARNINGS.key, "retained_earnings"); + assert_eq!(PAYROLL.len(), 7); + assert!(control_account("nope").is_none()); +} + +/// Oracle: `role_account` is "the one assigned to it, else the account at +/// the role's code (None when there is none yet)"; `role_assignment` is +/// None while the role uses its code. +#[test] +fn a_fresh_role_resolves_to_its_code_or_nothing() { + let (mut l, _) = books(); + for c in control_accounts() { + assert_eq!(l.role_assignment(c), None); + assert_eq!(l.role_account(c), None, "{}", c.key); + } + let ar = add(&mut l, "1200", "A/R", AccountType::Asset); + assert_eq!(l.role_account(&RECEIVABLES), Some(ar)); + assert_eq!(l.role_assignment(&RECEIVABLES), None); +} + +// ------------------------------------------------- INVARIANT 1: steering + +/// Oracle: assign_role INVARIANT 1 + create_invoice / receive_payment +/// (Dr receivables / Cr revenue; Dr cash / Cr receivables). +#[test] +fn invoices_and_payments_post_to_assigned_receivables_and_revenue() { + let (mut l, cash) = books(); + let ar = add(&mut l, "1100", "Trade Receivables", AccountType::Asset); + let sales = add(&mut l, "4100", "Sales", AccountType::Revenue); + l.assign_role("receivables", Some(ar)).unwrap(); + l.assign_role("revenue", Some(sales)).unwrap(); + assert_eq!(l.role_assignment(&RECEIVABLES), Some(ar)); + assert_eq!(l.role_account(&RECEIVABLES), Some(ar)); + assert_eq!(l.role_account(&REVENUE), Some(sales)); + + let cust = l.add_customer("Acme").unwrap(); + let inv = l + .create_invoice( + cust, + d(2026, 3, 1), + d(2026, 3, 31), + vec![line("work", "300.00"), line("more", "50.25")], + ) + .unwrap(); + let e = l.get_invoice(inv).unwrap().entry_id; + assert_eq!( + lines_by_code(&l, e), + sorted(vec![ + ent("1100", Side::Debit, "350.25"), + ent("4100", Side::Credit, "350.25"), + ]) + ); + let p = l + .receive_payment(inv, d(2026, 3, 15), dec("100.00"), cash) + .unwrap(); + assert_eq!( + lines_by_code(&l, p), + sorted(vec![ + ent("1000", Side::Debit, "100.00"), + ent("1100", Side::Credit, "100.00"), + ]) + ); + assert_eq!(bal(&l, ar), dec("250.25")); + assert_eq!(bal(&l, sales), dec("350.25")); + assert_eq!(acct(&l, "1200"), None, "nothing created at 1200"); + assert_eq!(acct(&l, "4000"), None, "nothing created at 4000"); +} + +/// Oracle: assign_role INVARIANT 1 + create_bill / pay_bill +/// (Dr general expense / Cr payables; Dr payables / Cr cash). +#[test] +fn bills_and_payments_post_to_assigned_payables_and_general_expense() { + let (mut l, cash) = books(); + let ap = add(&mut l, "2010", "Trade Payables", AccountType::Liability); + let misc = add(&mut l, "6500", "Misc Expense", AccountType::Expense); + l.assign_role("payables", Some(ap)).unwrap(); + l.assign_role("general_expense", Some(misc)).unwrap(); + + let v = l.add_vendor("Supplier").unwrap(); + let bill = l + .create_bill(v, d(2026, 4, 1), d(2026, 4, 30), vec![line("x", "80.00")]) + .unwrap(); + let e = l.get_bill(bill).unwrap().entry_id; + assert_eq!( + lines_by_code(&l, e), + sorted(vec![ + ent("6500", Side::Debit, "80.00"), + ent("2010", Side::Credit, "80.00"), + ]) + ); + let p = l + .pay_bill(bill, d(2026, 4, 10), dec("80.00"), cash) + .unwrap(); + assert_eq!( + lines_by_code(&l, p), + sorted(vec![ + ent("2010", Side::Debit, "80.00"), + ent("1000", Side::Credit, "80.00"), + ]) + ); + assert_eq!(bal(&l, ap), Decimal::ZERO); + assert_eq!(bal(&l, misc), dec("80.00")); + assert_eq!(acct(&l, "2000"), None); + assert_eq!(acct(&l, "6999"), None); +} + +/// Oracle: assign_role INVARIANT 1 + run_payroll INVARIANT 1 (Dr salary +/// gross, Dr payroll tax employer FICA, Cr the withholdings, Cr cash net), +/// payroll_tax_payables (the withholding accounts' balances, in role +/// order), and remit_payroll_taxes INVARIANT 1 (zero every payable). +#[test] +fn payroll_and_remittance_post_to_all_seven_assigned_accounts() { + let (mut l, cash) = books(); + let picks = [ + ("salary_expense", "5100", AccountType::Expense), + ("payroll_tax_expense", "5110", AccountType::Expense), + ("federal_withholding", "2210", AccountType::Liability), + ("state_withholding", "2220", AccountType::Liability), + ("social_security_payable", "2230", AccountType::Liability), + ("medicare_payable", "2240", AccountType::Liability), + ("other_deductions_payable", "2250", AccountType::Liability), + ]; + let mut ids = Vec::new(); + for (key, code, t) in picks { + let id = add(&mut l, code, key, t); + l.assign_role(key, Some(id)).unwrap(); + assert_eq!(l.role_account(role(key)), Some(id)); + ids.push(id); + } + // Fund cash so net pay has somewhere to come from (not required by the + // spec; keeps the books ordinary). + let cap = add(&mut l, "3000", "Capital", AccountType::Equity); + post( + &mut l, + d(2026, 1, 2), + &[ + (cash, Side::Debit, "10000.00"), + (cap, Side::Credit, "10000.00"), + ], + ); + + let run = l.run_payroll(d(2026, 2, 27), vec![alice()], cash).unwrap(); + let e = l.get_payroll_run(run).unwrap().entry_id; + // Net = 5000 - 600 - 250 - 310 - 72.50 - 200 = 3567.50. + // Employer FICA = 310 + 72.50 = 382.50. SS payable 620, Medicare 145. + assert_eq!( + lines_by_code(&l, e), + sorted(vec![ + ent("5100", Side::Debit, "5000.00"), + ent("5110", Side::Debit, "382.50"), + ent("2210", Side::Credit, "600.00"), + ent("2220", Side::Credit, "250.00"), + ent("2230", Side::Credit, "620.00"), + ent("2240", Side::Credit, "145.00"), + ent("2250", Side::Credit, "200.00"), + ent("1000", Side::Credit, "3567.50"), + ]) + ); + assert_eq!( + l.payroll_tax_payables(), + vec![ + (ids[2], dec("600.00")), + (ids[3], dec("250.00")), + (ids[4], dec("620.00")), + (ids[5], dec("145.00")), + (ids[6], dec("200.00")), + ] + ); + let r = l.remit_payroll_taxes(d(2026, 3, 15), cash).unwrap(); + assert_eq!( + lines_by_code(&l, r), + sorted(vec![ + ent("2210", Side::Debit, "600.00"), + ent("2220", Side::Debit, "250.00"), + ent("2230", Side::Debit, "620.00"), + ent("2240", Side::Debit, "145.00"), + ent("2250", Side::Debit, "200.00"), + ent("1000", Side::Credit, "1815.00"), + ]) + ); + for id in &ids[2..] { + assert_eq!(bal(&l, *id), Decimal::ZERO); + } + assert!(l.payroll_tax_payables().is_empty()); + for code in ["6100", "6110", "2110", "2120", "2130", "2140", "2150"] { + assert_eq!(acct(&l, code), None, "nothing created at {code}"); + } +} + +/// Oracle: assign_role INVARIANT 1 + close_period INVARIANT 2 (credit +/// equity with the net income) under CloseTo::RetainedEarnings. +#[test] +fn a_close_to_retained_earnings_goes_to_the_assigned_equity_account() { + let (mut l, cash) = books(); + let re = add(&mut l, "3100", "Accumulated Earnings", AccountType::Equity); + let rev = add(&mut l, "4500", "Fees", AccountType::Revenue); + let exp = add(&mut l, "6500", "Rent", AccountType::Expense); + l.assign_role("retained_earnings", Some(re)).unwrap(); + post( + &mut l, + d(2026, 1, 5), + &[(cash, Side::Debit, "900.00"), (rev, Side::Credit, "900.00")], + ); + post( + &mut l, + d(2026, 1, 20), + &[(exp, Side::Debit, "250.00"), (cash, Side::Credit, "250.00")], + ); + let p = l + .create_period("2026-01", d(2026, 1, 1), d(2026, 1, 31)) + .unwrap(); + let closing = l + .close_period_to(p, &CloseTo::RetainedEarnings { sweep: vec![] }) + .unwrap() + .expect("a closing entry"); + assert_eq!( + lines_by_code(&l, closing), + sorted(vec![ + ent("4500", Side::Debit, "900.00"), + ent("6500", Side::Credit, "250.00"), + ent("3100", Side::Credit, "650.00"), + ]) + ); + assert_eq!(bal(&l, re), dec("650.00")); + assert_eq!(acct(&l, "3999"), None, "nothing created at 3999"); +} + +/// Oracle: assign_role "or (None) return it to its code"; INVARIANT 1 +/// "after None, to the account at the role's code (created when first +/// needed, as before)". +#[test] +fn none_returns_a_role_to_its_code() { + let (mut l, _) = books(); + let ar = add(&mut l, "1100", "Trade Receivables", AccountType::Asset); + let sales = add(&mut l, "4100", "Sales", AccountType::Revenue); + l.assign_role("receivables", Some(ar)).unwrap(); + l.assign_role("revenue", Some(sales)).unwrap(); + // Neither holds a balance yet, so both may move. + l.assign_role("receivables", None).unwrap(); + l.assign_role("revenue", None).unwrap(); + assert_eq!(l.role_assignment(&RECEIVABLES), None); + assert_eq!(l.role_account(&RECEIVABLES), None); + assert_eq!(l.role_account(&REVENUE), None); + + let cust = l.add_customer("Acme").unwrap(); + let inv = l + .create_invoice( + cust, + d(2026, 3, 1), + d(2026, 3, 31), + vec![line("w", "10.00")], + ) + .unwrap(); + let e = l.get_invoice(inv).unwrap().entry_id; + assert_eq!( + lines_by_code(&l, e), + sorted(vec![ + ent("1200", Side::Debit, "10.00"), + ent("4000", Side::Credit, "10.00"), + ]) + ); + assert_eq!(l.role_account(&RECEIVABLES), acct(&l, "1200")); + assert_eq!(bal(&l, ar), Decimal::ZERO); + assert_eq!(bal(&l, sales), Decimal::ZERO); +} + +/// Oracle: None returns the role to an existing right-typed account at its +/// code, and later postings go there. +#[test] +fn none_returns_a_role_to_an_existing_account_at_its_code() { + let (mut l, _) = books(); + let exp = add(&mut l, "6999", "General", AccountType::Expense); + let other = add(&mut l, "6500", "Other", AccountType::Expense); + l.assign_role("general_expense", Some(other)).unwrap(); + l.assign_role("general_expense", None).unwrap(); + assert_eq!(l.role_account(&GENERAL_EXPENSES), Some(exp)); + let v = l.add_vendor("V").unwrap(); + let b = l + .create_bill(v, d(2026, 2, 1), d(2026, 2, 1), vec![line("x", "5.00")]) + .unwrap(); + let e = l.get_bill(b).unwrap().entry_id; + assert!(lines_by_code(&l, e).contains(&ent("6999", Side::Debit, "5.00"))); +} + +// --------------------------------------------------- SCENARIO 1: unknowns + +/// Oracle: SCENARIO 1 (unknown role) + `UnknownRole`; INVARIANT 5. +#[test] +fn an_unknown_role_is_refused() { + let (mut l, cash) = books(); + for key in ["", "Receivables", "receivable", "ar", "salary"] { + assert_eq!( + refuses(&mut l, key, Some(cash)), + LedgerError::UnknownRole(key.to_string()) + ); + assert_eq!( + refuses(&mut l, key, None), + LedgerError::UnknownRole(key.to_string()) + ); + assert!(l.check_assign_role(key, None).is_err()); + } +} + +/// Oracle: SCENARIO 1 (unknown account); INVARIANT 5. +#[test] +fn an_unknown_account_is_refused() { + let (mut l, _) = books(); + let missing = l.next_account_id() + 7; + for key in KEYS { + assert_eq!( + refuses(&mut l, key, Some(missing)), + LedgerError::UnknownAccount(missing) + ); + } +} + +// ----------------------------------------- SCENARIO 2: type / placeholder + +/// Oracle: SCENARIO 2 + INVARIANT 3 + B-4: every role refuses an account of +/// each other type with ControlAccountMismatch naming the account, the +/// role, the type needed and the type found. +#[test] +fn a_wrong_typed_account_is_refused_for_every_role() { + use AccountType::*; + let (mut l, _) = books(); + let types = [Asset, Liability, Equity, Revenue, Expense]; + let mut pool = Vec::new(); + for (i, t) in types.into_iter().enumerate() { + let code = format!("{}", 8000 + i); + let id = add(&mut l, &code, &format!("Pool {t:?}"), t); + pool.push((id, code, t)); + } + for c in control_accounts() { + for (id, code, t) in &pool { + if *t == c.account_type { + continue; + } + match refuses(&mut l, c.key, Some(*id)) { + LedgerError::ControlAccountMismatch { + code: got_code, + name, + role, + expected, + found, + placeholder, + } => { + assert_eq!(&got_code, code); + assert_eq!(name, format!("Pool {t:?}")); + assert_eq!(role, c.role); + assert_eq!(expected, c.account_type); + assert_eq!(found, *t); + assert!(!placeholder); + } + other => panic!("{}: expected ControlAccountMismatch, got {other:?}", c.key), + } + } + } +} + +/// Oracle: SCENARIO 2 + INVARIANT 3 ("always postable"). +#[test] +fn a_placeholder_account_is_refused() { + let (mut l, _) = books(); + let ph = l + .add_account( + "1150", + "Receivables (group)", + AccountType::Asset, + None, + true, + ) + .unwrap(); + match refuses(&mut l, "receivables", Some(ph)) { + LedgerError::ControlAccountMismatch { + expected, + found, + placeholder, + .. + } => { + assert_eq!(expected, AccountType::Asset); + assert_eq!(found, AccountType::Asset); + assert!(placeholder); + } + other => panic!("expected ControlAccountMismatch, got {other:?}"), + } +} + +/// Oracle: SCENARIO 2 "(or, for None, the account at the role's code)": +/// returning a role to a code that holds a wrong-typed account is refused. +#[test] +fn none_is_refused_when_the_default_code_holds_a_wrong_typed_account() { + // An imported chart with an expense at 1200, role never assigned. + let (mut l, _) = books(); + add(&mut l, "1200", "Postage", AccountType::Expense); + match refuses(&mut l, "receivables", None) { + LedgerError::ControlAccountMismatch { + expected, found, .. + } => { + assert_eq!(expected, AccountType::Asset); + assert_eq!(found, AccountType::Expense); + } + other => panic!("expected ControlAccountMismatch, got {other:?}"), + } + + // The role assigned elsewhere, then 1200 taken by a wrong type (an + // assignment frees the code, B-23 / check_renumber_account): None is + // refused, and the assignment stays. + let (mut l, _) = books(); + let ar = add(&mut l, "1100", "Trade Receivables", AccountType::Asset); + l.assign_role("receivables", Some(ar)).unwrap(); + let post_exp = add(&mut l, "6400", "Postage", AccountType::Expense); + l.renumber_account(post_exp, "1200").unwrap(); + assert!(matches!( + refuses(&mut l, "receivables", None), + LedgerError::ControlAccountMismatch { .. } + )); + assert_eq!(l.role_account(&RECEIVABLES), Some(ar)); + + // A placeholder at the code is refused for None too. + let (mut l, _) = books(); + l.add_account("6999", "Expenses", AccountType::Expense, None, true) + .unwrap(); + assert!(matches!( + refuses(&mut l, "general_expense", None), + LedgerError::ControlAccountMismatch { + placeholder: true, + .. + } + )); +} + +// ------------------------------------------------ SCENARIO 3: RoleTaken + +/// Oracle: SCENARIO 3 + INVARIANT 2: an account assigned to one role can't +/// take another. +#[test] +fn one_account_cannot_fill_two_assigned_roles() { + let (mut l, _) = books(); + let liab = add(&mut l, "2500", "Payables to all", AccountType::Liability); + l.assign_role("payables", Some(liab)).unwrap(); + match refuses(&mut l, "federal_withholding", Some(liab)) { + LedgerError::RoleTaken { code, name, role } => { + assert_eq!(code, "2500"); + assert_eq!(name, "Payables to all"); + assert_eq!(role, PAYABLES.role); + } + other => panic!("expected RoleTaken, got {other:?}"), + } + let exp = add(&mut l, "6500", "Wages", AccountType::Expense); + l.assign_role("salary_expense", Some(exp)).unwrap(); + assert!(matches!( + refuses(&mut l, "payroll_tax_expense", Some(exp)), + LedgerError::RoleTaken { .. } + )); + assert!(matches!( + refuses(&mut l, "general_expense", Some(exp)), + LedgerError::RoleTaken { .. } + )); +} + +/// Oracle: SCENARIO 3 + `role_account` ("else the account at the role's +/// code"): an account filling a role only by its default code fills it. +#[test] +fn an_account_at_another_roles_default_code_is_taken() { + let (mut l, _) = books(); + let ge = add(&mut l, "6999", "General Expenses", AccountType::Expense); + match refuses(&mut l, "salary_expense", Some(ge)) { + LedgerError::RoleTaken { code, role, .. } => { + assert_eq!(code, "6999"); + assert_eq!(role, GENERAL_EXPENSES.role); + } + other => panic!("expected RoleTaken, got {other:?}"), + } + // Once general expense moves elsewhere, 6999 fills no role and is free. + let g = add(&mut l, "6998", "Sundry", AccountType::Expense); + l.assign_role("general_expense", Some(g)).unwrap(); + l.assign_role("salary_expense", Some(ge)).unwrap(); + assert_eq!(l.role_account(PAYROLL.first().unwrap()), Some(ge)); +} + +/// Oracle: SCENARIO 3 applies to None too ("that account" is the one at the +/// role's code): returning a role to its code while another role is +/// assigned the account there is refused. +#[test] +fn none_is_refused_when_its_codes_account_fills_another_role() { + let (mut l, _) = books(); + let at_6100 = add(&mut l, "6100", "Salaries", AccountType::Expense); + let wages = add(&mut l, "5100", "Wages", AccountType::Expense); + l.assign_role("salary_expense", Some(wages)).unwrap(); + l.assign_role("general_expense", Some(at_6100)).unwrap(); + match refuses(&mut l, "salary_expense", None) { + LedgerError::RoleTaken { code, role, .. } => { + assert_eq!(code, "6100"); + assert_eq!(role, GENERAL_EXPENSES.role); + } + other => panic!("expected RoleTaken, got {other:?}"), + } +} + +// --------------------------------------------- SCENARIO 4: balances move + +/// Oracle: SCENARIO 4 + B-23 ("moves only when its account holds no +/// balance, so the bookkeeper first moves the balance with an entry"). +#[test] +fn receivables_cannot_move_while_holding_a_balance_but_can_once_moved_out() { + let (mut l, cash) = books(); + let cust = l.add_customer("Acme").unwrap(); + let inv = l + .create_invoice( + cust, + d(2026, 3, 1), + d(2026, 3, 31), + vec![line("w", "120.00")], + ) + .unwrap(); + let old = acct(&l, "1200").expect("1200 created by the invoice"); + let new = add(&mut l, "1100", "Trade Receivables", AccountType::Asset); + match refuses(&mut l, "receivables", Some(new)) { + LedgerError::RoleHoldsBalance { + role, + code, + balance, + .. + } => { + assert_eq!(role, RECEIVABLES.role); + assert_eq!(code, "1200"); + assert_eq!(balance, dec("120.00")); + } + other => panic!("expected RoleHoldsBalance, got {other:?}"), + } + // Move the balance with an entry, then the role may move. + post( + &mut l, + d(2026, 3, 2), + &[(new, Side::Debit, "120.00"), (old, Side::Credit, "120.00")], + ); + l.assign_role("receivables", Some(new)).unwrap(); + assert_eq!(l.role_account(&RECEIVABLES), Some(new)); + // The open invoice is now controlled by 1100: its payment credits it. + let p = l + .receive_payment(inv, d(2026, 3, 20), dec("120.00"), cash) + .unwrap(); + assert!(lines_by_code(&l, p).contains(&ent("1100", Side::Credit, "120.00"))); + assert_eq!(bal(&l, new), Decimal::ZERO); + assert_eq!(bal(&l, old), Decimal::ZERO); +} + +/// Oracle: SCENARIO 4 for each balance-sheet role kind (asset, liability, +/// equity), and for None as a move too. +#[test] +fn every_balance_sheet_role_holds_while_its_account_has_a_balance() { + let (mut l, cash) = books(); + let mut held = Vec::new(); + for c in control_accounts().filter(|c| balance_sheet(c)) { + let cur = add(&mut l, c.code, c.name, c.account_type); + // Give it a balance against cash. + let (dr, cr) = if c.account_type == AccountType::Asset { + (cur, cash) + } else { + (cash, cur) + }; + post( + &mut l, + d(2026, 1, 3), + &[(dr, Side::Debit, "7.00"), (cr, Side::Credit, "7.00")], + ); + held.push((c, cur)); + } + for (i, (c, cur)) in held.iter().enumerate() { + let target = add(&mut l, &format!("{}", 9100 + i), "Target", c.account_type); + assert!( + matches!( + refuses(&mut l, c.key, Some(target)), + LedgerError::RoleHoldsBalance { .. } + ), + "{}", + c.key + ); + // Assigning the account it already resolves to is not a move. + l.assign_role(c.key, Some(*cur)).unwrap(); + assert_eq!(l.role_assignment(c), Some(*cur)); + // None would move it nowhere new (the code holds the same account): + // accepted. + l.assign_role(c.key, None).unwrap(); + assert_eq!(l.role_account(c), Some(*cur)); + } +} + +/// Oracle: SCENARIO 4 (None is a move when the role is assigned away from +/// its code and the assigned account holds a balance). +#[test] +fn none_is_refused_while_the_assigned_balance_sheet_account_holds_a_balance() { + let (mut l, cash) = books(); + let ap = add(&mut l, "2010", "Trade Payables", AccountType::Liability); + l.assign_role("payables", Some(ap)).unwrap(); + let v = l.add_vendor("V").unwrap(); + let exp = add(&mut l, "6500", "Stuff", AccountType::Expense); + l.assign_role("general_expense", Some(exp)).unwrap(); + l.create_bill(v, d(2026, 2, 1), d(2026, 2, 28), vec![line("x", "40.00")]) + .unwrap(); + assert!(matches!( + refuses(&mut l, "payables", None), + LedgerError::RoleHoldsBalance { .. } + )); + let _ = cash; +} + +/// Oracle: SCENARIO 4 "Revenue and expense roles move freely"; B-23 +/// "earlier postings stay where they are"; INVARIANT 4. +#[test] +fn revenue_and_expense_roles_move_even_with_a_balance() { + let (mut l, cash) = books(); + let cust = l.add_customer("Acme").unwrap(); + let ar = add(&mut l, "1200", "A/R", AccountType::Asset); + let _ = ar; + l.create_invoice( + cust, + d(2026, 3, 1), + d(2026, 3, 31), + vec![line("w", "60.00")], + ) + .unwrap(); + let rev_old = acct(&l, "4000").expect("revenue created"); + let v = l.add_vendor("V").unwrap(); + let ap = add(&mut l, "2000", "A/P", AccountType::Liability); + l.create_bill(v, d(2026, 3, 2), d(2026, 3, 2), vec![line("x", "15.00")]) + .unwrap(); + let exp_old = acct(&l, "6999").expect("general expense created"); + let _ = (ap, cash); + let before = entries(&l); + + let rev_new = add(&mut l, "4100", "Sales", AccountType::Revenue); + let exp_new = add(&mut l, "6500", "Misc", AccountType::Expense); + l.assign_role("revenue", Some(rev_new)).unwrap(); + l.assign_role("general_expense", Some(exp_new)).unwrap(); + assert_eq!(entries(&l), before, "posted entries unchanged"); + assert_eq!(bal(&l, rev_old), dec("60.00")); + assert_eq!(bal(&l, exp_old), dec("15.00")); + + l.create_invoice(cust, d(2026, 4, 1), d(2026, 4, 30), vec![line("w", "5.00")]) + .unwrap(); + l.create_bill(v, d(2026, 4, 2), d(2026, 4, 2), vec![line("x", "3.00")]) + .unwrap(); + assert_eq!(bal(&l, rev_old), dec("60.00")); + assert_eq!(bal(&l, rev_new), dec("5.00")); + assert_eq!(bal(&l, exp_old), dec("15.00")); + assert_eq!(bal(&l, exp_new), dec("3.00")); + + // Payroll expense roles move freely too. + let run = l.run_payroll(d(2026, 4, 30), vec![alice()], cash).unwrap(); + let _ = run; + let wages_old = acct(&l, "6100").unwrap(); + let wages_new = add(&mut l, "5100", "Wages", AccountType::Expense); + l.assign_role("salary_expense", Some(wages_new)).unwrap(); + assert_eq!(bal(&l, wages_old), dec("5000.00")); + // ... and back to its code, though 6100 holds a balance. + l.assign_role("salary_expense", None).unwrap(); + assert_eq!(l.role_account(&PAYROLL[0]), Some(wages_old)); +} + +// ---------------------------------------------- SCENARIO 5 / INVARIANT 4 + +/// Oracle: SCENARIO 5: re-assigning the account the role resolves to is +/// accepted and recorded, balance or not. +#[test] +fn reassigning_the_account_a_role_already_resolves_to_is_accepted() { + let (mut l, _) = books(); + let cust = l.add_customer("Acme").unwrap(); + l.create_invoice(cust, d(2026, 3, 1), d(2026, 3, 31), vec![line("w", "9.00")]) + .unwrap(); + let ar = acct(&l, "1200").unwrap(); + assert_eq!(l.role_assignment(&RECEIVABLES), None); + l.assign_role("receivables", Some(ar)).unwrap(); + assert_eq!(l.role_assignment(&RECEIVABLES), Some(ar), "recorded"); + l.assign_role("receivables", Some(ar)).unwrap(); + assert_eq!(l.role_assignment(&RECEIVABLES), Some(ar)); + // check_assign_role agrees and does not mutate. + let before = fingerprint(&l); + assert_eq!( + l.check_assign_role("receivables", Some(ar)).unwrap().key, + "receivables" + ); + assert_eq!(fingerprint(&l), before); +} + +/// Oracle: INVARIANT 4: an assignment changes no posted entry. +#[test] +fn posted_entries_are_unchanged_by_assignments() { + let (mut l, cash) = books(); + let cust = l.add_customer("Acme").unwrap(); + let inv = l + .create_invoice( + cust, + d(2026, 3, 1), + d(2026, 3, 31), + vec![line("w", "75.00")], + ) + .unwrap(); + l.receive_payment(inv, d(2026, 3, 5), dec("75.00"), cash) + .unwrap(); + let before = entries(&l); + let balances: Vec<_> = l.chart().all().map(|a| (a.id, bal(&l, a.id))).collect(); + // 1200 is back to zero, so receivables may move; revenue moves freely. + let ar = add(&mut l, "1100", "Trade A/R", AccountType::Asset); + let sales = add(&mut l, "4100", "Sales", AccountType::Revenue); + l.assign_role("receivables", Some(ar)).unwrap(); + l.assign_role("revenue", Some(sales)).unwrap(); + assert_eq!(entries(&l), before); + for (id, b) in balances { + assert_eq!(bal(&l, id), b); + } +} + +/// Oracle: check_assign_role is "the validation behind assign_role": it +/// agrees with assign_role on accept/refuse and changes nothing. +#[test] +fn check_assign_role_agrees_and_does_not_mutate() { + let (mut l, _) = books(); + let ar = add(&mut l, "1100", "Trade A/R", AccountType::Asset); + let exp = add(&mut l, "6500", "Misc", AccountType::Expense); + let before = fingerprint(&l); + assert!(l.check_assign_role("receivables", Some(ar)).is_ok()); + assert!(matches!( + l.check_assign_role("receivables", Some(exp)), + Err(LedgerError::ControlAccountMismatch { .. }) + )); + assert_eq!(fingerprint(&l), before); +} + +// ------------------------------------------- renumbering and the codes + +/// Oracle: B-23 + check_renumber_account ("an assigned role frees its +/// code"); renumber_account SCENARIO 2 for an unassigned role's code. +#[test] +fn an_assigned_role_frees_its_code_for_renumbering() { + let (mut l, _) = books(); + let ar = add(&mut l, "1100", "Trade A/R", AccountType::Asset); + let postage = add(&mut l, "6400", "Postage", AccountType::Expense); + let hosting = add(&mut l, "6450", "Hosting", AccountType::Expense); + + // Revenue is unassigned: its code still refuses an expense. + let before = fingerprint(&l); + assert!(matches!( + l.renumber_account(hosting, "4000"), + Err(LedgerError::ControlAccountMismatch { .. }) + )); + assert_eq!(fingerprint(&l), before); + // Receivables unassigned: 1200 refuses an expense too. + assert!(matches!( + l.renumber_account(postage, "1200"), + Err(LedgerError::ControlAccountMismatch { .. }) + )); + + l.assign_role("receivables", Some(ar)).unwrap(); + l.renumber_account(postage, "1200").unwrap(); + assert_eq!(code_of(&l, postage), "1200"); + // The role still resolves to its assignment and invoices go there. + assert_eq!(l.role_account(&RECEIVABLES), Some(ar)); + let cust = l.add_customer("Acme").unwrap(); + let inv = l + .create_invoice(cust, d(2026, 3, 1), d(2026, 3, 31), vec![line("w", "1.00")]) + .unwrap(); + let e = l.get_invoice(inv).unwrap().entry_id; + assert!(lines_by_code(&l, e).contains(&ent("1100", Side::Debit, "1.00"))); + assert_eq!(bal(&l, postage), Decimal::ZERO); + // An unassigned role's free code (payables, 2000) still refuses the + // expense. (4000 now holds the invoice's revenue account.) + assert!(matches!( + l.renumber_account(hosting, "2000"), + Err(LedgerError::ControlAccountMismatch { .. }) + )); + assert_eq!(code_of(&l, hosting), "6450"); +} + +// ------------------------------------------------------------- property + +#[derive(Clone, Debug)] +enum Op { + /// Assign role `0` (index into KEYS) the pool account `1`, or None. + Assign(usize, Option), + /// Post a small entry debiting pool account `0` and crediting `1` + /// (skipped if either is a placeholder or they are equal), so balances + /// come and go. + Move(usize, usize, u32), +} + +fn op() -> impl Strategy { + prop_oneof![ + 3 => (0..KEYS.len(), proptest::option::of(0..POOL)).prop_map(|(r, a)| Op::Assign(r, a)), + 1 => (0..POOL, 0..POOL, 1u32..500).prop_map(|(a, b, c)| Op::Move(a, b, c)), + ] +} + +const POOL: usize = 24; + +/// A pool of accounts of every type: a right-typed postable account at +/// most roles' default codes, two spares of each type, and a placeholder of +/// each type. +fn pool_books() -> (Ledger, Vec) { + use AccountType::*; + let mut l = Ledger::new(Currency::USD); + let mut ids = Vec::new(); + for c in control_accounts().take(7) { + ids.push(add(&mut l, c.code, c.name, c.account_type)); + } + for (i, t) in [Asset, Liability, Equity, Revenue, Expense] + .into_iter() + .enumerate() + { + ids.push(add(&mut l, &format!("{}", 8000 + 10 * i), "spare a", t)); + ids.push(add(&mut l, &format!("{}", 8001 + 10 * i), "spare b", t)); + ids.push( + l.add_account(&format!("{}", 8002 + 10 * i), "group", t, None, true) + .unwrap(), + ); + } + while ids.len() < POOL { + let n = ids.len(); + ids.push(add(&mut l, &format!("{}", 8500 + n), "extra", Expense)); + } + assert_eq!(ids.len(), POOL); + (l, ids) +} + +/// The reasons the spec gives to refuse an assignment, worked from the +/// current books (assign_role SCENARIOS 1–5). +fn predicted_refusals( + l: &Ledger, + c: &ControlAccount, + account: Option, +) -> Vec<&'static str> { + let mut why = Vec::new(); + let target = match account { + Some(id) => l.chart().get(id), + None => l.chart().find_by_code(c.code), + }; + if let Some(a) = target { + if a.account_type != c.account_type || a.placeholder { + why.push("mismatch"); + } + if control_accounts().any(|o| o.key != c.key && l.role_account(o) == Some(a.id)) { + why.push("taken"); + } + } + let now = l.role_account(c); + let next = target.map(|a| a.id); + if balance_sheet(c) + && next != now + && let Some(cur) = now + && !bal(l, cur).is_zero() + { + why.push("balance"); + } + why +} + +fn reason(e: &LedgerError) -> &'static str { + match e { + LedgerError::ControlAccountMismatch { .. } => "mismatch", + LedgerError::RoleTaken { .. } => "taken", + LedgerError::RoleHoldsBalance { .. } => "balance", + _ => "other", + } +} + +fn assert_role_invariants(l: &Ledger) { + // INVARIANT 2: no account fills two roles. + let filled: Vec = control_accounts() + .filter_map(|c| l.role_account(c)) + .collect(); + let distinct: BTreeSet<_> = filled.iter().copied().collect(); + assert_eq!(filled.len(), distinct.len(), "an account fills two roles"); + // INVARIANT 3: a role's account is postable and of its type. + for c in control_accounts() { + if let Some(id) = l.role_account(c) { + let a = l.chart().get(id).expect("role account exists"); + assert_eq!(a.account_type, c.account_type, "{}", c.key); + assert!(!a.placeholder, "{}", c.key); + } + } +} + +proptest! { + #![proptest_config(ProptestConfig::with_cases(256))] + + /// Oracle: assign_role INVARIANTS 2, 3, 5 and SCENARIOS 2–5, checked + /// after every command of a random sequence. + #[test] + fn role_invariants_hold_over_random_assignments(ops in proptest::collection::vec(op(), 1..40)) { + let (mut l, pool) = pool_books(); + assert_role_invariants(&l); + for o in ops { + match o { + Op::Assign(r, a) => { + let c = role(KEYS[r]); + let account = a.map(|i| pool[i]); + let why = predicted_refusals(&l, c, account); + let before = fingerprint(&l); + let entries_before = entries(&l); + match l.assign_role(c.key, account) { + Ok(()) => { + prop_assert!(why.is_empty(), "accepted {o:?} but the spec refuses: {why:?}"); + prop_assert_eq!(l.role_assignment(c), account); + let expect = match account { + Some(id) => Some(id), + None => acct(&l, c.code), + }; + prop_assert_eq!(l.role_account(c), expect); + prop_assert_eq!(entries(&l), entries_before); + } + Err(e) => { + prop_assert_eq!(fingerprint(&l), before, "refusal changed the books"); + prop_assert!( + why.contains(&reason(&e)), + "refused {:?} with {:?}; spec predicts {:?}", o, e, why + ); + } + } + } + Op::Move(a, b, cents) => { + let (da, cb) = (pool[a], pool[b]); + let ph = |id| l.chart().get(id).unwrap().placeholder; + if da == cb || ph(da) || ph(cb) { + continue; + } + let amt = Decimal::new(i64::from(cents), 2).to_string(); + post(&mut l, d(2026, 1, 1), &[(da, Side::Debit, &amt), (cb, Side::Credit, &amt)]); + } + } + assert_role_invariants(&l); + } + } +} diff --git a/crates/ledger-gui/src/pages/journal.rs b/crates/ledger-gui/src/pages/journal.rs index 7c98d1a..62da766 100644 --- a/crates/ledger-gui/src/pages/journal.rs +++ b/crates/ledger-gui/src/pages/journal.rs @@ -132,6 +132,28 @@ pub fn describe(b: &Books, line: &JournalLine) -> Option { .and_then(|v| v.as_u64()) .map(Route::Account), ), + "role.assigned" => { + let role = s(p, "role"); + let control = ledger_core::control_account(role); + let purpose = control.map_or(role, |c| c.role); + let mut chars = purpose.chars(); + let purpose: String = chars + .next() + .map(|f| f.to_uppercase().chain(chars).collect()) + .unwrap_or_default(); + let account = p.get("account_id").and_then(|v| v.as_u64()); + ev( + icon::TAG, + match account { + Some(id) => format!("{purpose} now posts to {}", b.account_label(id)), + None => format!( + "{purpose} back to its default code {}", + control.map_or("", |c| c.code) + ), + }, + account.map(Route::Account), + ) + } "account.tax_set" => ev( icon::TAG, format!( diff --git a/crates/ledger-mcp/src/main.rs b/crates/ledger-mcp/src/main.rs index 5287dd6..f6b151e 100644 --- a/crates/ledger-mcp/src/main.rs +++ b/crates/ledger-mcp/src/main.rs @@ -7,11 +7,12 @@ //! and optionally `config.json`. If no store exists, one is created with the //! configured base currency. Company settings (name, fiscal year start, //! receipt threshold) come from the books dir's `wanderbooks.toml`, by -//! default the one next to the data dir. +//! default the one next to the data dir, and reload whenever it changes +//! (settings.rs). -use ledger_config as company; mod policy; mod server; +mod settings; mod types; mod wire; @@ -94,22 +95,21 @@ fn main() -> Result<(), Box> { }; // Logs go to stderr: stdout is the MCP channel. eprintln!("ledger-mcp: data dir {}", data_dir.display()); - // An explicit --books-toml must exist; the default is optional. - let settings = match books_toml { - Some(path) => company::load(&path)?, - None => match data_dir.parent().map(|p| p.join("wanderbooks.toml")) { - Some(path) if path.exists() => company::load(&path)?, - _ => company::BooksSettings::default(), - }, + // An explicit --books-toml must exist; the default (beside the data + // dir) is optional. Either is reloaded whenever it changes. + let source = settings::Source { + required: books_toml.is_some(), + books_toml: books_toml.unwrap_or_else(|| { + data_dir + .parent() + .map(|p| p.join("wanderbooks.toml")) + .unwrap_or_else(|| PathBuf::from("wanderbooks.toml")) + }), + config_json: config_path.clone(), }; - eprintln!("ledger-mcp: company {:?}", settings.company.name); - let policy = company::effective_receipt_policy(&config_path, &settings)?; - let roles = server::CashRoles { - cash: settings.cash_accounts, - financing: settings.financing_accounts, - investing: settings.investing_accounts, - }; - let company = settings.company; + let live = settings::Live::watching(source)?; + eprintln!("ledger-mcp: company {:?}", live.current().company.name); + let live = std::sync::Arc::new(std::sync::Mutex::new(live)); let mut store = if data_dir.join(ledger_store::JOURNAL_FILE).exists() { open_waiting_for_lock(&data_dir)? @@ -134,14 +134,12 @@ fn main() -> Result<(), Box> { .build()?; if let Some(addr) = http_addr { eprintln!("ledger-mcp: store verified, serving Streamable HTTP on {addr}"); - rt.block_on(serve_http(addr, store, policy, company, roles))?; + rt.block_on(serve_http(addr, store, live))?; return Ok(()); } eprintln!("ledger-mcp: store verified, serving over stdio"); - let server = BooksServer::new(store, policy) - .with_company(company) - .with_cash_roles(roles); + let server = BooksServer::new(store, ReceiptPolicy::default()).with_live_settings(live); rt.block_on(async { // The wire answers what JSON-RPC requires that rmcp drops (wire.rs). let (input, output, writer) = wire::spawn(tokio::io::stdin(), tokio::io::stdout()); @@ -156,9 +154,7 @@ fn main() -> Result<(), Box> { async fn serve_http( addr: String, store: Store, - policy: ReceiptPolicy, - company: company::Company, - roles: server::CashRoles, + live: std::sync::Arc>, ) -> Result<(), Box> { use rmcp::transport::streamable_http_server::{ StreamableHttpService, session::local::LocalSessionManager, @@ -168,9 +164,8 @@ async fn serve_http( let store = Arc::new(std::sync::Mutex::new(store)); let service = StreamableHttpService::new( move || { - let server = server::BooksServer::new_shared(store.clone(), policy.clone()) - .with_company(company.clone()) - .with_cash_roles(roles.clone()); + let server = server::BooksServer::new_shared(store.clone(), ReceiptPolicy::default()) + .with_live_settings(live.clone()); Ok(server) }, Arc::new(LocalSessionManager::default()), diff --git a/crates/ledger-mcp/src/server.rs b/crates/ledger-mcp/src/server.rs index ed3f1d6..3279bb6 100644 --- a/crates/ledger-mcp/src/server.rs +++ b/crates/ledger-mcp/src/server.rs @@ -31,8 +31,10 @@ //! ``` use crate::policy::ReceiptPolicy; +use crate::settings::{Live, Settings}; use crate::types::*; use chrono::NaiveDate; +#[cfg(test)] use ledger_config::Company; use ledger_core::{ AccountId, AccountType, AssetStatus, Currency, DocumentLine, DocumentStatus, JournalEntry, @@ -52,7 +54,7 @@ use rust_decimal::Decimal; use std::sync::{Arc, Mutex}; /// The agent operating contract, delivered via initialize. -const INSTRUCTIONS: &str = "You are the bookkeeping agent for these books. The engine enforces the accounting; you enforce the workflow.\n\nRULES\n- Money is exact decimal. Always pass amounts as decimal strings (\"123.45\"), never floats.\n- Never post directly. The flow is draft (books.draft_entry) -> add lines (books.add_line) -> preview (books.post_entry without confirmed) -> review the preview -> post (books.post_entry with confirmed=true).\n- Every entry must balance: debits equal credits, exactly. Every line needs a real account from books.chart. The engine rejects anything else.\n- Receipts: any entry moving more than $25.00 in debits needs at least one receipt attached (books.attach_receipt) before posting. The server rejects the post otherwise. This is enforced, not a suggestion.\n- Never invent accounts. If the account you need does not exist, add it with books.add_account first.\n- Corrections go through books.reverse_entry. Posted entries are immutable; there is no edit.\n- Receipt corrections: receipts can change, but nothing is erased. To replace one, attach the new receipt, then books.unlink_receipt the old (with a reason); retire unwanted receipts or documents with books.withdraw_attachment (with a reason). The journal keeps every step.\n- After posting, check books.digest to review what changed, so a human can review too.\n- Amounts in every response are decimal strings. Treat them as exact.\n- Fixed assets: register with books.register_asset, inspect with books.asset_schedule and books.list_assets, post monthly depreciation with books.run_depreciation (exempt from the receipt policy; the schedule is the evidence), and dispose with books.dispose_asset (preview first; the bill of sale is the receipt). Depreciation is straight-line, book basis only - tax depreciation stays with the CPA.\n- Prepaid expenses and deferred revenue: record the original payment (with its receipt), then books.create_schedule; books.run_schedules posts the monthly amortization (preview first; exempt from the receipt policy like depreciation). Recurring entries (books.create_recurring, books.run_recurring) only ever create drafts: attach each actual receipt and post through the normal preview.\n- Platform payouts (app stores, payment processors, marketplaces): never book the net deposit as revenue. books.record_payout grosses it up (gross revenue, fees, adjustments) into a draft and refuses unless it reconciles to the cent; attach the payout statement, then preview and post.\n- The viewer: the human usually has the read-only viewer open beside you (`just gui`). When they ask to see something, and after you post, call books.show so it's on their screen.\n- 1099s: mark contractors and landlords with books.set_vendor_1099 (and whether their W-9 is on file; never store a TIN), and tag every payment to them with its vendor (books.draft_entry vendor, or books.set_entry_vendor; books.pay_bill tags automatically). books.vendor_1099 is the year-end worksheet for the CPA.\n- Rules that change with the law: wanderbooks knows some legal amounts (like the 1099 threshold) only through a given year and never guesses past it. When a tool refuses with \"wanderbooks knows the ... only through\", or books.digest lists stale_rules: check today's date, find the current figure from the official source (the refusal names the vendored source file) or ask the human (owner, CPA), confirm it with them, and pass it explicitly. Then update the rule for everyone: open a pull request at https://github.com/swaits/wanderbooks with the new value and its official text (best), or at least an issue; if you can't reach GitHub, give the human the issue text to file.\n- Control accounts: invoices, bills, payroll, and closes post to accounts found by code (1200, 2000, 4000, 6999, 3999, 6100-2150). If the chart already uses one of those codes for a different kind of account, the engine refuses and says which. It can't tell an account of the right type but another purpose (a hosting expense at payroll's 6100): check the names before running payroll, bills, or invoices. Never renumber the human's accounts on your own: explain the conflict, and when they decide, books.renumber_account moves the account (its history stays)."; +const INSTRUCTIONS: &str = "You are the bookkeeping agent for these books. The engine enforces the accounting; you enforce the workflow.\n\nRULES\n- Money is exact decimal. Always pass amounts as decimal strings (\"123.45\"), never floats.\n- Never post directly. The flow is draft (books.draft_entry) -> add lines (books.add_line) -> preview (books.post_entry without confirmed) -> review the preview -> post (books.post_entry with confirmed=true).\n- Every entry must balance: debits equal credits, exactly. Every line needs a real account from books.chart. The engine rejects anything else.\n- Receipts: any entry moving more than $25.00 in debits needs at least one receipt attached (books.attach_receipt) before posting. The server rejects the post otherwise. This is enforced, not a suggestion.\n- Never invent accounts. If the account you need does not exist, add it with books.add_account first.\n- Corrections go through books.reverse_entry. Posted entries are immutable; there is no edit.\n- Receipt corrections: receipts can change, but nothing is erased. To replace one, attach the new receipt, then books.unlink_receipt the old (with a reason); retire unwanted receipts or documents with books.withdraw_attachment (with a reason). The journal keeps every step.\n- After posting, check books.digest to review what changed, so a human can review too.\n- Amounts in every response are decimal strings. Treat them as exact.\n- Fixed assets: register with books.register_asset, inspect with books.asset_schedule and books.list_assets, post monthly depreciation with books.run_depreciation (exempt from the receipt policy; the schedule is the evidence), and dispose with books.dispose_asset (preview first; the bill of sale is the receipt). Depreciation is straight-line, book basis only - tax depreciation stays with the CPA.\n- Prepaid expenses and deferred revenue: record the original payment (with its receipt), then books.create_schedule; books.run_schedules posts the monthly amortization (preview first; exempt from the receipt policy like depreciation). Recurring entries (books.create_recurring, books.run_recurring) only ever create drafts: attach each actual receipt and post through the normal preview.\n- Platform payouts (app stores, payment processors, marketplaces): never book the net deposit as revenue. books.record_payout grosses it up (gross revenue, fees, adjustments) into a draft and refuses unless it reconciles to the cent; attach the payout statement, then preview and post.\n- The viewer: the human usually has the read-only viewer open beside you (`just gui`). When they ask to see something, and after you post, call books.show so it's on their screen.\n- 1099s: mark contractors and landlords with books.set_vendor_1099 (and whether their W-9 is on file; never store a TIN), and tag every payment to them with its vendor (books.draft_entry vendor, or books.set_entry_vendor; books.pay_bill tags automatically). books.vendor_1099 is the year-end worksheet for the CPA.\n- Rules that change with the law: wanderbooks knows some legal amounts (like the 1099 threshold) only through a given year and never guesses past it. When a tool refuses with \"wanderbooks knows the ... only through\", or books.digest lists stale_rules: check today's date, find the current figure from the official source (the refusal names the vendored source file) or ask the human (owner, CPA), confirm it with them, and pass it explicitly. Then update the rule for everyone: open a pull request at https://github.com/swaits/wanderbooks with the new value and its official text (best), or at least an issue; if you can't reach GitHub, give the human the issue text to file.\n- Control roles: invoices, bills, payroll, and closes post to the account filling each role (receivables, revenue, payables, general expense, retained earnings, payroll expense and withholdings); books.roles lists them. A role uses its default code (1200, 4000, 2000, 6999, 3999, 6100, 6110, 2110-2150) until it is assigned one of the chart's own accounts with books.assign_role (journaled). If the chart uses a default code for a different kind of account, the engine refuses and says which. It can't tell an account of the right type but another purpose (a hosting expense at payroll's 6100): check books.roles before running payroll, bills, or invoices. Never assign roles or renumber the human's accounts on your own: explain the conflict, and when they decide, assign the role to their account (best: their numbering stays) or books.renumber_account the account (its history stays).\n- Settings: wanderbooks.toml beside the books holds the company's facts and settings (name, fiscal year, tax form, members and their capital accounts, basis, receipt threshold, cash flow roles, close target); books.settings shows what is in force and lists problems, settings that disagree with the chart. Edit the file when the human tells you a company fact, when a tool refuses naming a wanderbooks.toml setting, when books.settings or books.digest lists a settings problem, and after books.renumber_account lists config_mentions. The server reloads the file whenever it changes, with no restart: after each edit, call books.settings and check reload_error is null (a broken edit leaves the last good settings in force) and problems is empty. Never put a member's SSN or any TIN in it."; /// Max receipt/document upload: 25 MB. const MAX_UPLOAD_BYTES: usize = 25 * 1024 * 1024; @@ -63,40 +65,35 @@ const MAX_UPLOAD_BYTES: usize = 25 * 1024 * 1024; #[derive(Clone)] pub struct BooksServer { store: Arc>, - policy: ReceiptPolicy, - company: Company, - roles: CashRoles, + settings: Arc>, } -/// `[books]` account roles for the cash flow statement, used when a call -/// doesn't pass its own: cash, financing, and investing account codes. -#[derive(Clone, Debug, Default)] -pub struct CashRoles { - /// `[books] cash_accounts`. - pub cash: Vec, - /// `[books] financing_accounts`. - pub financing: Vec, - /// `[books] investing_accounts`. - pub investing: Vec, -} +#[cfg(test)] +pub use crate::settings::CashRoles; impl BooksServer { /// The wanderbooks.toml settings that name account `code`. fn config_mentions(&self, code: &str) -> Vec { let mut out = Vec::new(); for (key, codes) in [ - ("[books] cash_accounts", &self.roles.cash), - ("[books] financing_accounts", &self.roles.financing), - ("[books] investing_accounts", &self.roles.investing), + ("[books] cash_accounts", &self.settings().roles.cash), + ( + "[books] financing_accounts", + &self.settings().roles.financing, + ), + ( + "[books] investing_accounts", + &self.settings().roles.investing, + ), ] { if codes.iter().any(|c| c == code) { out.push(key.to_string()); } } - if self.company.close_into.as_deref() == Some(code) { + if self.settings().company.close_into.as_deref() == Some(code) { out.push("[company] close_into".to_string()); } - for m in &self.company.members { + for m in &self.settings().company.members { if m.capital_account == code { out.push(format!("[[company.members]] {} capital_account", m.name)); } @@ -112,12 +109,7 @@ impl BooksServer { /// Build the server around an open store. pub fn new(store: Store, policy: ReceiptPolicy) -> Self { - Self { - store: Arc::new(Mutex::new(store)), - policy, - company: Company::default(), - roles: CashRoles::default(), - } + Self::new_shared(Arc::new(Mutex::new(store)), policy) } /// Build the server around a shared store (for HTTP: each connection @@ -125,24 +117,46 @@ impl BooksServer { pub fn new_shared(store: Arc>, policy: ReceiptPolicy) -> Self { Self { store, - policy, - company: Company::default(), - roles: CashRoles::default(), + settings: Arc::new(Mutex::new(Live::fixed(Settings { + policy, + ..Settings::default() + }))), } } + /// Take the settings from files, reloading them whenever they change + /// (crate::settings), shared with the other sessions holding `live`. + pub fn with_live_settings(mut self, live: Arc>) -> Self { + self.settings = live; + self + } + /// Set the company (name for report headers, fiscal year). - pub fn with_company(mut self, company: Company) -> Self { - self.company = company; + #[cfg(test)] + pub fn with_company(self, company: Company) -> Self { + self.live().edit(|s| s.company = company); self } /// Set the `[books]` cash flow account roles. - pub fn with_cash_roles(mut self, roles: CashRoles) -> Self { - self.roles = roles; + #[cfg(test)] + pub fn with_cash_roles(self, roles: CashRoles) -> Self { + self.live().edit(|s| s.roles = roles); self } + fn live(&self) -> std::sync::MutexGuard<'_, Live> { + // A panic while holding it can't leave the settings half-written + // (they're swapped whole), so a poisoned lock is still good. + self.settings.lock().unwrap_or_else(|e| e.into_inner()) + } + + /// The settings in force for this request (reloaded at its start, in + /// `call_tool` and `read_resource`, if their files changed). + fn settings(&self) -> Arc { + self.live().current() + } + /// The cash flow statement from account codes, each list defaulting to /// the `[books]` roles when the call passes none. fn cash_flow_for( @@ -165,17 +179,17 @@ impl BooksServer { ledger, start, end, - &or(cash, &self.roles.cash), - &or(financing, &self.roles.financing), - &or(investing, &self.roles.investing), + &or(cash, &self.settings().roles.cash), + &or(financing, &self.settings().roles.financing), + &or(investing, &self.settings().roles.investing), ) } /// Header and provenance for a rendered report. fn report_meta(&self, store: &Store) -> ReportMeta { ReportMeta { - company: self.company.name.clone(), - basis: self.company.basis.clone(), + company: self.settings().company.name.clone(), + basis: self.settings().company.basis.clone(), prepared_on: today(), journal_seq: store.journal_seq(), } @@ -183,7 +197,8 @@ impl BooksServer { /// Members as reports name them (empty unless a multi-member LLC). fn report_members(&self) -> Vec { - self.company + self.settings() + .company .members .iter() .map(|m| ledger_import::Member { @@ -197,13 +212,14 @@ impl BooksServer { /// Members with their capital accounts resolved in the chart, for the /// statement of members' equity. fn member_capitals(&self, ledger: &Ledger) -> Result, String> { - if self.company.members.is_empty() { + if self.settings().company.members.is_empty() { return Err( "no members configured: add [[company.members]] (name, share, capital_account) to wanderbooks.toml" .to_string(), ); } - self.company + self.settings() + .company .members .iter() .map(|m| { @@ -250,7 +266,7 @@ impl BooksServer { .map(|a| a.id) .ok_or_else(|| format!("wanderbooks.toml names unknown account {c}")) }; - if !self.company.members.is_empty() { + if !self.settings().company.members.is_empty() { return Ok(ledger_core::CloseTo::Members( self.member_capitals(ledger)? .into_iter() @@ -263,12 +279,13 @@ impl BooksServer { )); } let sweep = self + .settings() .company .close_sweep .iter() .map(|c| code(c)) .collect::, _>>()?; - Ok(match self.company.close_into.as_deref() { + Ok(match self.settings().company.close_into.as_deref() { None | Some(ledger_core::RETAINED_EARNINGS_CODE) => { ledger_core::CloseTo::RetainedEarnings { sweep } } @@ -288,7 +305,7 @@ impl BooksServer { ledger: &Ledger, as_of: NaiveDate, ) -> Result { - let (fy_start, _) = self.company.fiscal_year_containing(as_of); + let (fy_start, _) = self.settings().company.fiscal_year_containing(as_of); let net_income_ytd = ledger_core::profit_and_loss_for_period(ledger, fy_start, as_of, None) .map_err(|e| e.to_string())? .net_income(); @@ -339,8 +356,16 @@ impl BooksServer { if entry.status() != ledger_core::EntryStatus::Posted || entry.reversal_of().is_some() { return Ok(()); } - let split_group = self.policy.split_group(store.ledger(), entry_id, entry); - if !self.policy.receipt_required(store.ledger(), entry) && split_group.is_empty() { + let split_group = self + .settings() + .policy + .split_group(store.ledger(), entry_id, entry); + if !self + .settings() + .policy + .receipt_required(store.ledger(), entry) + && split_group.is_empty() + { return Ok(()); } let total = ReceiptPolicy::entry_debits(entry); @@ -349,12 +374,12 @@ impl BooksServer { message: format!( "entry {entry_id} moves {} in debits ({}); unlinking its last receipt would leave it without one.", money(total), - self.policy.describe() + self.settings().policy.describe() ), entry_id: Some(entry_id), asset_id: None, debits: total.to_string(), - policy: self.policy.describe(), + policy: self.settings().policy.describe(), remedy_tool: "books.attach_receipt".to_string(), remedy_hint: format!( "attach the replacement receipt to entry {entry_id} with books.attach_receipt first, then retry books.unlink_receipt" @@ -388,6 +413,78 @@ fn parse_side(s: &str) -> Result { } } +/// 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). +fn settings_problems(ledger: &Ledger, cfg: &Settings) -> Vec { + let mut out = Vec::new(); + 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} in wanderbooks.toml" + )), + Some(a) if !want.contains(&a.account_type) => out.push(format!( + "{setting} names {code} \"{}\", a {:?} account, but needs {}: change {setting} in wanderbooks.toml", + a.name, + a.account_type, + want.iter().map(|t| format!("{t:?}")).collect::>().join(" or "), + )), + Some(_) => {} + } + }; + use AccountType::*; + for code in &cfg.roles.cash { + check("[books] cash_accounts", code, &[Asset]); + } + for code in &cfg.roles.financing { + check("[books] financing_accounts", code, &[Liability, Equity]); + } + for code in &cfg.roles.investing { + check("[books] investing_accounts", code, &[Asset]); + } + let c = &cfg.company; + for m in &c.members { + check( + &format!("[[company.members]] {} capital_account", m.name), + &m.capital_account, + &[Equity], + ); + if let Some(d) = &m.distribution_account { + check( + &format!("[[company.members]] {} distribution_account", 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]); + } + out +} + +/// 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"); @@ -1066,11 +1163,17 @@ impl BooksServer { )); } } - let mut receipt_required = self.policy.receipt_required(store.ledger(), &entry); + 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.policy.split_group(store.ledger(), p.entry_id, &entry); + let split_group = self + .settings() + .policy + .split_group(store.ledger(), p.entry_id, &entry); if !split_group.is_empty() { receipt_required = true; } @@ -1105,13 +1208,13 @@ impl BooksServer { "entry {} moves {} in debits ({}); attach a receipt with books.attach_receipt before posting.{}", p.entry_id, money(total), - self.policy.describe(), + self.settings().policy.describe(), split_note ), entry_id: Some(p.entry_id), asset_id: None, debits: total.to_string(), - policy: self.policy.describe(), + 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", @@ -1706,7 +1809,7 @@ impl BooksServer { let receipts = store .list_attachments(Some(e.id())) .map_err(|e| e.to_string())?; - let required = self.policy.receipt_required(ledger, e); + let required = self.settings().policy.receipt_required(ledger, e); entries.push(DigestEntry { entry_id: e.id(), date: e.date().to_string(), @@ -1761,6 +1864,16 @@ impl BooksServer { totals_by_account: totals, stale_rules, journal_repairs, + settings_problems: { + let mut problems = settings_problems(store.ledger(), &self.settings()); + if let Some(e) = self.live().status().error { + problems.insert( + 0, + format!("wanderbooks.toml didn't load (the last good settings are in force): {e}"), + ); + } + problems + }, })) } @@ -2346,7 +2459,7 @@ impl BooksServer { annotations(read_only_hint = true) )] async fn tax_categories(&self) -> Result, String> { - let form = self.company.tax_form; + 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))) @@ -2398,6 +2511,80 @@ impl BooksServer { })) } + #[tool( + name = "books.settings", + description = "The settings in force from wanderbooks.toml (company, fiscal year, tax form, members, basis, receipt rule, cash flow roles, close target), the file they come from, and problems: settings that disagree with the chart, each saying what to change. The server reloads the file whenever it changes, with no restart; after editing it, call this to confirm the edit loaded (reload_error says why one didn't, and the last good settings stay in force).", + annotations(read_only_hint = true) + )] + async fn settings_tool(&self) -> Result, String> { + let status = self.live().status(); + let cfg = self.settings(); + let store = self.lock()?; + let c = &cfg.company; + Ok(Json(SettingsOut { + books_toml: status.books_toml.map(|p| p.display().to_string()), + books_toml_exists: status.books_toml_exists, + config_json: status.config_json.map(|p| p.display().to_string()), + reload_error: status.error, + loads: status.loads, + 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(), + 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(), + }) + .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", + 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) + )] + async fn roles(&self) -> Result, String> { + let store = self.lock()?; + Ok(Json(RolesOut { + roles: roles_out(store.ledger()), + })) + } + + #[tool( + name = "books.assign_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." + )] + 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", description = "Map a revenue or expense account to a tax category (see books.tax_categories), optionally overriding its deductible percent; category null clears it. Drives the tax_summary report." @@ -2420,7 +2607,7 @@ impl BooksServer { .get(id) .expect("resolved") .account_type; - ledger_core::tax::check_category(cat, t, self.company.tax_form) + ledger_core::tax::check_category(cat, t, self.settings().company.tax_form) .map_err(|e| e.to_string())?; } store @@ -2470,8 +2657,12 @@ impl BooksServer { .add_account(&row.code, &row.name, account_type, parent, false) .map_err(|e| format!("{}: {e}", row.code))?; if let Some(cat) = &row.tax_category { - ledger_core::tax::check_category(cat, account_type, self.company.tax_form) - .map_err(|e| format!("{}: {e}", row.code))?; + ledger_core::tax::check_category( + cat, + account_type, + self.settings().company.tax_form, + ) + .map_err(|e| format!("{}: {e}", row.code))?; scratch .set_account_tax(id, Some(cat), None) .map_err(|e| format!("{}: {e}", row.code))?; @@ -2677,7 +2868,7 @@ impl BooksServer { "tax_summary" => { let (start, end) = statement_range(ledger, p.start.as_deref(), p.end.as_deref())?; report::tax_summary( - &ledger_core::tax_summary(ledger, start, end, self.company.tax_form) + &ledger_core::tax_summary(ledger, start, end, self.settings().company.tax_form) .map_err(|e| e.to_string())?, ) } @@ -2745,12 +2936,14 @@ impl BooksServer { .registered_assets() .flat_map(|a| [a.cost_account, a.accum_depr_account]) .collect(); + let receivables = ledger.role_account(&ledger_core::RECEIVABLES); + let payables = ledger.role_account(&ledger_core::PAYABLES); let accounts = iif_accounts(ledger, |a| { - if self.roles.cash.contains(&a.code) { + if self.settings().roles.cash.contains(&a.code) { Qb::Bank - } else if a.code == ledger_core::RECEIVABLES.code { + } else if Some(a.id) == receivables { Qb::AccountsReceivable - } else if a.code == ledger_core::PAYABLES.code { + } else if Some(a.id) == payables { Qb::AccountsPayable } else if fixed.contains(&a.id) { Qb::FixedAsset @@ -3003,7 +3196,10 @@ impl BooksServer { } else { Decimal::ZERO }; - let receipt_required = self.policy.receipt_required_for(&code_refs, debits); + let receipt_required = self + .settings() + .policy + .receipt_required_for(&code_refs, debits); let out = DisposeAssetOut { status: "preview".to_string(), @@ -3028,12 +3224,12 @@ impl BooksServer { "disposing {} moves {} in debits ({}); file the bill of sale with books.file_document, then retry with receipt_attachment_ids", asset.description, money(debits), - self.policy.describe() + self.settings().policy.describe() ), entry_id: None, asset_id: Some(p.asset_id), debits: debits.to_string(), - policy: self.policy.describe(), + 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=[]" @@ -3552,15 +3748,12 @@ impl BooksServer { let mut store = self.lock()?; let cash = resolve_account(&store, &p.cash_account)?; // Total before remitting, for the response. - let mut total = Decimal::ZERO; - for code in ["2110", "2120", "2130", "2140", "2150"] { - if let Some(a) = store.ledger().chart().find_by_code(code) { - total += store - .ledger() - .account_balance(a.id) - .map_err(|e| e.to_string())?; - } - } + 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())?; @@ -3755,7 +3948,7 @@ impl BooksServer { let ledger = store.ledger(); // Fiscal year to date, per wanderbooks.toml's fiscal_year_start. - let (fy_start, _) = self.company.fiscal_year_containing(as_of); + let (fy_start, _) = self.settings().company.fiscal_year_containing(as_of); let (fy_start, as_of_s) = (fy_start.to_string(), as_of.to_string()); let xlsx = |r: XlsxReport<'_>| report_to_xlsx(&r, &meta).map_err(|e| e.to_string()); let pdf = @@ -3797,7 +3990,7 @@ impl BooksServer { ), ), ]; - if !self.company.members.is_empty() { + if !self.settings().company.members.is_empty() { let me = ledger_core::members_equity(ledger, fy, as_of, &self.member_capitals(ledger)?) .map_err(|e| e.to_string())?; statements.push(("members-equity", report::members_equity(&me))); @@ -3805,7 +3998,7 @@ impl BooksServer { statements.push(( "tax-summary", report::tax_summary( - &ledger_core::tax_summary(ledger, fy, as_of, self.company.tax_form) + &ledger_core::tax_summary(ledger, fy, as_of, self.settings().company.tax_form) .map_err(|e| e.to_string())?, ), )); @@ -3922,9 +4115,9 @@ impl BooksServer { )); let readme = format!( "{name} — books package for the CPA\n\nFiscal year to date: {fy_start} through {as_of_s}. {basis} basis.\nKept in wanderbooks: double-entry books from an append-only, hash-chained journal ({seq} events, verified).\n\nStatements (PDF and XLSX): profit and loss, balance sheet, cash flow, {members}tax summary, trial balance.\nLedgers (XLSX): general ledger by account; general journal entry by entry.\ngeneral-journal.iif: every posted entry, for import into QuickBooks Desktop.\n1099-worksheet-{year}.csv: cash paid per vendor marked for a 1099, against the year's threshold.\n{receipts}\nThe tax summary maps accounts to return categories; it's a worksheet, not a filing. Questions go to the owners, who can ask their bookkeeping agent to trace any number to its entries and receipts.\n", - name = self.company.name, - basis = self.company.basis, - members = if self.company.members.is_empty() { + name = self.settings().company.name, + basis = self.settings().company.basis, + members = if self.settings().company.members.is_empty() { "" } else { "members' equity, " @@ -4116,7 +4309,7 @@ impl BooksServer { let store = self.lock()?; let ledger = store.ledger(); let (start, end) = statement_range(ledger, p.start.as_deref(), p.end.as_deref())?; - let ts = ledger_core::tax_summary(ledger, start, end, self.company.tax_form) + 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!({ @@ -4443,6 +4636,17 @@ impl BooksServer { #[tool_handler] impl ServerHandler for BooksServer { + async fn call_tool( + &self, + request: rmcp::model::CallToolRequestParams, + context: RequestContext, + ) -> Result { + // Pick up an edited wanderbooks.toml before the tool runs. + self.live().refresh(); + let tcc = rmcp::handler::server::tool::ToolCallContext::new(self, request, context); + Self::tool_router().call(tcc).await + } + fn get_info(&self) -> ServerConfig { ServerConfig::new( ServerCapabilities::builder() @@ -4453,7 +4657,7 @@ impl ServerHandler for BooksServer { // Tell the agent the policy these books actually use. .with_instructions(INSTRUCTIONS.replace( "any entry moving more than $25.00 in debits needs at least one receipt", - &self.policy.rule(), + &self.settings().policy.rule(), )) .with_server_info(Implementation::new( "wanderbooks", @@ -4513,6 +4717,7 @@ impl ServerHandler for BooksServer { request: ReadResourceRequestParams, _context: RequestContext, ) -> Result { + self.live().refresh(); let uri = request.uri.clone(); let text = self.resource_text(&uri).await.map_err(|e| { rmcp::ErrorData::resource_not_found(uri.clone(), Some(serde_json::json!({"reason": e}))) diff --git a/crates/ledger-mcp/src/settings.rs b/crates/ledger-mcp/src/settings.rs new file mode 100644 index 0000000..34f27fe --- /dev/null +++ b/crates/ledger-mcp/src/settings.rs @@ -0,0 +1,283 @@ +//! The server's settings (`wanderbooks.toml`, and the receipt policy in +//! `books-data/config.json`), reloaded whenever their files change. +//! +//! ```text +//! GOAL: an edit to wanderbooks.toml takes effect on the next tool call, +//! with no restart, and a broken edit never takes the server down. +//! SOURCE: DECISION (Stephen, 2026-10-15): the configuration is always +//! auto-reloaded. +//! INVARIANTS: +//! 1. Every request (tool call, resource read) sees the files' contents +//! as of its start, one version for the whole request: a request after +//! an edit sees the edit (compared by content, not by timestamp, so two +//! edits in one second are both seen). +//! 2. A file that fails to load leaves the last good settings in force, +//! and the failure is reported (books.settings, stderr) until a later +//! edit loads. +//! 3. A settings file that doesn't exist and wasn't asked for (the +//! default wanderbooks.toml beside the books) means the defaults; one +//! named with --books-toml that goes missing is a failure (2). +//! 4. Only the settings reload: the data dir and the books' base currency +//! are fixed when the server starts. +//! ``` + +use crate::policy::ReceiptPolicy; +use ledger_config::Company; +use std::path::PathBuf; +use std::sync::Arc; + +/// `[books]` account roles for the cash flow statement, used when a call +/// doesn't pass its own: cash, financing, and investing account codes. +#[derive(Clone, Debug, Default)] +pub struct CashRoles { + /// `[books] cash_accounts`. + pub cash: Vec, + /// `[books] financing_accounts`. + pub financing: Vec, + /// `[books] investing_accounts`. + pub investing: Vec, +} + +/// What the settings files say, as last loaded. +#[derive(Clone, Debug, Default)] +pub struct Settings { + /// Which entries need a receipt. + pub policy: ReceiptPolicy, + /// `[company]`. + pub company: Company, + /// `[books]` cash flow roles. + pub roles: CashRoles, +} + +/// Where the settings come from. +#[derive(Clone, Debug)] +pub struct Source { + /// The books' wanderbooks.toml. + pub books_toml: PathBuf, + /// Whether it must exist (named with --books-toml) or may be absent + /// (the default beside the books: then the defaults apply). + pub required: bool, + /// `config.json`, for its receipt policy (optional). + pub config_json: PathBuf, +} + +/// Both files' bytes (`None`: absent), to tell when either changed. +type Seen = (Option>, Option>); + +impl Source { + fn read(&self) -> Seen { + ( + std::fs::read(&self.books_toml).ok(), + std::fs::read(&self.config_json).ok(), + ) + } + + /// Load the settings now. + pub fn load(&self) -> Result { + let books = if self.required || self.books_toml.exists() { + ledger_config::load(&self.books_toml)? + } else { + ledger_config::BooksSettings::default() + }; + let policy = ledger_config::effective_receipt_policy(&self.config_json, &books)?; + Ok(Settings { + policy, + roles: CashRoles { + cash: books.cash_accounts, + financing: books.financing_accounts, + investing: books.investing_accounts, + }, + company: books.company, + }) + } +} + +/// The settings in force, and how they got there. +#[derive(Debug)] +pub struct Live { + current: Arc, + source: Option, + seen: Seen, + error: Option, + loads: u64, +} + +/// What books.settings reports about the files. +#[derive(Clone, Debug)] +pub struct Status { + /// The wanderbooks.toml read, if the settings come from files. + pub books_toml: Option, + /// Whether that file exists now. + pub books_toml_exists: bool, + /// The config.json read. + pub config_json: Option, + /// Why the last change failed to load (the last good settings stay in + /// force), or `None`. + pub error: Option, + /// How many times the settings have loaded, the first included. + pub loads: u64, +} + +impl Live { + /// Settings that never reload (tests, and servers built in code). + pub fn fixed(settings: Settings) -> Self { + Self { + current: Arc::new(settings), + source: None, + seen: (None, None), + error: None, + loads: 1, + } + } + + /// Load from `source`, and reload from it whenever it changes. Fails + /// only on this first load: a server doesn't start on broken settings. + pub fn watching(source: Source) -> Result { + let seen = source.read(); + let settings = source.load()?; + Ok(Self { + current: Arc::new(settings), + source: Some(source), + seen, + error: None, + loads: 1, + }) + } + + /// The settings in force. + pub fn current(&self) -> Arc { + self.current.clone() + } + + /// Reload if a file changed since the last look: the server calls this + /// once at the start of every request, so the whole request sees one + /// version. + pub fn refresh(&mut self) { + if let Some(source) = &self.source { + let now = source.read(); + if now != self.seen { + self.seen = now; + match source.load() { + Ok(s) => { + self.current = Arc::new(s); + self.error = None; + self.loads += 1; + eprintln!("ledger-mcp: reloaded {}", source.books_toml.display()); + } + Err(e) => { + eprintln!( + "ledger-mcp: settings not reloaded (keeping the last good ones): {e}" + ); + self.error = Some(e); + } + } + } + } + } + + /// Change the settings in force (builders; never reloads them away + /// unless a watched file changes). + #[cfg(test)] + pub fn edit(&mut self, f: impl FnOnce(&mut Settings)) { + f(Arc::make_mut(&mut self.current)); + } + + /// Where the settings come from and whether the last change loaded. + pub fn status(&mut self) -> Status { + self.refresh(); + Status { + books_toml: self.source.as_ref().map(|s| s.books_toml.clone()), + books_toml_exists: self.source.as_ref().is_some_and(|s| s.books_toml.exists()), + config_json: self.source.as_ref().map(|s| s.config_json.clone()), + error: self.error.clone(), + loads: self.loads, + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn dir(tag: &str) -> PathBuf { + let d = + std::env::temp_dir().join(format!("ledger-mcp-settings-{}-{tag}", std::process::id())); + let _ = std::fs::remove_dir_all(&d); + std::fs::create_dir_all(&d).unwrap(); + d + } + + fn source(d: &std::path::Path, required: bool) -> Source { + Source { + books_toml: d.join("wanderbooks.toml"), + required, + config_json: d.join("config.json"), + } + } + + /// INVARIANT 3: an absent default means the defaults, and the file + /// loads once it appears; an unchanged file is not reloaded. + #[test] + fn an_absent_default_is_the_defaults_until_it_appears() { + let d = dir("absent"); + let mut live = Live::watching(source(&d, false)).unwrap(); + assert_eq!(live.current().company.name, Company::default().name); + live.refresh(); + assert_eq!(live.status().loads, 1, "nothing changed"); + std::fs::write( + d.join("wanderbooks.toml"), + "[company]\nname = \"Late Co\"\n", + ) + .unwrap(); + live.refresh(); + assert_eq!(live.current().company.name, "Late Co"); + assert_eq!(live.status().loads, 2); + assert!(live.status().books_toml_exists); + } + + /// INVARIANT 3: a named file must exist to start; INVARIANT 2: the + /// first load fails on broken settings, a later one keeps the last. + #[test] + fn a_named_file_must_load_to_start() { + let d = dir("named"); + assert!(Live::watching(source(&d, true)).is_err()); + std::fs::write(d.join("wanderbooks.toml"), "[books\n").unwrap(); + assert!(Live::watching(source(&d, true)).is_err()); + std::fs::write(d.join("wanderbooks.toml"), "[company]\nname = \"One\"\n").unwrap(); + let mut live = Live::watching(source(&d, true)).unwrap(); + std::fs::write(d.join("wanderbooks.toml"), "[company]\nname = 5\n").unwrap(); + live.refresh(); + assert_eq!(live.current().company.name, "One"); + assert!(live.status().error.is_some()); + } + + /// The receipt policy in config.json is watched too. + #[test] + fn the_config_json_policy_reloads() { + let d = dir("json"); + std::fs::write( + d.join("wanderbooks.toml"), + "[books]\nreceipt_threshold = \"25.00\"\n", + ) + .unwrap(); + let mut live = Live::watching(source(&d, true)).unwrap(); + let before = live.current().policy.rule(); + std::fs::write( + d.join("config.json"), + r#"{"receipt_policy": {"require_receipts_above": "0.00"}}"#, + ) + .unwrap(); + live.refresh(); + assert_ne!(live.current().policy.rule(), before); + assert!(live.status().error.is_none(), "{:?}", live.status().error); + } + + /// Settings built in code never reload. + #[test] + fn fixed_settings_stay() { + let mut live = Live::fixed(Settings::default()); + live.refresh(); + let s = live.status(); + assert_eq!((s.loads, s.books_toml), (1, None)); + } +} diff --git a/crates/ledger-mcp/src/types.rs b/crates/ledger-mcp/src/types.rs index f8c6aef..1430fa5 100644 --- a/crates/ledger-mcp/src/types.rs +++ b/crates/ledger-mcp/src/types.rs @@ -499,10 +499,106 @@ pub struct RenumberAccountOut { /// The code it has now. pub code: String, /// Settings in wanderbooks.toml that still name the old code: change - /// each to the new one, then restart the server. + /// each to the new one (the server reloads the file). pub config_mentions: Vec, } +/// One member, as `books.settings` reports it. +#[derive(Debug, Serialize, JsonSchema)] +pub struct MemberSettingOut { + /// Name. + pub name: String, + /// Share of income, as a decimal string ("0.5"). + pub share: String, + /// Capital account code. + pub capital_account: String, + /// Distributions account code, if any. + pub distribution_account: Option, +} + +/// Result of `books.settings`. +#[derive(Debug, Serialize, JsonSchema)] +pub struct SettingsOut { + /// The wanderbooks.toml the server reads (null: settings built in, not + /// from a file). + pub books_toml: Option, + /// Whether that file exists (absent: the defaults apply). + pub books_toml_exists: bool, + /// The config.json read for a receipt policy. + pub config_json: Option, + /// Why the last edit failed to load, or null. While set, the settings + /// below are the last ones that loaded: fix the file. + pub reload_error: Option, + /// How many times the settings have loaded (the first included); it + /// goes up each time an edit loads. + pub loads: u64, + /// `[company] name`. + pub company: String, + /// `[company] fiscal_year_start`, "MM-DD". + pub fiscal_year_start: String, + /// The return filed (`tax_form`, or from `tax_classification`), or null. + pub tax_form: Option, + /// `[books] accounting_basis`. + pub basis: String, + /// The receipt rule in force. + pub receipt_rule: String, + /// `[[company.members]]`. + pub members: Vec, + /// `[books] cash_accounts`. + pub cash_accounts: Vec, + /// `[books] financing_accounts`. + pub financing_accounts: Vec, + /// `[books] investing_accounts`. + pub investing_accounts: Vec, + /// `[books] close_into`. + pub close_into: Option, + /// `[books] close_sweep`. + pub close_sweep: Vec, + /// Settings that disagree with the books, each saying what to change in + /// wanderbooks.toml (or the chart). Empty: nothing to fix. + pub problems: Vec, +} + +/// Parameters for `books.assign_role`. +#[derive(Debug, Deserialize, JsonSchema)] +pub struct AssignRoleParams { + /// The role, from books.roles: receivables, revenue, payables, + /// general_expense, retained_earnings, salary_expense, + /// payroll_tax_expense, federal_withholding, state_withholding, + /// social_security_payable, medicare_payable, other_deductions_payable. + pub role: String, + /// The account to fill it (code or id); null returns the role to its + /// default code. + pub account: Option, +} + +/// One control role and the account filling it. +#[derive(Debug, Serialize, JsonSchema)] +pub struct RoleOut { + /// The role's key, for books.assign_role. + pub role: String, + /// What it is for, e.g. "accounts receivable". + pub purpose: String, + /// The account type it needs. + pub account_type: String, + /// The code it uses when no account is assigned. + pub default_code: String, + /// Whether an account is assigned (false: the default code is used). + pub assigned: bool, + /// The account filling it now: its code, or null when none exists yet + /// (the first command that needs it creates it at the default code). + pub account_code: Option, + /// That account's name. + pub account_name: Option, +} + +/// Result of `books.roles`. +#[derive(Debug, Serialize, JsonSchema)] +pub struct RolesOut { + /// Every control role, in the engine's order. + pub roles: Vec, +} + /// Parameters for `books.set_tax_category`. #[derive(Debug, Deserialize, JsonSchema)] pub struct SetTaxCategoryParams { @@ -1307,6 +1403,10 @@ pub struct DigestOut { /// lost; tell the human, and if they had just asked for a change, /// check it is there and redo it if not. pub journal_repairs: Vec, + /// Settings in wanderbooks.toml that need changing: an edit that + /// didn't load, or a setting that disagrees with the chart. Fix each + /// with the human, then check books.settings. + pub settings_problems: Vec, } /// A torn journal tail set aside on open. diff --git a/crates/ledger-mcp/tests/mcp_stdio.rs b/crates/ledger-mcp/tests/mcp_stdio.rs index 32d2fb1..a61f31b 100644 --- a/crates/ledger-mcp/tests/mcp_stdio.rs +++ b/crates/ledger-mcp/tests/mcp_stdio.rs @@ -144,10 +144,10 @@ fn mcp_end_to_end_over_stdio() { assert!(instructions.contains("decimal strings")); mcp.notify("notifications/initialized"); - // Tool listing: 23 narrow tools, chart is read-only. + // Tool listing: the narrow tools; chart is read-only. let list = mcp.request("tools/list", json!({})); let tools = list["result"]["tools"].as_array().unwrap(); - assert_eq!(tools.len(), 67, "expected 67 tools, got {}", tools.len()); + assert_eq!(tools.len(), 70, "expected 70 tools, got {}", tools.len()); let chart = tools.iter().find(|t| t["name"] == "books.chart").unwrap(); assert_eq!(chart["annotations"]["readOnlyHint"], true); assert!( diff --git a/crates/ledger-mcp/tests/spec_mcp.rs b/crates/ledger-mcp/tests/spec_mcp.rs index c59d508..46fb6c0 100644 --- a/crates/ledger-mcp/tests/spec_mcp.rs +++ b/crates/ledger-mcp/tests/spec_mcp.rs @@ -609,6 +609,16 @@ fn happy_path(name: &str) -> Mcp { "books.renumber_account", json!({"account": "7000", "code": "7100"}), ); + m.ok("books.settings", json!({})); + m.ok("books.roles", json!({})); + m.ok( + "books.assign_role", + json!({"role": "general_expense", "account": "7100"}), + ); + m.ok( + "books.assign_role", + json!({"role": "general_expense", "account": null}), + ); let deduction = cats["categories"] .as_array() .unwrap() @@ -1982,3 +1992,202 @@ fn helpers_self_check() { ); assert_eq!(bad.len(), 2, "{bad:?}"); } + +// ------------------------------------- settings reload (settings.rs spec) + +/// INVARIANT 1: a call after an edit sees it, with no restart, and two +/// same-size edits in a row are both seen (compared by content). +#[test] +fn settings_reload_on_the_next_call() { + let dir = test_dir("reload"); + let toml = books_toml(&dir, "25.00", ""); + let mut m = Mcp::start(&dir, &toml); + let s = m.ok("books.settings", json!({})); + assert_eq!(s["company"], "Spec Co"); + assert_eq!(s["loads"], 1); + for name in ["Aaaa Co", "Bbbb Co"] { + let text = std::fs::read_to_string(&toml) + .unwrap() + .replace("Spec Co", name) + .replace("Aaaa Co", name); + std::fs::write(&toml, text).unwrap(); + let s = m.ok("books.settings", json!({})); + assert_eq!(s["company"], name); + assert_eq!(s["reload_error"], Value::Null); + } + assert_eq!(m.ok("books.settings", json!({}))["loads"], 3); +} + +/// INVARIANT 1, enforced: a lowered receipt threshold refuses the next +/// post without a receipt. +#[test] +fn a_reloaded_threshold_is_enforced() { + let dir = test_dir("reload-threshold"); + let toml = books_toml(&dir, "1000.00", ""); + let mut m = Mcp::start(&dir, &toml); + m.accounts(BASE_CHART); + let first = m.draft( + "2026-03-01", + "small", + &[("6000", "debit", "1.00"), ("1000", "credit", "1.00")], + ); + m.post(first).ok("under the threshold"); + let text = std::fs::read_to_string(&toml) + .unwrap() + .replace("\"1000.00\"", "\"0.00\""); + std::fs::write(&toml, text).unwrap(); + let second = m.draft( + "2026-03-02", + "small", + &[("6000", "debit", "1.00"), ("1000", "credit", "1.00")], + ); + let out = m.post(second); + assert!(out.err_text().contains("receipt_required"), "{out:?}"); + assert_eq!(m.entry_status(second), "draft"); +} + +/// INVARIANTS 2 and 3: a broken edit, and a named file gone missing, keep +/// the last good settings and say why; fixing it loads again. +#[test] +fn a_broken_edit_keeps_the_last_good_settings() { + let dir = test_dir("reload-broken"); + let toml = books_toml(&dir, "25.00", ""); + let good = std::fs::read_to_string(&toml).unwrap(); + let mut m = Mcp::start(&dir, &toml); + std::fs::write( + &toml, + good.replace("Spec Co", "Next Co") + .replace("[books]", "[books"), + ) + .unwrap(); + let s = m.ok("books.settings", json!({})); + assert_eq!(s["company"], "Spec Co"); + assert!(s["reload_error"].is_string(), "{s}"); + let digest = m.ok("books.digest", json!({"since": "2026-01-01"})); + assert!( + digest["settings_problems"][0] + .as_str() + .unwrap() + .contains("didn't load"), + "{digest}" + ); + std::fs::remove_file(&toml).unwrap(); + let s = m.ok("books.settings", json!({})); + assert!( + s["reload_error"].is_string(), + "a named file gone missing: {s}" + ); + assert_eq!(s["books_toml_exists"], false); + assert_eq!(s["company"], "Spec Co"); + std::fs::write(&toml, good.replace("Spec Co", "Fixed Co")).unwrap(); + let s = m.ok("books.settings", json!({})); + assert_eq!(s["company"], "Fixed Co"); + assert_eq!(s["reload_error"], Value::Null); + assert_eq!(s["loads"], 2); +} + +/// books.settings and the digest list settings that disagree with the +/// chart, each naming the setting. +#[test] +fn settings_that_disagree_with_the_chart_are_problems() { + let dir = test_dir("problems"); + let toml = books_toml( + &dir, + "25.00", + "financing_accounts = [\"6000\"]\nclose_into = \"3000\"\n", + ); + let mut m = Mcp::start(&dir, &toml); + m.accounts(BASE_CHART); + let s = m.ok("books.settings", json!({})); + let problems: Vec = s["problems"] + .as_array() + .unwrap() + .iter() + .map(|p| p.as_str().unwrap().to_string()) + .collect(); + assert_eq!(problems.len(), 1, "{problems:?}"); + assert!(problems[0].contains("[books] financing_accounts names 6000")); + let text = std::fs::read_to_string(&toml) + .unwrap() + .replace("cash_accounts = [\"1000\"]", "cash_accounts = [\"1010\"]") + .replace("close_into = \"3000\"", "close_into = \"4000\""); + std::fs::write(&toml, text).unwrap(); + let digest = m.ok("books.digest", json!({"since": "2026-01-01"})); + let problems = digest["settings_problems"].to_string(); + assert!( + problems.contains("[books] cash_accounts names 1010, which isn't in the chart"), + "{problems}" + ); + assert!( + problems.contains("[books] close_into names 4000"), + "{problems}" + ); + assert!( + problems.contains("[books] financing_accounts names 6000"), + "{problems}" + ); +} + +/// books.assign_role steers what an invoice posts, and books.roles shows +/// it; a refusal names the reason. +#[test] +fn assigned_roles_steer_invoices() { + let dir = test_dir("roles"); + let toml = books_toml(&dir, "1000000.00", ""); + let mut m = Mcp::start(&dir, &toml); + m.accounts(&[ + ("1000", "Bank", "asset"), + ("1110", "Trade Receivables", "asset"), + ("4100", "Consulting Income", "revenue"), + ("6000", "Rent", "expense"), + ]); + let roles = m.ok("books.roles", json!({})); + let receivables = roles["roles"] + .as_array() + .unwrap() + .iter() + .find(|r| r["role"] == "receivables") + .unwrap() + .clone(); + assert_eq!(receivables["default_code"], "1200"); + assert_eq!(receivables["assigned"], false); + assert_eq!(receivables["account_code"], Value::Null); + let err = m.refuse( + "books.assign_role", + json!({"role": "receivables", "account": "6000"}), + ); + assert!(err.contains("accounts receivable"), "{err}"); + let err = m.refuse( + "books.assign_role", + json!({"role": "receivable", "account": "1110"}), + ); + assert!(err.contains("no role"), "{err}"); + m.ok( + "books.assign_role", + json!({"role": "receivables", "account": "1110"}), + ); + let roles = m.ok( + "books.assign_role", + json!({"role": "revenue", "account": "4100"}), + ); + let revenue = roles["roles"] + .as_array() + .unwrap() + .iter() + .find(|r| r["role"] == "revenue") + .unwrap() + .clone(); + assert_eq!(revenue["assigned"], true); + assert_eq!(revenue["account_code"], "4100"); + let customer = m.ok("books.add_customer", json!({"name": "Client"}))["id"] + .as_u64() + .unwrap(); + m.ok( + "books.create_invoice", + json!({"party_id": customer, "date": "2026-02-01", "due_date": "2026-03-01", + "lines": [{"description": "Consulting", "amount": "500.00"}]}), + ); + let tb = m.ok("books.trial_balance", json!({})).to_string(); + assert!(tb.contains("1110") && tb.contains("4100"), "{tb}"); + assert!(!tb.contains("\"1200\"") && !tb.contains("\"4000\""), "{tb}"); +} diff --git a/crates/ledger-store/src/store.rs b/crates/ledger-store/src/store.rs index 76ffe5b..cdbe550 100644 --- a/crates/ledger-store/src/store.rs +++ b/crates/ledger-store/src/store.rs @@ -1235,7 +1235,8 @@ impl Store { Ok(()) } - /// Fetch a control account by code, creating it through the journaled + /// Fetch a control role's account (the one assigned to it, else by + /// code), creating it at its code through the journaled /// account path when missing. The ledger's internal ensure_account /// then no-ops, which keeps replay deterministic: every account /// creation is an `account.added` event. @@ -1243,8 +1244,8 @@ impl Store { &mut self, c: &ledger_core::ControlAccount, ) -> Result { - if let Some(a) = self.ledger.chart().find_by_code(c.code) { - return Ok(a.id); + if let Some(id) = self.ledger.role_account(c) { + return Ok(id); } self.add_account(c.code, c.name, c.account_type, None, false) } @@ -2093,6 +2094,25 @@ impl Store { Ok(()) } + /// Assign a control role an account, or (`None`) return it to its code + /// (see `Ledger::assign_role`). Journaled as `role.assigned`, with the + /// account that filled the role before. + pub fn assign_role( + &mut self, + role: &str, + account: Option, + ) -> Result<(), StoreError> { + let c = self.ledger.check_assign_role(role, account)?; + let previous = self.ledger.role_account(c); + let line = self.append_journal( + "role.assigned", + json!({"role": c.key, "account_id": account, "previous_account_id": previous}), + )?; + self.ledger.assign_role(role, account)?; + apply_line_to_sqlite(&self.conn, &line)?; + Ok(()) + } + /// Set (or clear) an account's tax category and deductible percent. /// Journaled as `account.tax_set`. pub fn set_account_tax( @@ -3285,6 +3305,15 @@ pub(crate) fn replay_line_into_ledger( "account.renumbered" => { ledger.renumber_account(get_u64(p, "account_id")?, get_str(p, "code")?)?; } + "role.assigned" => { + let account = match p.get("account_id") { + None | Some(Value::Null) => None, + Some(v) => Some(v.as_u64().ok_or_else(|| { + StoreError::JournalCorrupt("role.assigned account_id".into()) + })?), + }; + ledger.assign_role(get_str(p, "role")?, account)?; + } "account.tax_set" => { let pct = match p.get("deductible_percent").and_then(|v| v.as_str()) { Some(s) => Some(s.parse().map_err(|_| { @@ -3776,6 +3805,10 @@ fn apply_line_rows(conn: &Connection, line: &JournalLine) -> Result<(), StoreErr "payroll.taxes_remitted" => { // The entry was projected by its own events; this only names it. } + "role.assigned" => { + // Roles live in the ledger (rebuilt by replay); the events + // mirror below keeps the history. + } "receipt.attached" | "document.filed" => { conn.execute( "INSERT OR IGNORE INTO blobs(sha256, size_bytes, mime_type, original_name, created_at) VALUES (?,?,?,?,?)", diff --git a/crates/ledger-store/tests/roles.rs b/crates/ledger-store/tests/roles.rs new file mode 100644 index 0000000..bdac86f --- /dev/null +++ b/crates/ledger-store/tests/roles.rs @@ -0,0 +1,366 @@ +//! Spec-derived tests for control-role assignment in the store. +//! +//! Oracles: `docs/spec/rules.md` B-23 ("the assignment is journaled, so +//! replay never depends on a setting outside the journal"), the doc comment +//! on `Store::assign_role` (journaled as `role.assigned`, with the account +//! that filled the role before), and the `Ledger::assign_role` spec (a +//! refusal leaves the books unchanged). Expected values are worked by hand. + +use chrono::NaiveDate; +use ledger_core::{ + AccountId, AccountType, CloseTo, Currency, DocumentLine, EntryId, Ledger, LedgerError, Money, + RECEIVABLES, Side, control_accounts, +}; +use ledger_store::journal::read_and_verify_journal; +use ledger_store::{JOURNAL_FILE, JournalWriter, Store, StoreError}; +use rust_decimal::Decimal; +use serde_json::{Value, json}; +use std::path::{Path, PathBuf}; +use std::str::FromStr; + +fn test_dir(name: &str) -> PathBuf { + let dir = std::env::temp_dir().join(format!("wb-roles-{name}-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&dir); + std::fs::create_dir_all(&dir).unwrap(); + dir +} + +fn dec(s: &str) -> Decimal { + Decimal::from_str(s).unwrap() +} + +fn date(y: i32, m: u32, d: u32) -> NaiveDate { + NaiveDate::from_ymd_opt(y, m, d).unwrap() +} + +fn line(desc: &str, amount: &str) -> DocumentLine { + DocumentLine { + description: desc.to_string(), + amount: dec(amount), + } +} + +/// The journal's (type, payload) pairs, verified. +fn journal(dir: &Path) -> Vec<(String, Value)> { + read_and_verify_journal(&dir.join(JOURNAL_FILE)) + .unwrap() + .into_iter() + .map(|l| (l.line_type, l.payload)) + .collect() +} + +fn roles(l: &Ledger) -> Vec<(&'static str, Option, Option)> { + control_accounts() + .map(|c| (c.key, l.role_assignment(c), l.role_account(c))) + .collect() +} + +fn code_of(l: &Ledger, id: AccountId) -> String { + l.chart().get(id).unwrap().code.clone() +} + +fn entry_codes(l: &Ledger, e: EntryId) -> Vec<(String, Side, Decimal)> { + let mut v: Vec<_> = l + .get_entry(e) + .unwrap() + .lines() + .iter() + .map(|x| (code_of(l, x.account), x.side, x.amount.amount)) + .collect(); + v.sort_by(|a, b| format!("{a:?}").cmp(&format!("{b:?}"))); + v +} + +/// Every entry and every balance, for "replays identically". +fn state(l: &Ledger) -> String { + let mut out = String::new(); + for id in 0..l.next_entry_id() + 2 { + if let Some(e) = l.get_entry(id) { + out.push_str(&format!( + "E{} {} {:?} {:?} {:?}\n", + e.id(), + e.date(), + e.description(), + e.status(), + e.lines() + .iter() + .map(|x| (x.account, x.side, x.amount.amount)) + .collect::>() + )); + } + } + let mut accts: Vec<_> = l + .chart() + .all() + .map(|a| { + format!( + "{} {} {:?} {}", + a.id, + a.code, + a.account_type, + l.account_balance(a.id).unwrap() + ) + }) + .collect(); + accts.sort(); + out.push_str(&accts.join("\n")); + for p in l.list_periods() { + out.push_str(&format!("\n{p:?}")); + } + out.push_str(&format!("\n{:?}", roles(l))); + out +} + +/// Oracle: Store::assign_role doc: journaled as `role.assigned` with role, +/// account_id, and the account that filled the role before. +#[test] +fn assign_role_is_journaled_as_role_assigned() { + let dir = test_dir("journaled"); + let mut s = Store::create(&dir, Currency::USD).unwrap(); + let ar = s + .add_account("1100", "Trade A/R", AccountType::Asset, None, false) + .unwrap(); + let old = s + .add_account( + "1200", + "Accounts Receivable", + AccountType::Asset, + None, + false, + ) + .unwrap(); + let seq = s.journal_seq(); + s.assign_role("receivables", Some(ar)).unwrap(); + assert_eq!(s.journal_seq(), seq + 1); + let j = journal(&dir); + let (t, p) = j.last().unwrap(); + assert_eq!(t, "role.assigned"); + assert_eq!(p["role"], json!("receivables")); + assert_eq!(p["account_id"], json!(ar)); + // Before: the account at 1200 filled it by code. + assert_eq!(p["previous_account_id"], json!(old)); + + // Back to the code: account_id is null, the previous is 1100. + s.assign_role("receivables", None).unwrap(); + let j = journal(&dir); + let (t, p) = j.last().unwrap(); + assert_eq!(t, "role.assigned"); + assert_eq!(p["role"], json!("receivables")); + assert!(p["account_id"].is_null()); + assert_eq!(p["previous_account_id"], json!(ar)); + assert_eq!(s.ledger().role_account(&RECEIVABLES), Some(old)); +} + +/// Oracle: a role with no account at its code yet had no previous account. +#[test] +fn a_first_assignment_with_nothing_at_the_code_has_no_previous() { + let dir = test_dir("no-previous"); + let mut s = Store::create(&dir, Currency::USD).unwrap(); + let sales = s + .add_account("4100", "Sales", AccountType::Revenue, None, false) + .unwrap(); + s.assign_role("revenue", Some(sales)).unwrap(); + let j = journal(&dir); + let (t, p) = j.last().unwrap(); + assert_eq!(t, "role.assigned"); + assert_eq!(p["account_id"], json!(sales)); + assert!(p["previous_account_id"].is_null()); +} + +/// Oracle: Ledger::assign_role INVARIANT 5 through the store: a refusal +/// journals nothing and changes nothing. +#[test] +fn a_refused_assignment_journals_nothing() { + let dir = test_dir("refused"); + let mut s = Store::create(&dir, Currency::USD).unwrap(); + let exp = s + .add_account("6500", "Misc", AccountType::Expense, None, false) + .unwrap(); + let ph = s + .add_account("1150", "A/R group", AccountType::Asset, None, true) + .unwrap(); + let bytes = std::fs::read(dir.join(JOURNAL_FILE)).unwrap(); + let seq = s.journal_seq(); + let before = state(s.ledger()); + + let e = s.assign_role("nope", Some(exp)).unwrap_err(); + assert!(matches!(e, StoreError::Ledger(LedgerError::UnknownRole(ref k)) if k == "nope")); + let e = s.assign_role("receivables", Some(exp)).unwrap_err(); + assert!(matches!( + e, + StoreError::Ledger(LedgerError::ControlAccountMismatch { .. }) + )); + let e = s.assign_role("receivables", Some(ph)).unwrap_err(); + assert!(matches!( + e, + StoreError::Ledger(LedgerError::ControlAccountMismatch { .. }) + )); + let e = s.assign_role("receivables", Some(9999)).unwrap_err(); + assert!(matches!( + e, + StoreError::Ledger(LedgerError::UnknownAccount(9999)) + )); + s.assign_role("general_expense", Some(exp)).unwrap(); + let bytes_after_ok = std::fs::read(dir.join(JOURNAL_FILE)).unwrap(); + assert!(bytes_after_ok.len() > bytes.len()); + let seq_ok = s.journal_seq(); + assert_eq!(seq_ok, seq + 1); + let mid = state(s.ledger()); + assert_ne!(mid, before); + let e = s.assign_role("salary_expense", Some(exp)).unwrap_err(); + assert!(matches!( + e, + StoreError::Ledger(LedgerError::RoleTaken { .. }) + )); + assert_eq!(s.journal_seq(), seq_ok); + assert_eq!( + std::fs::read(dir.join(JOURNAL_FILE)).unwrap(), + bytes_after_ok + ); + assert_eq!(state(s.ledger()), mid); +} + +/// Oracle: B-23 (replay never depends on a setting outside the journal): +/// reopening gives the same assignments, and later commands still post to +/// the assigned accounts. +#[test] +fn assignments_replay_on_reopen_and_steer_later_invoices() { + let dir = test_dir("replay"); + let mut s = Store::create(&dir, Currency::USD).unwrap(); + let cash = s + .add_account("1000", "Cash", AccountType::Asset, None, false) + .unwrap(); + let ar = s + .add_account("1100", "Trade A/R", AccountType::Asset, None, false) + .unwrap(); + let sales = s + .add_account("4100", "Sales", AccountType::Revenue, None, false) + .unwrap(); + let misc = s + .add_account("6500", "Misc", AccountType::Expense, None, false) + .unwrap(); + s.assign_role("receivables", Some(ar)).unwrap(); + s.assign_role("revenue", Some(sales)).unwrap(); + s.assign_role("general_expense", Some(misc)).unwrap(); + // Assigned then returned: replay must end with it unassigned. + s.assign_role("general_expense", None).unwrap(); + let before = roles(s.ledger()); + drop(s); + + let mut s = Store::open(&dir).unwrap(); + assert_eq!(roles(s.ledger()), before); + assert_eq!(s.ledger().role_assignment(&RECEIVABLES), Some(ar)); + + let cust = s.add_customer("Acme").unwrap(); + let inv = s + .create_invoice( + cust, + date(2026, 3, 1), + date(2026, 3, 31), + vec![line("w", "42.00")], + ) + .unwrap(); + let e = s.ledger().get_invoice(inv).unwrap().entry_id; + let mut want = vec![ + ("1100".to_string(), Side::Debit, dec("42.00")), + ("4100".to_string(), Side::Credit, dec("42.00")), + ]; + want.sort_by(|a, b| format!("{a:?}").cmp(&format!("{b:?}"))); + assert_eq!(entry_codes(s.ledger(), e), want); + s.receive_payment(inv, date(2026, 3, 9), dec("42.00"), cash) + .unwrap(); + assert!(s.ledger().chart().find_by_code("1200").is_none()); + assert!(s.ledger().chart().find_by_code("4000").is_none()); + let after = state(s.ledger()); + drop(s); + + let s = Store::open(&dir).unwrap(); + assert_eq!(state(s.ledger()), after); +} + +/// Oracle: B-23 + close_period: a close to retained earnings after the +/// role is assigned goes to the assigned account, and replays identically. +#[test] +fn a_close_to_assigned_retained_earnings_replays_identically() { + let dir = test_dir("close"); + let mut s = Store::create(&dir, Currency::USD).unwrap(); + let cash = s + .add_account("1000", "Cash", AccountType::Asset, None, false) + .unwrap(); + let rev = s + .add_account("4500", "Fees", AccountType::Revenue, None, false) + .unwrap(); + let re = s + .add_account( + "3100", + "Accumulated Earnings", + AccountType::Equity, + None, + false, + ) + .unwrap(); + s.assign_role("retained_earnings", Some(re)).unwrap(); + let e = s.create_entry(date(2026, 1, 5), "fees").unwrap(); + s.add_line( + e, + cash, + Side::Debit, + Money::new(dec("310.00"), Currency::USD), + ) + .unwrap(); + s.add_line( + e, + rev, + Side::Credit, + Money::new(dec("310.00"), Currency::USD), + ) + .unwrap(); + s.post_entry(e).unwrap(); + let p = s + .create_period("2026-01", date(2026, 1, 1), date(2026, 1, 31)) + .unwrap(); + let closing = s + .close_period_to(p, &CloseTo::RetainedEarnings { sweep: vec![] }) + .unwrap() + .expect("closing entry"); + let mut want = vec![ + ("4500".to_string(), Side::Debit, dec("310.00")), + ("3100".to_string(), Side::Credit, dec("310.00")), + ]; + want.sort_by(|a, b| format!("{a:?}").cmp(&format!("{b:?}"))); + assert_eq!(entry_codes(s.ledger(), closing), want); + assert!(s.ledger().chart().find_by_code("3999").is_none()); + let before = state(s.ledger()); + drop(s); + + let s = Store::open(&dir).unwrap(); + assert_eq!(state(s.ledger()), before); + assert_eq!(s.ledger().account_balance(re).unwrap(), dec("310.00")); +} + +/// Oracle: a journal is the system of record; a `role.assigned` line whose +/// account_id isn't an account id (an integer) is corruption, refused on +/// open. The line is appended with `JournalWriter`, so the hash chain is +/// valid and only the payload is wrong. +#[test] +fn a_role_assigned_line_with_a_non_integer_account_is_corrupt() { + for bad in [json!("1100"), json!(1.5), json!(-3), json!({"id": 1})] { + let dir = test_dir("corrupt"); + let mut s = Store::create(&dir, Currency::USD).unwrap(); + s.add_account("1100", "Trade A/R", AccountType::Asset, None, false) + .unwrap(); + drop(s); + let mut w = JournalWriter::open(&dir.join(JOURNAL_FILE)).unwrap(); + w.append( + "role.assigned", + json!({"role": "receivables", "account_id": bad, "previous_account_id": null}), + ) + .unwrap(); + drop(w); + match Store::open(&dir) { + Err(StoreError::JournalCorrupt(_)) => {} + Err(other) => panic!("account_id {bad}: expected JournalCorrupt, got {other:?}"), + Ok(_) => panic!("account_id {bad}: a corrupt role.assigned line opened"), + } + } +} diff --git a/docs/mcp-interface.md b/docs/mcp-interface.md index 9b0062b..40eb66f 100644 --- a/docs/mcp-interface.md +++ b/docs/mcp-interface.md @@ -43,6 +43,35 @@ advanced, such as a "Due to owners" liability: ASC 230-10-45-14(b)), and 230-10-20); the tools and the viewer both read them, and a call may pass its own (docs/spec/rules.md B-14). +The server reloads `wanderbooks.toml` (and `config.json`'s receipt policy) +whenever either changes, compared by content at the start of each request, +so an edit takes effect on the next call with no restart. A broken edit +keeps the last good settings in force and is reported until fixed; the +data dir and base currency are fixed at startup (`ledger-mcp/src/settings.rs`). + +- `books.settings {}` → `{ books_toml, books_toml_exists, config_json, + reload_error, loads, company, fiscal_year_start, tax_form, basis, + receipt_rule, members, cash_accounts, financing_accounts, + investing_accounts, close_into, close_sweep, problems }`: the settings in + force and where they come from. `problems` lists settings that disagree + with the chart (a code not in it, or of the wrong kind), each saying what + to change; `books.digest` repeats them, with a failed reload, as + `settings_problems`. The agent edits the file when the human gives a + company fact, a refusal names a setting, or a problem is listed, then + checks `books.settings`. + +The accounts invoices, bills, payroll, and closes post to are not settings: +they are control roles in the journal (rules.md B-23). + +- `books.roles {}` → `{ roles: [{ role, purpose, account_type, + default_code, assigned, account_code?, account_name? }] }`: every control + role and the account filling it. +- `books.assign_role { role, account? }` → `{ roles }`: assign a role one + of the chart's accounts (`account` null: back to the default code). + Refuses the wrong type, a placeholder, an account already filling + another role, and moving a balance sheet role while its account holds a + balance. Journaled as `role.assigned`. + ## Transports 1. **stdio** (build first). For Claude Code, Claude Desktop, local agents. @@ -344,9 +373,10 @@ posting. - `books.renumber_account { account, code }` → `{ id, previous_code, code, config_mentions }`: give an account a new code. Only the code changes (entries name accounts by id), so balances and reports don't - move. Refuses a code in use, or a control account's code for an account - of the wrong type. `config_mentions` lists the wanderbooks.toml settings - still naming the old code. Journaled as `account.renumbered`. + move. Refuses a code in use, or an unassigned control role's default + code for an account of the wrong type. `config_mentions` lists the + wanderbooks.toml settings still naming the old code (edit them; the + server reloads the file). Journaled as `account.renumbered`. - `books.set_tax_category { account, category?, deductible_percent? }`: map a revenue or expense account to a category (null clears), optionally overriding its deductible percent. Journaled as diff --git a/docs/spec/rules.md b/docs/spec/rules.md index 6eeecac..bee289e 100644 --- a/docs/spec/rules.md +++ b/docs/spec/rules.md @@ -420,3 +420,29 @@ taxable years beginning on or after January 1, 2016. The 12-month rule: §1.263(a)-4(f)(1). The election is made yearly on the return. **Enforced:** bookkeeper policy (books' AGENTS.md), not the engine. + +## B-23 Control roles: one account each, of the right kind, moved only when empty + +**Status:** CONVENTION (a control account agrees with the records it +controls); DECISIONS (Stephen, 2026-10-13 and 2026-10-15) + +Invoices, bills, payroll, and closes post to accounts the engine picks by +role: receivables, revenue, payables, general expense, retained earnings, +salary and payroll tax expense, and five payroll withholdings. A role uses +its default code (1200, 4000, 2000, 6999, 3999, 6100, 6110, 2110–2150) +until the human assigns it one of the chart's own accounts; the assignment +is journaled, so replay never depends on a setting outside the journal +(DECISION, 2026-10-15). A role's account is postable and of the role's +type (B-4), and the engine refuses one that isn't rather than renumber +the chart (DECISION, 2026-10-13). No account fills two roles. A +receivables, payables, withholding, or retained earnings account must keep +agreeing with what it controls (open invoices, open bills, taxes withheld, +closed earnings): such a role moves only when its account holds no +balance, so the bookkeeper first moves the balance with an entry, which +the journal shows. Revenue and expense roles move at any time; earlier +postings stay where they are. + +**Enforced:** `Ledger::check_controls`, `Ledger::assign_role` / +`check_assign_role`, `Ledger::check_renumber_account` (an assigned role +frees its code). **Checked:** `ledger-core` `tests/spec_roles.rs`; +`ledger-store` replay of `role.assigned`.