fix(core,store): refuse control accounts of the wrong type, with a reason

Invoices, bills, payments, payroll, and closes post to accounts the
engine finds by code (1200, 2000, 4000, 6999, 3999, 6100-2150). If an
account with that code existed it was used whatever its type: a 1200
"Customer Deposits" liability would have taken receivables. Now
(Stephen, 2026-10-13: never force an imported chart into our numbering)
the engine refuses with ControlAccountMismatch, naming the code, the
account, its type, the role, and the type needed, so the agent can work
it out with the human. A placeholder is refused too.

Every command checks all its control accounts before changing anything
(Ledger::check_controls, spec + SOURCE in its doc comment), so a refusal
never leaves a half-made account behind. One table of control accounts
(ledger_core::RECEIVABLES ... PAYROLL) replaces the codes duplicated
across the engine and the store.

Tests (spec-derived, all assert the books are untouched after the
refusal): an_invoice_refuses_a_1200_that_is_not_receivables,
an_invoice_refuses_a_placeholder_1200,
an_invoice_creates_nothing_when_its_second_account_is_wrong,
a_bill_refuses_a_2000_that_is_not_payables,
a_close_refuses_a_3999_that_is_not_equity.

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-13 12:29:24 -06:00
1 parent ad572ae89d
commit e2bebce051
5 files changed
+370 -122

No files matched your search

+27
View File
@@ -75,6 +75,33 @@ pub enum LedgerError {
#[error("entry {0} closes a period and cannot be reversed")]
ClosingEntryIrreversible(u64),
/// The engine posts invoices, bills, payroll, and closes to accounts it
/// finds by code (1200 receivables, 2000 payables, 4000 revenue, ...).
/// An account with that code exists but is the wrong type, or a
/// placeholder, so the engine refuses rather than post there or force
/// the chart into its numbering (Stephen, 2026-10-13). The bookkeeper
/// 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)",
if *placeholder { format!("a placeholder {found:?} account") } else { format!("a {found:?} account") }
)]
ControlAccountMismatch {
/// The code the engine looks for.
code: String,
/// The existing account's name.
name: String,
/// What the engine wanted it for, e.g. "accounts receivable".
role: String,
/// The type that role needs.
expected: crate::AccountType,
/// The existing account's type.
found: crate::AccountType,
/// Whether the existing account is a placeholder.
placeholder: bool,
},
/// The referenced journal entry does not exist.
#[error("entry {0} does not exist")]
UnknownEntry(u64),