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
check / check (push) Canceled after 0s

- 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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GWVo7x3UHw5kwCUaWxiejX
This commit is contained in:
swaitsandClaude Opus 5.5 committed 2026-10-15 10:30:16 -06:00
1 parent efa370dfa3
commit 1771d87e0e
19 files changed
+3044 -132

No files matched your search

+40 -2
View File
@@ -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`]).
+171 -10
View File
@@ -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<Item = &'static ControlAccount> {
.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<InvoiceId, Vec<(EntryId, Decimal)>>,
/// Each bill's payments (entry, amount), for aging as of a date.
bill_payments: HashMap<BillId, Vec<(EntryId, Decimal)>>,
/// 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<AccountId> {
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<AccountId> {
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<AccountId>,
) -> 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<AccountId>,
) -> 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<AccountId, LedgerError> {
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
+1 -1
View File
@@ -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};