books.renumber_account: give an account a new code, history kept
check / check (push) Canceled after 0s

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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GWVo7x3UHw5kwCUaWxiejX
This commit is contained in:
swaitsandClaude Opus 5.5 committed 2026-10-14 08:00:58 -06:00
1 parent ccec4e9439
commit 5b8910a1cd
10 files changed
+446 -2

No files matched your search

+18
View File
@@ -125,6 +125,24 @@ impl ChartOfAccounts {
self.accounts.values() 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. /// Set (or clear) an account's tax category and deductible percent.
pub fn set_tax( pub fn set_tax(
&mut self, &mut self,
+70
View File
@@ -85,6 +85,19 @@ pub const RETAINED_EARNINGS: ControlAccount = control(
"retained earnings", "retained earnings",
AccountType::Equity, AccountType::Equity,
); );
/// Every control account the engine posts to by code.
pub fn control_accounts() -> impl Iterator<Item = &'static ControlAccount> {
[
&RECEIVABLES,
&REVENUE,
&PAYABLES,
&GENERAL_EXPENSES,
&RETAINED_EARNINGS,
]
.into_iter()
.chain(PAYROLL.iter())
}
/// The payroll accounts, in [`PayrollAccounts`] order. /// The payroll accounts, in [`PayrollAccounts`] order.
pub const PAYROLL: [ControlAccount; 7] = [ pub const PAYROLL: [ControlAccount; 7] = [
control( control(
@@ -720,6 +733,63 @@ impl Ledger {
Ok(id) 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 /// Set (or clear, with `None`) an account's tax category, and optionally
/// override the category's deductible percent (0 to 100). The category /// override the category's deductible percent (0 to 100). The category
/// must fit the account's type (see [`crate::tax::check_category`]). /// must fit the account's type (see [`crate::tax::check_category`]).
+96
View File
@@ -2443,3 +2443,99 @@ proptest! {
prop_assert_eq!(b.l.post_entry(e), v); 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();
}
+11
View File
@@ -121,6 +121,17 @@ pub fn describe(b: &Books, line: &JournalLine) -> Option<Event> {
format!("Added account {} {}", s(p, "code"), s(p, "name")), format!("Added account {} {}", s(p, "code"), s(p, "name")),
Some(Route::Account(id)), 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( "account.tax_set" => ev(
icon::TAG, icon::TAG,
format!( format!(
+59 -1
View File
@@ -23,7 +23,7 @@ use rust_decimal::Decimal;
use std::sync::{Arc, Mutex}; use std::sync::{Arc, Mutex};
/// The agent operating contract, delivered via initialize. /// 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. /// Max receipt/document upload: 25 MB.
const MAX_UPLOAD_BYTES: usize = 25 * 1024 * 1024; const MAX_UPLOAD_BYTES: usize = 25 * 1024 * 1024;
@@ -52,6 +52,35 @@ pub struct CashRoles {
} }
impl BooksServer { impl BooksServer {
/// The wanderbooks.toml settings that name account `code`.
fn config_mentions(&self, code: &str) -> Vec<String> {
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. /// Build the server around an open store.
pub fn new(store: Store, policy: ReceiptPolicy) -> Self { pub fn new(store: Store, policy: ReceiptPolicy) -> Self {
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<RenumberAccountParams>,
) -> Result<Json<RenumberAccountOut>, 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( #[tool(
name = "books.set_tax_category", 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." 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."
+23
View File
@@ -480,6 +480,29 @@ pub struct ChartTemplateOut {
pub notes: Vec<String>, pub notes: Vec<String>,
} }
/// 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<String>,
}
/// Parameters for `books.set_tax_category`. /// Parameters for `books.set_tax_category`.
#[derive(Debug, Deserialize, JsonSchema)] #[derive(Debug, Deserialize, JsonSchema)]
pub struct SetTaxCategoryParams { pub struct SetTaxCategoryParams {
+124 -1
View File
@@ -147,7 +147,7 @@ fn mcp_end_to_end_over_stdio() {
// Tool listing: 23 narrow tools, chart is read-only. // Tool listing: 23 narrow tools, chart is read-only.
let list = mcp.request("tools/list", json!({})); let list = mcp.request("tools/list", json!({}));
let tools = list["result"]["tools"].as_array().unwrap(); 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(); let chart = tools.iter().find(|t| t["name"] == "books.chart").unwrap();
assert_eq!(chart["annotations"]["readOnlyHint"], true); assert_eq!(chart["annotations"]["readOnlyHint"], true);
assert!( assert!(
@@ -1900,3 +1900,126 @@ fn bank_import_reads_dates_rules_and_files_whole() {
"the first row was journaled" "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:?}"
);
}
+29
View File
@@ -2062,6 +2062,26 @@ impl Store {
Ok(out) 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. /// Set (or clear) an account's tax category and deductible percent.
/// Journaled as `account.tax_set`. /// Journaled as `account.tax_set`.
pub fn set_account_tax( 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()), 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" => { "account.tax_set" => {
let pct = match p.get("deductible_percent").and_then(|v| v.as_str()) { let pct = match p.get("deductible_percent").and_then(|v| v.as_str()) {
Some(s) => Some(s.parse().map_err(|_| { 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" => { "account.tax_set" => {
conn.execute( conn.execute(
"UPDATE accounts SET tax_category = ?, deductible_percent = ? WHERE id = ?", "UPDATE accounts SET tax_category = ?, deductible_percent = ? WHERE id = ?",
+10
View File
@@ -952,6 +952,7 @@ mod refusals {
SetEntryVendor(u64, Option<u64>), SetEntryVendor(u64, Option<u64>),
SetVendor1099(u64, bool), SetVendor1099(u64, bool),
SetAccountTax(u64, u8), SetAccountTax(u64, u8),
Renumber(u64, u8),
// The rest of the command surface (2026-10-13). // The rest of the command surface (2026-10-13).
AttachReceipt(Option<u64>, u8), AttachReceipt(Option<u64>, u8),
FileDocument(u8), FileDocument(u8),
@@ -1025,6 +1026,7 @@ mod refusals {
(id.clone(), prop::option::of(0u64..4)).prop_map(|(e, v)| Op::SetEntryVendor(e, v)), (id.clone(), prop::option::of(0u64..4)).prop_map(|(e, v)| Op::SetEntryVendor(e, v)),
(0u64..4, any::<bool>()).prop_map(|(v, t)| Op::SetVendor1099(v, t)), (0u64..4, any::<bool>()).prop_map(|(v, t)| Op::SetVendor1099(v, t)),
(0u64..10, 0u8..4).prop_map(|(a, k)| Op::SetAccountTax(a, k)), (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)), (prop::option::of(id.clone()), 0u8..4).prop_map(|(e, b)| Op::AttachReceipt(e, b)),
(0u8..4).prop_map(Op::FileDocument), (0u8..4).prop_map(Op::FileDocument),
(0u64..6, id.clone()).prop_map(|(a, e)| Op::LinkReceipt(a, e)), (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::SetEntryVendor(e, v) => store.set_entry_vendor(e, v),
Op::SetVendor1099(v, t) => store.set_vendor_1099(v, Some(Form1099::Nec), t), 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), 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 Op::AttachReceipt(e, b) => store
.attach_receipt(e, "r.pdf", "application/pdf", &[b; 3], Some("receipt")) .attach_receipt(e, "r.pdf", "application/pdf", &[b; 3], Some("receipt"))
.map(drop), .map(drop),
+6
View File
@@ -342,6 +342,12 @@ posting.
(`[company] tax_form`, or derived from `tax_classification`). Stable (`[company] tax_form`, or derived from `tax_classification`). Stable
semantic keys (`meals`, `guaranteed_payments`, `commissions_fees`, ...), semantic keys (`meals`, `guaranteed_payments`, `commissions_fees`, ...),
never form line numbers, which change by year. 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? }`: - `books.set_tax_category { account, category?, deductible_percent? }`:
map a revenue or expense account to a category (null clears), map a revenue or expense account to a category (null clears),
optionally overriding its deductible percent. Journaled as optionally overriding its deductible percent. Journaled as