books.renumber_account: give an account a new code, history kept
check / check (push) Canceled after 0s
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:
1 parent
ccec4e9439
commit
5b8910a1cd
10 files changed
+446
-2
No files matched your search
@@ -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,
|
||||
|
||||
@@ -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<Item = &'static ControlAccount> {
|
||||
[
|
||||
&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`]).
|
||||
|
||||
Reference in new issue
Block a user