From 5b8910a1cd03cc70d824afa237c7e5c161a2a36d Mon Sep 17 00:00:00 2001 From: Stephen Waits Date: Wed, 14 Oct 2026 08:00:58 -0600 Subject: [PATCH] books.renumber_account: give an account a new code, history kept Ledger::renumber_account changes only the code (entries name accounts by id): refuses an unknown account, a blank or used code, and a control code for an account of the wrong type or a placeholder. Journaled as account.renumbered (with the previous code), replayed, projected, shown in the viewer's journal. The MCP tool lists wanderbooks.toml settings that still name the old code. The server instructions now warn that the engine can't tell a same-type account with another purpose at a control code (a hosting expense at payroll's 6100). Tests: core property (only the code changes; trial balance equal), core refusals, the store's command property, MCP end to end with restart. Mutation on the new code: 8 of 8 viable caught. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01GWVo7x3UHw5kwCUaWxiejX --- crates/ledger-core/src/account.rs | 18 ++++ crates/ledger-core/src/ledger.rs | 70 +++++++++++++ crates/ledger-core/tests/spec_ledger.rs | 96 ++++++++++++++++++ crates/ledger-gui/src/pages/journal.rs | 11 +++ crates/ledger-mcp/src/server.rs | 60 +++++++++++- crates/ledger-mcp/src/types.rs | 23 +++++ crates/ledger-mcp/tests/mcp_stdio.rs | 125 +++++++++++++++++++++++- crates/ledger-store/src/store.rs | 29 ++++++ crates/ledger-store/tests/store.rs | 10 ++ docs/mcp-interface.md | 6 ++ 10 files changed, 446 insertions(+), 2 deletions(-) diff --git a/crates/ledger-core/src/account.rs b/crates/ledger-core/src/account.rs index 780aa62..9f2f9b7 100644 --- a/crates/ledger-core/src/account.rs +++ b/crates/ledger-core/src/account.rs @@ -125,6 +125,24 @@ impl ChartOfAccounts { self.accounts.values() } + /// Give an account a new code. The code must be non-empty and free. + pub fn set_code(&mut self, id: AccountId, code: &str) -> Result<(), LedgerError> { + if code.trim().is_empty() { + return Err(LedgerError::EmptyAccountCode); + } + if self.by_code.contains_key(code) { + return Err(LedgerError::DuplicateAccountCode(code.to_string())); + } + let a = self + .accounts + .get_mut(&id) + .ok_or(LedgerError::UnknownAccount(id))?; + self.by_code.remove(&a.code); + a.code = code.to_string(); + self.by_code.insert(a.code.clone(), id); + Ok(()) + } + /// Set (or clear) an account's tax category and deductible percent. pub fn set_tax( &mut self, diff --git a/crates/ledger-core/src/ledger.rs b/crates/ledger-core/src/ledger.rs index 8bf89ef..b0967ee 100644 --- a/crates/ledger-core/src/ledger.rs +++ b/crates/ledger-core/src/ledger.rs @@ -85,6 +85,19 @@ pub const RETAINED_EARNINGS: ControlAccount = control( "retained earnings", AccountType::Equity, ); +/// Every control account the engine posts to by code. +pub fn control_accounts() -> impl Iterator { + [ + &RECEIVABLES, + &REVENUE, + &PAYABLES, + &GENERAL_EXPENSES, + &RETAINED_EARNINGS, + ] + .into_iter() + .chain(PAYROLL.iter()) +} + /// The payroll accounts, in [`PayrollAccounts`] order. pub const PAYROLL: [ControlAccount; 7] = [ control( @@ -720,6 +733,63 @@ impl Ledger { Ok(id) } + /// Give an account a new number (code). + /// + /// ```text + /// GOAL: let the human renumber an account (say, off a code the engine + /// uses for a control role) without touching its history. + /// SOURCE: DECISION (Stephen, 2026-10-14: "fix the account number"); + /// control accounts: Ledger::check_controls. + /// INVARIANTS: + /// 1. Only the code changes: the account's id, name, type, parent, + /// tax category, and every entry posted to it stay as they were + /// (entries name accounts by id), so every balance and report + /// total is unchanged. + /// 2. A refusal MUST leave the books unchanged. + /// SCENARIOS: + /// 1. IF the account is unknown, or the code is blank or already in + /// use (its own included): SHOULD refuse. + /// 2. IF the code is a control account's and the account is of + /// another type, or a placeholder: SHOULD refuse with + /// ControlAccountMismatch, as a control command would. + /// ``` + pub fn renumber_account(&mut self, account: AccountId, code: &str) -> Result<(), LedgerError> { + self.check_renumber_account(account, code)?; + self.chart.set_code(account, code) + } + + /// The validation behind [`Self::renumber_account`], for stores that + /// journal before mutating. + pub fn check_renumber_account( + &self, + account: AccountId, + code: &str, + ) -> Result<(), LedgerError> { + let a = self + .chart + .get(account) + .ok_or(LedgerError::UnknownAccount(account))?; + if code.trim().is_empty() { + return Err(LedgerError::EmptyAccountCode); + } + 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) + && (a.account_type != c.account_type || a.placeholder) + { + return Err(LedgerError::ControlAccountMismatch { + code: code.to_string(), + name: a.name.clone(), + role: c.role.to_string(), + expected: c.account_type, + found: a.account_type, + placeholder: a.placeholder, + }); + } + Ok(()) + } + /// 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`]). diff --git a/crates/ledger-core/tests/spec_ledger.rs b/crates/ledger-core/tests/spec_ledger.rs index 7f300ef..4f647e3 100644 --- a/crates/ledger-core/tests/spec_ledger.rs +++ b/crates/ledger-core/tests/spec_ledger.rs @@ -2443,3 +2443,99 @@ proptest! { prop_assert_eq!(b.l.post_entry(e), v); } } + +// --------------------------------------------------------------------- +// Ledger::renumber_account (2026-10-14). +// --------------------------------------------------------------------- + +proptest! { + /// renumber_account INVARIANT 1: only the code changes. Every other + /// part of the ledger is equal, every account keeps its other fields, + /// and the trial balance is the same line for line apart from that one + /// code. + #[test] + fn renumber_changes_only_the_code( + postings in proptest::collection::vec((0usize..4, 0usize..4, 1i64..1_000_000), 0..12), + which in 0usize..4, + ) { + let mut l = Ledger::new(Currency::USD); + let ids: Vec<_> = [ + ("1000", AccountType::Asset), + ("2000", AccountType::Liability), + ("4000", AccountType::Revenue), + ("6100", AccountType::Expense), + ] + .iter() + .map(|(c, t)| l.add_account(c, c, *t, None, false).unwrap()) + .collect(); + for (dr, cr, c) in postings { + if dr == cr { + continue; + } + let e = l.create_entry(d(2026, 1, 5), "x"); + let m = Money::new(Decimal::new(c, 2), Currency::USD); + l.add_line(e, ids[dr], Side::Debit, m).unwrap(); + l.add_line(e, ids[cr], Side::Credit, m).unwrap(); + l.post_entry(e).unwrap(); + } + let before = l.clone(); + let old = l.chart().get(ids[which]).unwrap().code.clone(); + l.renumber_account(ids[which], "7777").unwrap(); + prop_assert_eq!(l.differs_from(&before), vec!["chart"]); + for id in &ids { + let (a, b) = (l.chart().get(*id).unwrap(), before.chart().get(*id).unwrap()); + let mut b = b.clone(); + if *id == ids[which] { + b.code = "7777".to_string(); + } + prop_assert_eq!(a, &b); + } + prop_assert_eq!(l.chart().find_by_code("7777").map(|a| a.id), Some(ids[which])); + prop_assert!(l.chart().find_by_code(&old).is_none()); + let (t1, t0) = (trial_balance(&l), trial_balance(&before)); + prop_assert_eq!(t1.total_debits, t0.total_debits); + let mut a: Vec<_> = t1.lines.iter().map(|x| (x.name.clone(), x.debit, x.credit)).collect(); + let mut b: Vec<_> = t0.lines.iter().map(|x| (x.name.clone(), x.debit, x.credit)).collect(); + a.sort(); + b.sort(); + prop_assert_eq!(a, b); + } +} + +/// renumber_account SCENARIOS 1-2 and INVARIANT 2: an unknown account, a +/// blank or used code (its own too), and a control code for an account of +/// another type are refused, leaving the books as they were. A control +/// code for an account of the right type is accepted. +#[test] +fn renumber_refusals_leave_the_books_unchanged() { + let mut l = Ledger::new(Currency::USD); + let cash = l + .add_account("1000", "Cash", AccountType::Asset, None, false) + .unwrap(); + let hosting = l + .add_account("6100", "Hosting", AccountType::Expense, None, false) + .unwrap(); + let group = l + .add_account("6000", "Expenses", AccountType::Expense, None, true) + .unwrap(); + let before = l.clone(); + for (id, code) in [ + (999, "7000"), + (hosting, ""), + (hosting, " "), + (hosting, "1000"), + (hosting, "6100"), + (hosting, "2110"), // payroll federal withholding: a liability + (hosting, "1200"), // receivables: an asset + (cash, "4000"), // revenue + (group, "6999"), // general expenses: a placeholder can't be one + ] { + assert!( + l.renumber_account(id, code).is_err(), + "{id} -> {code:?} accepted" + ); + assert!(l.differs_from(&before).is_empty(), "{id} -> {code:?}"); + } + l.renumber_account(hosting, "6999").unwrap(); + l.renumber_account(cash, "1200").unwrap(); +} diff --git a/crates/ledger-gui/src/pages/journal.rs b/crates/ledger-gui/src/pages/journal.rs index ebbf27a..b5f86d1 100644 --- a/crates/ledger-gui/src/pages/journal.rs +++ b/crates/ledger-gui/src/pages/journal.rs @@ -121,6 +121,17 @@ pub fn describe(b: &Books, line: &JournalLine) -> Option { format!("Added account {} {}", s(p, "code"), s(p, "name")), Some(Route::Account(id)), ), + "account.renumbered" => ev( + icon::TAG, + format!( + "Renumbered {} from {}", + b.account_label(p.get("account_id").and_then(|v| v.as_u64()).unwrap_or(0)), + s(p, "previous_code") + ), + p.get("account_id") + .and_then(|v| v.as_u64()) + .map(Route::Account), + ), "account.tax_set" => ev( icon::TAG, format!( diff --git a/crates/ledger-mcp/src/server.rs b/crates/ledger-mcp/src/server.rs index 46e7704..56873ab 100644 --- a/crates/ledger-mcp/src/server.rs +++ b/crates/ledger-mcp/src/server.rs @@ -23,7 +23,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. Never rename or renumber the human's accounts on your own: explain the conflict and let them decide."; +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)."; /// Max receipt/document upload: 25 MB. const MAX_UPLOAD_BYTES: usize = 25 * 1024 * 1024; @@ -52,6 +52,35 @@ pub struct 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), + ] { + if codes.iter().any(|c| c == code) { + out.push(key.to_string()); + } + } + if self.company.close_into.as_deref() == Some(code) { + out.push("[company] close_into".to_string()); + } + for m in &self.company.members { + if m.capital_account == code { + out.push(format!("[[company.members]] {} capital_account", m.name)); + } + if m.distribution_account.as_deref() == Some(code) { + out.push(format!( + "[[company.members]] {} distribution_account", + m.name + )); + } + } + out + } + /// Build the server around an open store. pub fn new(store: Store, policy: ReceiptPolicy) -> Self { Self { @@ -2271,6 +2300,35 @@ impl BooksServer { })) } + #[tool( + name = "books.renumber_account", + description = "Give an account a new code. Only the code changes: its id, name, and every entry posted to it stay, so balances and reports don't move. Refuses a code in use, or a control account's code (invoices, bills, payroll, closes) for an account of the wrong type. Lists any wanderbooks.toml settings that still name the old code. Never renumber the human's accounts on your own: only when they ask." + )] + async fn renumber_account( + &self, + params: Parameters, + ) -> Result, String> { + let p = params.0; + let mut store = self.lock()?; + let id = resolve_account(&store, &p.account)?; + let previous_code = store + .ledger() + .chart() + .get(id) + .expect("resolved") + .code + .clone(); + store + .renumber_account(id, &p.code) + .map_err(|e| e.to_string())?; + Ok(Json(RenumberAccountOut { + id, + config_mentions: self.config_mentions(&previous_code), + previous_code, + code: p.code, + })) + } + #[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." diff --git a/crates/ledger-mcp/src/types.rs b/crates/ledger-mcp/src/types.rs index 3320e79..44046a5 100644 --- a/crates/ledger-mcp/src/types.rs +++ b/crates/ledger-mcp/src/types.rs @@ -480,6 +480,29 @@ pub struct ChartTemplateOut { pub notes: Vec, } +/// Parameters for `books.renumber_account`. +#[derive(Debug, Deserialize, JsonSchema)] +pub struct RenumberAccountParams { + /// The account's current code (or id). + pub account: String, + /// Its new code: non-empty and not in use. + pub code: String, +} + +/// Result of `books.renumber_account`. +#[derive(Debug, Serialize, JsonSchema)] +pub struct RenumberAccountOut { + /// The account's id (unchanged). + pub id: u64, + /// The code it had. + pub previous_code: String, + /// 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. + pub config_mentions: Vec, +} + /// Parameters for `books.set_tax_category`. #[derive(Debug, Deserialize, JsonSchema)] pub struct SetTaxCategoryParams { diff --git a/crates/ledger-mcp/tests/mcp_stdio.rs b/crates/ledger-mcp/tests/mcp_stdio.rs index d190d64..32d2fb1 100644 --- a/crates/ledger-mcp/tests/mcp_stdio.rs +++ b/crates/ledger-mcp/tests/mcp_stdio.rs @@ -147,7 +147,7 @@ fn mcp_end_to_end_over_stdio() { // Tool listing: 23 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(), 66, "expected 66 tools, got {}", tools.len()); + assert_eq!(tools.len(), 67, "expected 67 tools, got {}", tools.len()); let chart = tools.iter().find(|t| t["name"] == "books.chart").unwrap(); assert_eq!(chart["annotations"]["readOnlyHint"], true); assert!( @@ -1900,3 +1900,126 @@ fn bank_import_reads_dates_rules_and_files_whole() { "the first row was journaled" ); } + +/// books.renumber_account moves an account off a control code: payroll then +/// gets its own wages account, the renumbered account keeps its entries, +/// and settings naming the old code are listed. +#[test] +fn renumbering_an_account_keeps_its_history() { + let dir = test_dir("renumber"); + let toml = dir.join("wanderbooks.toml"); + std::fs::write( + &toml, + "[books]\nreceipt_threshold = \"1000000.00\"\ncash_accounts = [\"1000\"]\n", + ) + .unwrap(); + let mut mcp = Mcp::spawn_with( + &dir.join("books-data"), + &[std::ffi::OsStr::new("--books-toml"), toml.as_os_str()], + ); + mcp.request( + "initialize", + json!({ + "protocolVersion": "2025-06-18", + "capabilities": {}, + "clientInfo": {"name": "smoke-test", "version": "0.1.0"}, + }), + ); + mcp.notify("notifications/initialized"); + for (code, name, typ) in [ + ("1000", "Bank", "asset"), + ("2100", "Due to owners", "liability"), + ("6100", "Domain & Hosting", "expense"), + ] { + mcp.call( + "books.add_account", + json!({"code": code, "name": name, "account_type": typ}), + ); + } + let e = mcp.call( + "books.draft_entry", + json!({"date": "2026-03-05", "description": "domain"}), + ); + let id = e["entry_id"].as_u64().unwrap(); + for (account, side) in [("6100", "debit"), ("2100", "credit")] { + mcp.call( + "books.add_line", + json!({"entry_id": id, "account": account, "side": side, "amount": "12.34"}), + ); + } + mcp.call( + "books.post_entry", + json!({"entry_id": id, "confirmed": true}), + ); + + // Refusals: a code in use; a control code of another type (2110 is + // payroll's federal withholding, a liability). + let err = mcp.call_err( + "books.renumber_account", + json!({"account": "6100", "code": "1000"}), + ); + assert!(err.contains("already in use"), "{err}"); + let err = mcp.call_err( + "books.renumber_account", + json!({"account": "6100", "code": "2110"}), + ); + assert!(err.contains("2110"), "{err}"); + + let out = mcp.call( + "books.renumber_account", + json!({"account": "6100", "code": "6050"}), + ); + assert_eq!(out["previous_code"], "6100"); + assert_eq!(out["code"], "6050"); + assert_eq!(out["config_mentions"], json!([])); + let chart = mcp.call("books.chart", json!({})); + let acct = chart["accounts"] + .as_array() + .unwrap() + .iter() + .find(|a| a["code"] == "6050") + .expect("renumbered"); + assert_eq!(acct["name"], "Domain & Hosting"); + assert_eq!(acct["balance"], "12.34"); + assert!( + !chart["accounts"] + .as_array() + .unwrap() + .iter() + .any(|a| a["code"] == "6100") + ); + // The cash account is named in wanderbooks.toml: listed. + let out = mcp.call( + "books.renumber_account", + json!({"account": "1000", "code": "1010"}), + ); + assert_eq!(out["config_mentions"], json!(["[books] cash_accounts"])); + drop(mcp); + + // A restart replays the journal to the same chart. + let mut mcp = Mcp::spawn(&dir.join("books-data")); + mcp.request( + "initialize", + json!({ + "protocolVersion": "2025-06-18", + "capabilities": {}, + "clientInfo": {"name": "smoke-test", "version": "0.1.0"}, + }), + ); + mcp.notify("notifications/initialized"); + let chart = mcp.call("books.chart", json!({})); + let codes: Vec<&str> = chart["accounts"] + .as_array() + .unwrap() + .iter() + .map(|a| a["code"].as_str().unwrap()) + .collect(); + assert!( + codes.contains(&"6050") && codes.contains(&"1010"), + "{codes:?}" + ); + assert!( + !codes.contains(&"6100") && !codes.contains(&"1000"), + "{codes:?}" + ); +} diff --git a/crates/ledger-store/src/store.rs b/crates/ledger-store/src/store.rs index 30338ef..cab27f0 100644 --- a/crates/ledger-store/src/store.rs +++ b/crates/ledger-store/src/store.rs @@ -2062,6 +2062,26 @@ impl Store { Ok(out) } + /// Give an account a new code (see `Ledger::renumber_account`). + /// Journaled as `account.renumbered`, with the code it had. + pub fn renumber_account(&mut self, account: AccountId, code: &str) -> Result<(), StoreError> { + self.rehearse(|l| l.renumber_account(account, code))?; + self.ledger.check_renumber_account(account, code)?; + let previous = self + .ledger + .chart() + .get(account) + .map(|a| a.code.clone()) + .unwrap_or_default(); + let line = self.append_journal( + "account.renumbered", + json!({"account_id": account, "code": code, "previous_code": previous}), + )?; + self.ledger.renumber_account(account, code)?; + 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( @@ -3251,6 +3271,9 @@ pub(crate) fn replay_line_into_ledger( p.get("vendor_id").and_then(|v| v.as_u64()), )?; } + "account.renumbered" => { + ledger.renumber_account(get_u64(p, "account_id")?, get_str(p, "code")?)?; + } "account.tax_set" => { let pct = match p.get("deductible_percent").and_then(|v| v.as_str()) { Some(s) => Some(s.parse().map_err(|_| { @@ -3795,6 +3818,12 @@ fn apply_line_rows(conn: &Connection, line: &JournalLine) -> Result<(), StoreErr )?; } }, + "account.renumbered" => { + conn.execute( + "UPDATE accounts SET code = ? WHERE id = ?", + params![get_str(p, "code")?, get_u64(p, "account_id")? as i64], + )?; + } "account.tax_set" => { conn.execute( "UPDATE accounts SET tax_category = ?, deductible_percent = ? WHERE id = ?", diff --git a/crates/ledger-store/tests/store.rs b/crates/ledger-store/tests/store.rs index 3326da6..7fa51c3 100644 --- a/crates/ledger-store/tests/store.rs +++ b/crates/ledger-store/tests/store.rs @@ -952,6 +952,7 @@ mod refusals { SetEntryVendor(u64, Option), SetVendor1099(u64, bool), SetAccountTax(u64, u8), + Renumber(u64, u8), // The rest of the command surface (2026-10-13). AttachReceipt(Option, u8), FileDocument(u8), @@ -1025,6 +1026,7 @@ mod refusals { (id.clone(), prop::option::of(0u64..4)).prop_map(|(e, v)| Op::SetEntryVendor(e, v)), (0u64..4, any::()).prop_map(|(v, t)| Op::SetVendor1099(v, t)), (0u64..10, 0u8..4).prop_map(|(a, k)| Op::SetAccountTax(a, k)), + (0u64..10, 0u8..9).prop_map(|(a, c)| Op::Renumber(a, c)), (prop::option::of(id.clone()), 0u8..4).prop_map(|(e, b)| Op::AttachReceipt(e, b)), (0u8..4).prop_map(Op::FileDocument), (0u64..6, id.clone()).prop_map(|(a, e)| Op::LinkReceipt(a, e)), @@ -1143,6 +1145,14 @@ mod refusals { Op::SetEntryVendor(e, v) => store.set_entry_vendor(e, v), Op::SetVendor1099(v, t) => store.set_vendor_1099(v, Some(Form1099::Nec), t), Op::SetAccountTax(a, k) => store.set_account_tax(a, TAX[k as usize], None), + // The chart's codes, control codes (payroll's 6100 and 2110, + // revenue, receivables), a blank, and fresh ones. + Op::Renumber(a, c) => store.renumber_account( + a, + [ + "1000", "6100", "2110", "4000", "1200", "", "7001", "7002", "3999", + ][c as usize], + ), Op::AttachReceipt(e, b) => store .attach_receipt(e, "r.pdf", "application/pdf", &[b; 3], Some("receipt")) .map(drop), diff --git a/docs/mcp-interface.md b/docs/mcp-interface.md index 6311124..d71a498 100644 --- a/docs/mcp-interface.md +++ b/docs/mcp-interface.md @@ -342,6 +342,12 @@ posting. (`[company] tax_form`, or derived from `tax_classification`). Stable semantic keys (`meals`, `guaranteed_payments`, `commissions_fees`, ...), never form line numbers, which change by year. +- `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`. - `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