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 (the owner, 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:
1 parent
855d51e297
commit
8559bba6bc
5 files changed
+370
-122
No files matched your search
@@ -75,6 +75,33 @@ pub enum LedgerError {
|
|||||||
#[error("entry {0} closes a period and cannot be reversed")]
|
#[error("entry {0} closes a period and cannot be reversed")]
|
||||||
ClosingEntryIrreversible(u64),
|
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 (owner, 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.
|
/// The referenced journal entry does not exist.
|
||||||
#[error("entry {0} does not exist")]
|
#[error("entry {0} does not exist")]
|
||||||
UnknownEntry(u64),
|
UnknownEntry(u64),
|
||||||
|
|||||||
@@ -25,6 +25,111 @@ use rust_decimal::Decimal;
|
|||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
use std::collections::HashMap;
|
use std::collections::HashMap;
|
||||||
|
|
||||||
|
/// An account the engine posts to by code: invoices, bills, payroll, and
|
||||||
|
/// closes find (or create) these. See [`Ledger::check_controls`].
|
||||||
|
#[derive(Clone, Copy, Debug)]
|
||||||
|
pub struct ControlAccount {
|
||||||
|
/// The code the engine looks for.
|
||||||
|
pub code: &'static str,
|
||||||
|
/// The name it gives the account when it creates it.
|
||||||
|
pub name: &'static str,
|
||||||
|
/// What the account is for, in a refusal message.
|
||||||
|
pub role: &'static str,
|
||||||
|
/// The type the role needs.
|
||||||
|
pub account_type: AccountType,
|
||||||
|
}
|
||||||
|
|
||||||
|
const fn control(
|
||||||
|
code: &'static str,
|
||||||
|
name: &'static str,
|
||||||
|
role: &'static str,
|
||||||
|
account_type: AccountType,
|
||||||
|
) -> ControlAccount {
|
||||||
|
ControlAccount {
|
||||||
|
code,
|
||||||
|
name,
|
||||||
|
role,
|
||||||
|
account_type,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A control account; see [`ControlAccount`].
|
||||||
|
pub const RECEIVABLES: ControlAccount = control(
|
||||||
|
"1200",
|
||||||
|
"Accounts Receivable",
|
||||||
|
"accounts receivable",
|
||||||
|
AccountType::Asset,
|
||||||
|
);
|
||||||
|
/// A control account; see [`ControlAccount`].
|
||||||
|
pub const REVENUE: ControlAccount =
|
||||||
|
control("4000", "Revenue", "invoice revenue", AccountType::Revenue);
|
||||||
|
/// A control account; see [`ControlAccount`].
|
||||||
|
pub const PAYABLES: ControlAccount = control(
|
||||||
|
"2000",
|
||||||
|
"Accounts Payable",
|
||||||
|
"accounts payable",
|
||||||
|
AccountType::Liability,
|
||||||
|
);
|
||||||
|
/// A control account; see [`ControlAccount`].
|
||||||
|
pub const GENERAL_EXPENSES: ControlAccount = control(
|
||||||
|
"6999",
|
||||||
|
"General Expenses",
|
||||||
|
"bill expense",
|
||||||
|
AccountType::Expense,
|
||||||
|
);
|
||||||
|
/// A control account; see [`ControlAccount`].
|
||||||
|
pub const RETAINED_EARNINGS: ControlAccount = control(
|
||||||
|
periods::RETAINED_EARNINGS_CODE,
|
||||||
|
"Retained Earnings",
|
||||||
|
"retained earnings",
|
||||||
|
AccountType::Equity,
|
||||||
|
);
|
||||||
|
/// The payroll accounts, in [`PayrollAccounts`] order.
|
||||||
|
pub const PAYROLL: [ControlAccount; 7] = [
|
||||||
|
control(
|
||||||
|
"6100",
|
||||||
|
"Salary & Wage Expense",
|
||||||
|
"salary expense",
|
||||||
|
AccountType::Expense,
|
||||||
|
),
|
||||||
|
control(
|
||||||
|
"6110",
|
||||||
|
"Payroll Tax Expense",
|
||||||
|
"payroll tax expense",
|
||||||
|
AccountType::Expense,
|
||||||
|
),
|
||||||
|
control(
|
||||||
|
"2110",
|
||||||
|
"Federal Withholding Payable",
|
||||||
|
"federal withholding payable",
|
||||||
|
AccountType::Liability,
|
||||||
|
),
|
||||||
|
control(
|
||||||
|
"2120",
|
||||||
|
"State Withholding Payable",
|
||||||
|
"state withholding payable",
|
||||||
|
AccountType::Liability,
|
||||||
|
),
|
||||||
|
control(
|
||||||
|
"2130",
|
||||||
|
"SS Tax Payable",
|
||||||
|
"social security tax payable",
|
||||||
|
AccountType::Liability,
|
||||||
|
),
|
||||||
|
control(
|
||||||
|
"2140",
|
||||||
|
"Medicare Tax Payable",
|
||||||
|
"Medicare tax payable",
|
||||||
|
AccountType::Liability,
|
||||||
|
),
|
||||||
|
control(
|
||||||
|
"2150",
|
||||||
|
"Other Deductions Payable",
|
||||||
|
"other deductions payable",
|
||||||
|
AccountType::Liability,
|
||||||
|
),
|
||||||
|
];
|
||||||
|
|
||||||
/// A posted line that has not cleared, for the reconciliation report.
|
/// A posted line that has not cleared, for the reconciliation report.
|
||||||
#[derive(Clone, Debug, Serialize, Deserialize)]
|
#[derive(Clone, Debug, Serialize, Deserialize)]
|
||||||
pub struct UnclearedLine {
|
pub struct UnclearedLine {
|
||||||
@@ -1124,6 +1229,9 @@ impl Ledger {
|
|||||||
return Err(LedgerError::CloseOutOfOrder(id, later.id));
|
return Err(LedgerError::CloseOutOfOrder(id, later.id));
|
||||||
}
|
}
|
||||||
self.validate_close_to(to)?;
|
self.validate_close_to(to)?;
|
||||||
|
if matches!(to, periods::CloseTo::RetainedEarnings { .. }) {
|
||||||
|
self.check_controls(&[RETAINED_EARNINGS])?;
|
||||||
|
}
|
||||||
// An entry posts exactly when there is income to close or a sweep
|
// An entry posts exactly when there is income to close or a sweep
|
||||||
// account with a balance. Account creation (Retained Earnings) does
|
// account with a balance. Account creation (Retained Earnings) does
|
||||||
// not consume entry ids.
|
// not consume entry ids.
|
||||||
@@ -1257,6 +1365,56 @@ impl Ledger {
|
|||||||
.collect()
|
.collect()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Check the control accounts a command will post to by code, before
|
||||||
|
/// anything changes: each is either missing (it will be created) or a
|
||||||
|
/// postable account of the right type.
|
||||||
|
///
|
||||||
|
/// ```text
|
||||||
|
/// GOAL: never post a control role (receivables, payables, revenue,
|
||||||
|
/// payroll, retained earnings) to an account of the wrong kind.
|
||||||
|
/// SOURCE: docs/spec/rules.md B-4 (the elements); DECISION (owner,
|
||||||
|
/// 2026-10-13): refuse with a reason, never force the chart's
|
||||||
|
/// numbering.
|
||||||
|
/// INVARIANTS:
|
||||||
|
/// 1. MUST NOT mutate anything.
|
||||||
|
/// 2. NEVER accepts an existing account of another type, or a
|
||||||
|
/// placeholder, for a control role.
|
||||||
|
/// SCENARIOS:
|
||||||
|
/// 1. IF the code is free: SHOULD accept (the command creates it).
|
||||||
|
/// 2. IF the code holds a postable account of the right type: SHOULD
|
||||||
|
/// accept.
|
||||||
|
/// 3. IF it holds another type or a placeholder:
|
||||||
|
/// - SHOULD refuse with ControlAccountMismatch, naming the code,
|
||||||
|
/// the account, its type, the role, and the type needed
|
||||||
|
/// - NEVER post to it, and NEVER rename or renumber it
|
||||||
|
/// BECAUSE imported charts use their own numbering; the human
|
||||||
|
/// decides.
|
||||||
|
/// ```
|
||||||
|
fn check_controls(&self, controls: &[ControlAccount]) -> Result<(), LedgerError> {
|
||||||
|
for c in controls {
|
||||||
|
if let Some(a) = self.chart.find_by_code(c.code)
|
||||||
|
&& (a.account_type != c.account_type || a.placeholder)
|
||||||
|
{
|
||||||
|
return Err(LedgerError::ControlAccountMismatch {
|
||||||
|
code: c.code.to_string(),
|
||||||
|
name: a.name.clone(),
|
||||||
|
role: c.role.to_string(),
|
||||||
|
expected: c.account_type,
|
||||||
|
found: a.account_type,
|
||||||
|
placeholder: a.placeholder,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Ensure a control account exists, creating it if missing. Callers
|
||||||
|
/// 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)
|
||||||
|
}
|
||||||
|
|
||||||
/// Ensure an account exists by code, creating it with the given type
|
/// Ensure an account exists by code, creating it with the given type
|
||||||
/// and name if missing. Returns the account id.
|
/// and name if missing. Returns the account id.
|
||||||
fn ensure_account(&mut self, code: &str, name: &str, account_type: AccountType) -> AccountId {
|
fn ensure_account(&mut self, code: &str, name: &str, account_type: AccountType) -> AccountId {
|
||||||
@@ -1285,8 +1443,9 @@ impl Ledger {
|
|||||||
return Err(LedgerError::ClosedPeriod(pid, date.to_string()));
|
return Err(LedgerError::ClosedPeriod(pid, date.to_string()));
|
||||||
}
|
}
|
||||||
let total: Decimal = lines.iter().map(|l| l.amount).sum();
|
let total: Decimal = lines.iter().map(|l| l.amount).sum();
|
||||||
let ar = self.ensure_account("1200", "Accounts Receivable", AccountType::Asset);
|
self.check_controls(&[RECEIVABLES, REVENUE])?;
|
||||||
let revenue = self.ensure_account("4000", "Revenue", AccountType::Revenue);
|
let ar = self.ensure_control(&RECEIVABLES);
|
||||||
|
let revenue = self.ensure_control(&REVENUE);
|
||||||
let entry = self.create_entry(date, &format!("Invoice {}", self.next_invoice_id));
|
let entry = self.create_entry(date, &format!("Invoice {}", self.next_invoice_id));
|
||||||
let money = Money::new(total, self.base_currency);
|
let money = Money::new(total, self.base_currency);
|
||||||
self.add_line(entry, ar, Side::Debit, money)?;
|
self.add_line(entry, ar, Side::Debit, money)?;
|
||||||
@@ -1329,8 +1488,9 @@ impl Ledger {
|
|||||||
return Err(LedgerError::ClosedPeriod(pid, date.to_string()));
|
return Err(LedgerError::ClosedPeriod(pid, date.to_string()));
|
||||||
}
|
}
|
||||||
let total: Decimal = lines.iter().map(|l| l.amount).sum();
|
let total: Decimal = lines.iter().map(|l| l.amount).sum();
|
||||||
let expense = self.ensure_account("6999", "General Expenses", AccountType::Expense);
|
self.check_controls(&[GENERAL_EXPENSES, PAYABLES])?;
|
||||||
let ap = self.ensure_account("2000", "Accounts Payable", AccountType::Liability);
|
let expense = self.ensure_control(&GENERAL_EXPENSES);
|
||||||
|
let ap = self.ensure_control(&PAYABLES);
|
||||||
let entry = self.create_entry(date, &format!("Bill {}", self.next_bill_id));
|
let entry = self.create_entry(date, &format!("Bill {}", self.next_bill_id));
|
||||||
let money = Money::new(total, self.base_currency);
|
let money = Money::new(total, self.base_currency);
|
||||||
self.add_line(entry, expense, Side::Debit, money)?;
|
self.add_line(entry, expense, Side::Debit, money)?;
|
||||||
@@ -1389,7 +1549,8 @@ impl Ledger {
|
|||||||
if let Some(pid) = self.closed_period_containing(date) {
|
if let Some(pid) = self.closed_period_containing(date) {
|
||||||
return Err(LedgerError::ClosedPeriod(pid, date.to_string()));
|
return Err(LedgerError::ClosedPeriod(pid, date.to_string()));
|
||||||
}
|
}
|
||||||
let ar = self.ensure_account("1200", "Accounts Receivable", AccountType::Asset);
|
self.check_controls(&[RECEIVABLES])?;
|
||||||
|
let ar = self.ensure_control(&RECEIVABLES);
|
||||||
let entry = self.create_entry(date, &format!("Payment on invoice {}", invoice_id));
|
let entry = self.create_entry(date, &format!("Payment on invoice {}", invoice_id));
|
||||||
let money = Money::new(amount, self.base_currency);
|
let money = Money::new(amount, self.base_currency);
|
||||||
self.add_line(entry, cash_account, Side::Debit, money)?;
|
self.add_line(entry, cash_account, Side::Debit, money)?;
|
||||||
@@ -1425,7 +1586,8 @@ impl Ledger {
|
|||||||
if let Some(pid) = self.closed_period_containing(date) {
|
if let Some(pid) = self.closed_period_containing(date) {
|
||||||
return Err(LedgerError::ClosedPeriod(pid, date.to_string()));
|
return Err(LedgerError::ClosedPeriod(pid, date.to_string()));
|
||||||
}
|
}
|
||||||
let ap = self.ensure_account("2000", "Accounts Payable", AccountType::Liability);
|
self.check_controls(&[PAYABLES])?;
|
||||||
|
let ap = self.ensure_control(&PAYABLES);
|
||||||
let entry = self.create_entry(date, &format!("Payment on bill {}", bill_id));
|
let entry = self.create_entry(date, &format!("Payment on bill {}", bill_id));
|
||||||
let money = Money::new(amount, self.base_currency);
|
let money = Money::new(amount, self.base_currency);
|
||||||
self.add_line(entry, ap, Side::Debit, money)?;
|
self.add_line(entry, ap, Side::Debit, money)?;
|
||||||
@@ -1788,6 +1950,7 @@ impl Ledger {
|
|||||||
cash_account: AccountId,
|
cash_account: AccountId,
|
||||||
) -> Result<(), LedgerError> {
|
) -> Result<(), LedgerError> {
|
||||||
validate_payroll_lines(lines)?;
|
validate_payroll_lines(lines)?;
|
||||||
|
self.check_controls(&PAYROLL)?;
|
||||||
self.chart
|
self.chart
|
||||||
.get(cash_account)
|
.get(cash_account)
|
||||||
.ok_or(LedgerError::UnknownAccount(cash_account))?;
|
.ok_or(LedgerError::UnknownAccount(cash_account))?;
|
||||||
@@ -1801,38 +1964,15 @@ impl Ledger {
|
|||||||
/// missing. Journaling stores ensure these through their own journaled
|
/// missing. Journaling stores ensure these through their own journaled
|
||||||
/// path first; this then no-ops and replay stays deterministic.
|
/// path first; this then no-ops and replay stays deterministic.
|
||||||
fn payroll_accounts(&mut self) -> PayrollAccounts {
|
fn payroll_accounts(&mut self) -> PayrollAccounts {
|
||||||
|
let [salary, tax, federal, state, ss, medicare, other] = PAYROLL;
|
||||||
PayrollAccounts {
|
PayrollAccounts {
|
||||||
salary_expense: self.ensure_account(
|
salary_expense: self.ensure_control(&salary),
|
||||||
"6100",
|
payroll_tax_expense: self.ensure_control(&tax),
|
||||||
"Salary & Wage Expense",
|
federal_payable: self.ensure_control(&federal),
|
||||||
AccountType::Expense,
|
state_payable: self.ensure_control(&state),
|
||||||
),
|
ss_payable: self.ensure_control(&ss),
|
||||||
payroll_tax_expense: self.ensure_account(
|
medicare_payable: self.ensure_control(&medicare),
|
||||||
"6110",
|
other_payable: self.ensure_control(&other),
|
||||||
"Payroll Tax Expense",
|
|
||||||
AccountType::Expense,
|
|
||||||
),
|
|
||||||
federal_payable: self.ensure_account(
|
|
||||||
"2110",
|
|
||||||
"Federal Withholding Payable",
|
|
||||||
AccountType::Liability,
|
|
||||||
),
|
|
||||||
state_payable: self.ensure_account(
|
|
||||||
"2120",
|
|
||||||
"State Withholding Payable",
|
|
||||||
AccountType::Liability,
|
|
||||||
),
|
|
||||||
ss_payable: self.ensure_account("2130", "SS Tax Payable", AccountType::Liability),
|
|
||||||
medicare_payable: self.ensure_account(
|
|
||||||
"2140",
|
|
||||||
"Medicare Tax Payable",
|
|
||||||
AccountType::Liability,
|
|
||||||
),
|
|
||||||
other_payable: self.ensure_account(
|
|
||||||
"2150",
|
|
||||||
"Other Deductions Payable",
|
|
||||||
AccountType::Liability,
|
|
||||||
),
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1904,6 +2044,7 @@ impl Ledger {
|
|||||||
self.chart
|
self.chart
|
||||||
.get(cash_account)
|
.get(cash_account)
|
||||||
.ok_or(LedgerError::UnknownAccount(cash_account))?;
|
.ok_or(LedgerError::UnknownAccount(cash_account))?;
|
||||||
|
self.check_controls(&PAYROLL)?;
|
||||||
let payables = self.payroll_tax_payables();
|
let payables = self.payroll_tax_payables();
|
||||||
let total: Decimal = payables.iter().map(|(_, b)| *b).sum();
|
let total: Decimal = payables.iter().map(|(_, b)| *b).sum();
|
||||||
if total.is_zero() {
|
if total.is_zero() {
|
||||||
|
|||||||
@@ -49,7 +49,10 @@ pub use classes::{Class, ClassId};
|
|||||||
pub use error::LedgerError;
|
pub use error::LedgerError;
|
||||||
pub use form1099::{Form1099, Vendor1099Line, Vendor1099Report, vendor_1099_report};
|
pub use form1099::{Form1099, Vendor1099Line, Vendor1099Report, vendor_1099_report};
|
||||||
pub use journal::{EntryId, EntryStatus, JournalEntry, JournalLine};
|
pub use journal::{EntryId, EntryStatus, JournalEntry, JournalLine};
|
||||||
pub use ledger::{Ledger, ReconciliationReport, UnclearedLine};
|
pub use ledger::{
|
||||||
|
ControlAccount, GENERAL_EXPENSES, Ledger, PAYABLES, PAYROLL, RECEIVABLES, RETAINED_EARNINGS,
|
||||||
|
REVENUE, ReconciliationReport, UnclearedLine,
|
||||||
|
};
|
||||||
pub use money::{Currency, Money};
|
pub use money::{Currency, Money};
|
||||||
pub use payouts::{Payout, PayoutAdjustment, PayoutLine, PayoutTotals, payout_plan};
|
pub use payouts::{Payout, PayoutAdjustment, PayoutLine, PayoutTotals, payout_plan};
|
||||||
pub use payroll::{PayrollLine, PayrollRun, PayrollRunId};
|
pub use payroll::{PayrollLine, PayrollRun, PayrollRunId};
|
||||||
|
|||||||
@@ -774,3 +774,135 @@ fn cash_flow_with_registered_asset_and_disposal() {
|
|||||||
assert_eq!(cf.cash_from_financing, Decimal::new(10000000, 2));
|
assert_eq!(cf.cash_from_financing, Decimal::new(10000000, 2));
|
||||||
// Sum: 0 - 400 + 100,000 = 99,600. Matches cash.
|
// Sum: 0 - 400 + 100,000 = 99,600. Matches cash.
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Control accounts (owner, 2026-10-13): the engine posts invoices, bills,
|
||||||
|
// payroll, and closes to accounts it finds by code. When an account with
|
||||||
|
// that code exists but is the wrong kind, it refuses, says why, and changes
|
||||||
|
// nothing, rather than posting to it or forcing the chart into its
|
||||||
|
// numbering. SOURCE: rules.md B-4 (the elements), DECISION.
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
fn books_with(code: &str, name: &str, t: AccountType, placeholder: bool) -> Ledger {
|
||||||
|
let mut l = Ledger::new(Currency::USD);
|
||||||
|
l.add_account("1000", "Cash", AccountType::Asset, None, false)
|
||||||
|
.unwrap();
|
||||||
|
l.add_account(code, name, t, None, placeholder).unwrap();
|
||||||
|
l
|
||||||
|
}
|
||||||
|
|
||||||
|
fn doc(amount: u64) -> Vec<ledger_core::DocumentLine> {
|
||||||
|
vec![ledger_core::DocumentLine {
|
||||||
|
description: "work".into(),
|
||||||
|
amount: Decimal::from(amount),
|
||||||
|
}]
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Refused means untouched: same chart, same ids, nothing posted.
|
||||||
|
fn assert_untouched(before: &Ledger, after: &Ledger) {
|
||||||
|
assert_eq!(after.chart().len(), before.chart().len());
|
||||||
|
assert_eq!(after.next_entry_id(), before.next_entry_id());
|
||||||
|
assert_eq!(after.next_invoice_id(), before.next_invoice_id());
|
||||||
|
assert_eq!(after.next_bill_id(), before.next_bill_id());
|
||||||
|
assert_eq!(
|
||||||
|
after.posted_entries().count(),
|
||||||
|
before.posted_entries().count()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn an_invoice_refuses_a_1200_that_is_not_receivables() {
|
||||||
|
let mut l = books_with("1200", "Customer Deposits", AccountType::Liability, false);
|
||||||
|
let c = l.add_customer("Acme").unwrap();
|
||||||
|
let before = l.clone();
|
||||||
|
let err = l
|
||||||
|
.create_invoice(c, date(5), date(30), doc(100))
|
||||||
|
.unwrap_err();
|
||||||
|
assert_eq!(
|
||||||
|
err,
|
||||||
|
LedgerError::ControlAccountMismatch {
|
||||||
|
code: "1200".into(),
|
||||||
|
name: "Customer Deposits".into(),
|
||||||
|
role: "accounts receivable".into(),
|
||||||
|
expected: AccountType::Asset,
|
||||||
|
found: AccountType::Liability,
|
||||||
|
placeholder: false,
|
||||||
|
}
|
||||||
|
);
|
||||||
|
// The reason names the account, what it is, and what was needed.
|
||||||
|
let why = err.to_string();
|
||||||
|
assert!(
|
||||||
|
why.contains("1200") && why.contains("Customer Deposits"),
|
||||||
|
"{why}"
|
||||||
|
);
|
||||||
|
assert!(why.contains("Liability") && why.contains("Asset"), "{why}");
|
||||||
|
assert_untouched(&before, &l);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn an_invoice_refuses_a_placeholder_1200() {
|
||||||
|
let mut l = books_with("1200", "Receivables", AccountType::Asset, true);
|
||||||
|
let c = l.add_customer("Acme").unwrap();
|
||||||
|
let before = l.clone();
|
||||||
|
assert!(matches!(
|
||||||
|
l.create_invoice(c, date(5), date(30), doc(100)),
|
||||||
|
Err(LedgerError::ControlAccountMismatch {
|
||||||
|
placeholder: true,
|
||||||
|
..
|
||||||
|
})
|
||||||
|
));
|
||||||
|
assert_untouched(&before, &l);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn an_invoice_creates_nothing_when_its_second_account_is_wrong() {
|
||||||
|
// 1200 is missing (would be created) but 4000 is the wrong type: the
|
||||||
|
// refusal must come before 1200 is added.
|
||||||
|
let mut l = books_with("4000", "Owner Loans", AccountType::Liability, false);
|
||||||
|
let c = l.add_customer("Acme").unwrap();
|
||||||
|
let before = l.clone();
|
||||||
|
assert!(matches!(
|
||||||
|
l.create_invoice(c, date(5), date(30), doc(100)),
|
||||||
|
Err(LedgerError::ControlAccountMismatch { .. })
|
||||||
|
));
|
||||||
|
assert!(l.chart().find_by_code("1200").is_none());
|
||||||
|
assert_untouched(&before, &l);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_bill_refuses_a_2000_that_is_not_payables() {
|
||||||
|
let mut l = books_with("2000", "Savings", AccountType::Asset, false);
|
||||||
|
let v = l.add_vendor("Vend").unwrap();
|
||||||
|
let before = l.clone();
|
||||||
|
assert!(matches!(
|
||||||
|
l.create_bill(v, date(5), date(30), doc(100)),
|
||||||
|
Err(LedgerError::ControlAccountMismatch {
|
||||||
|
expected: AccountType::Liability,
|
||||||
|
..
|
||||||
|
})
|
||||||
|
));
|
||||||
|
assert_untouched(&before, &l);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_close_refuses_a_3999_that_is_not_equity() {
|
||||||
|
let mut l = books_with("3999", "Suspense", AccountType::Asset, false);
|
||||||
|
let sales = l
|
||||||
|
.add_account("4000", "Sales", AccountType::Revenue, None, false)
|
||||||
|
.unwrap();
|
||||||
|
let e = l.create_entry(date(5), "sale");
|
||||||
|
l.add_line(e, 1, Side::Debit, usd(100)).unwrap();
|
||||||
|
l.add_line(e, sales, Side::Credit, usd(100)).unwrap();
|
||||||
|
l.post_entry(e).unwrap();
|
||||||
|
let jan = l.create_period("2026-01", date(1), date(31)).unwrap();
|
||||||
|
let before = l.clone();
|
||||||
|
assert!(matches!(
|
||||||
|
l.close_period(jan),
|
||||||
|
Err(LedgerError::ControlAccountMismatch {
|
||||||
|
expected: AccountType::Equity,
|
||||||
|
..
|
||||||
|
})
|
||||||
|
));
|
||||||
|
assert_untouched(&before, &l);
|
||||||
|
assert!(l.closed_period_containing(date(5)).is_none());
|
||||||
|
}
|
||||||
@@ -984,11 +984,7 @@ impl Store {
|
|||||||
// Retained Earnings must exist before the close so its creation
|
// Retained Earnings must exist before the close so its creation
|
||||||
// (if needed) is journaled as account.added; the close's own
|
// (if needed) is journaled as account.added; the close's own
|
||||||
// ensure then no-ops and replay stays deterministic.
|
// ensure then no-ops and replay stays deterministic.
|
||||||
self.ensure_control_account(
|
self.ensure_control_account(&ledger_core::RETAINED_EARNINGS)?;
|
||||||
ledger_core::RETAINED_EARNINGS_CODE,
|
|
||||||
"Retained Earnings",
|
|
||||||
AccountType::Equity,
|
|
||||||
)?;
|
|
||||||
}
|
}
|
||||||
let closing_entry = self.ledger.close_period_to(id, to)?;
|
let closing_entry = self.ledger.close_period_to(id, to)?;
|
||||||
let entry_detail = match closing_entry {
|
let entry_detail = match closing_entry {
|
||||||
@@ -1091,14 +1087,12 @@ impl Store {
|
|||||||
|
|
||||||
fn ensure_control_account(
|
fn ensure_control_account(
|
||||||
&mut self,
|
&mut self,
|
||||||
code: &str,
|
c: &ledger_core::ControlAccount,
|
||||||
name: &str,
|
|
||||||
account_type: AccountType,
|
|
||||||
) -> Result<AccountId, StoreError> {
|
) -> Result<AccountId, StoreError> {
|
||||||
if let Some(a) = self.ledger.chart().find_by_code(code) {
|
if let Some(a) = self.ledger.chart().find_by_code(c.code) {
|
||||||
return Ok(a.id);
|
return Ok(a.id);
|
||||||
}
|
}
|
||||||
self.add_account(code, name, account_type, None, false)
|
self.add_account(c.code, c.name, c.account_type, None, false)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Journal an auto-posted two-line entry (debit one account, credit
|
/// Journal an auto-posted two-line entry (debit one account, credit
|
||||||
@@ -1197,8 +1191,8 @@ impl Store {
|
|||||||
lines: Vec<DocumentLine>,
|
lines: Vec<DocumentLine>,
|
||||||
) -> Result<InvoiceId, StoreError> {
|
) -> Result<InvoiceId, StoreError> {
|
||||||
self.rehearse(|l| {
|
self.rehearse(|l| {
|
||||||
ensure_on(l, "1200", "Accounts Receivable", AccountType::Asset)?;
|
ensure_on(l, &ledger_core::RECEIVABLES)?;
|
||||||
ensure_on(l, "4000", "Revenue", AccountType::Revenue)?;
|
ensure_on(l, &ledger_core::REVENUE)?;
|
||||||
l.create_invoice(customer_id, date, due_date, lines.clone())
|
l.create_invoice(customer_id, date, due_date, lines.clone())
|
||||||
})?;
|
})?;
|
||||||
// Pure pre-validation mirroring Ledger::create_invoice, so the
|
// Pure pre-validation mirroring Ledger::create_invoice, so the
|
||||||
@@ -1213,8 +1207,8 @@ impl Store {
|
|||||||
if let Some(pid) = self.ledger.closed_period_containing(date) {
|
if let Some(pid) = self.ledger.closed_period_containing(date) {
|
||||||
return Err(LedgerError::ClosedPeriod(pid, date.to_string()).into());
|
return Err(LedgerError::ClosedPeriod(pid, date.to_string()).into());
|
||||||
}
|
}
|
||||||
let ar = self.ensure_control_account("1200", "Accounts Receivable", AccountType::Asset)?;
|
let ar = self.ensure_control_account(&ledger_core::RECEIVABLES)?;
|
||||||
let revenue = self.ensure_control_account("4000", "Revenue", AccountType::Revenue)?;
|
let revenue = self.ensure_control_account(&ledger_core::REVENUE)?;
|
||||||
let invoice_id = self.ledger.next_invoice_id();
|
let invoice_id = self.ledger.next_invoice_id();
|
||||||
let total: Decimal = lines.iter().map(|l| l.amount).sum();
|
let total: Decimal = lines.iter().map(|l| l.amount).sum();
|
||||||
let description = format!("Invoice {invoice_id}");
|
let description = format!("Invoice {invoice_id}");
|
||||||
@@ -1261,8 +1255,8 @@ impl Store {
|
|||||||
lines: Vec<DocumentLine>,
|
lines: Vec<DocumentLine>,
|
||||||
) -> Result<BillId, StoreError> {
|
) -> Result<BillId, StoreError> {
|
||||||
self.rehearse(|l| {
|
self.rehearse(|l| {
|
||||||
ensure_on(l, "6999", "General Expenses", AccountType::Expense)?;
|
ensure_on(l, &ledger_core::GENERAL_EXPENSES)?;
|
||||||
ensure_on(l, "2000", "Accounts Payable", AccountType::Liability)?;
|
ensure_on(l, &ledger_core::PAYABLES)?;
|
||||||
l.create_bill(vendor_id, date, due_date, lines.clone())
|
l.create_bill(vendor_id, date, due_date, lines.clone())
|
||||||
})?;
|
})?;
|
||||||
if self.ledger.get_vendor(vendor_id).is_none() {
|
if self.ledger.get_vendor(vendor_id).is_none() {
|
||||||
@@ -1273,9 +1267,8 @@ impl Store {
|
|||||||
if let Some(pid) = self.ledger.closed_period_containing(date) {
|
if let Some(pid) = self.ledger.closed_period_containing(date) {
|
||||||
return Err(LedgerError::ClosedPeriod(pid, date.to_string()).into());
|
return Err(LedgerError::ClosedPeriod(pid, date.to_string()).into());
|
||||||
}
|
}
|
||||||
let expense =
|
let expense = self.ensure_control_account(&ledger_core::GENERAL_EXPENSES)?;
|
||||||
self.ensure_control_account("6999", "General Expenses", AccountType::Expense)?;
|
let ap = self.ensure_control_account(&ledger_core::PAYABLES)?;
|
||||||
let ap = self.ensure_control_account("2000", "Accounts Payable", AccountType::Liability)?;
|
|
||||||
let bill_id = self.ledger.next_bill_id();
|
let bill_id = self.ledger.next_bill_id();
|
||||||
let total: Decimal = lines.iter().map(|l| l.amount).sum();
|
let total: Decimal = lines.iter().map(|l| l.amount).sum();
|
||||||
let description = format!("Bill {bill_id}");
|
let description = format!("Bill {bill_id}");
|
||||||
@@ -1317,7 +1310,7 @@ impl Store {
|
|||||||
cash_account: AccountId,
|
cash_account: AccountId,
|
||||||
) -> Result<EntryId, StoreError> {
|
) -> Result<EntryId, StoreError> {
|
||||||
self.rehearse(|l| {
|
self.rehearse(|l| {
|
||||||
ensure_on(l, "1200", "Accounts Receivable", AccountType::Asset)?;
|
ensure_on(l, &ledger_core::RECEIVABLES)?;
|
||||||
l.receive_payment(invoice_id, date, amount, cash_account)
|
l.receive_payment(invoice_id, date, amount, cash_account)
|
||||||
})?;
|
})?;
|
||||||
// Pure pre-validation mirroring Ledger::receive_payment, plus the
|
// Pure pre-validation mirroring Ledger::receive_payment, plus the
|
||||||
@@ -1345,7 +1338,7 @@ impl Store {
|
|||||||
if cash.placeholder {
|
if cash.placeholder {
|
||||||
return Err(LedgerError::PlaceholderAccount(cash.code.clone()).into());
|
return Err(LedgerError::PlaceholderAccount(cash.code.clone()).into());
|
||||||
}
|
}
|
||||||
let ar = self.ensure_control_account("1200", "Accounts Receivable", AccountType::Asset)?;
|
let ar = self.ensure_control_account(&ledger_core::RECEIVABLES)?;
|
||||||
let description = format!("Payment on invoice {invoice_id}");
|
let description = format!("Payment on invoice {invoice_id}");
|
||||||
let (entry_id, mut journaled) =
|
let (entry_id, mut journaled) =
|
||||||
self.journal_auto_entry(date, &description, cash_account, ar, amount)?;
|
self.journal_auto_entry(date, &description, cash_account, ar, amount)?;
|
||||||
@@ -1379,7 +1372,7 @@ impl Store {
|
|||||||
cash_account: AccountId,
|
cash_account: AccountId,
|
||||||
) -> Result<EntryId, StoreError> {
|
) -> Result<EntryId, StoreError> {
|
||||||
self.rehearse(|l| {
|
self.rehearse(|l| {
|
||||||
ensure_on(l, "2000", "Accounts Payable", AccountType::Liability)?;
|
ensure_on(l, &ledger_core::PAYABLES)?;
|
||||||
l.pay_bill(bill_id, date, amount, cash_account)
|
l.pay_bill(bill_id, date, amount, cash_account)
|
||||||
})?;
|
})?;
|
||||||
if amount <= Decimal::ZERO {
|
if amount <= Decimal::ZERO {
|
||||||
@@ -1404,7 +1397,7 @@ impl Store {
|
|||||||
if cash.placeholder {
|
if cash.placeholder {
|
||||||
return Err(LedgerError::PlaceholderAccount(cash.code.clone()).into());
|
return Err(LedgerError::PlaceholderAccount(cash.code.clone()).into());
|
||||||
}
|
}
|
||||||
let ap = self.ensure_control_account("2000", "Accounts Payable", AccountType::Liability)?;
|
let ap = self.ensure_control_account(&ledger_core::PAYABLES)?;
|
||||||
let description = format!("Payment on bill {bill_id}");
|
let description = format!("Payment on bill {bill_id}");
|
||||||
let (entry_id, mut journaled) =
|
let (entry_id, mut journaled) =
|
||||||
self.journal_auto_entry(date, &description, ap, cash_account, amount)?;
|
self.journal_auto_entry(date, &description, ap, cash_account, amount)?;
|
||||||
@@ -1511,42 +1504,15 @@ impl Store {
|
|||||||
/// then no-ops, which keeps replay deterministic: every account
|
/// then no-ops, which keeps replay deterministic: every account
|
||||||
/// creation is an `account.added` event.
|
/// creation is an `account.added` event.
|
||||||
fn ensure_payroll_accounts(&mut self) -> Result<PayrollAccounts, StoreError> {
|
fn ensure_payroll_accounts(&mut self) -> Result<PayrollAccounts, StoreError> {
|
||||||
|
let [salary, tax, federal, state, ss, medicare, other] = ledger_core::PAYROLL;
|
||||||
Ok(PayrollAccounts {
|
Ok(PayrollAccounts {
|
||||||
salary_expense: self.ensure_control_account(
|
salary_expense: self.ensure_control_account(&salary)?,
|
||||||
"6100",
|
payroll_tax_expense: self.ensure_control_account(&tax)?,
|
||||||
"Salary & Wage Expense",
|
federal_payable: self.ensure_control_account(&federal)?,
|
||||||
AccountType::Expense,
|
state_payable: self.ensure_control_account(&state)?,
|
||||||
)?,
|
ss_payable: self.ensure_control_account(&ss)?,
|
||||||
payroll_tax_expense: self.ensure_control_account(
|
medicare_payable: self.ensure_control_account(&medicare)?,
|
||||||
"6110",
|
other_payable: self.ensure_control_account(&other)?,
|
||||||
"Payroll Tax Expense",
|
|
||||||
AccountType::Expense,
|
|
||||||
)?,
|
|
||||||
federal_payable: self.ensure_control_account(
|
|
||||||
"2110",
|
|
||||||
"Federal Withholding Payable",
|
|
||||||
AccountType::Liability,
|
|
||||||
)?,
|
|
||||||
state_payable: self.ensure_control_account(
|
|
||||||
"2120",
|
|
||||||
"State Withholding Payable",
|
|
||||||
AccountType::Liability,
|
|
||||||
)?,
|
|
||||||
ss_payable: self.ensure_control_account(
|
|
||||||
"2130",
|
|
||||||
"SS Tax Payable",
|
|
||||||
AccountType::Liability,
|
|
||||||
)?,
|
|
||||||
medicare_payable: self.ensure_control_account(
|
|
||||||
"2140",
|
|
||||||
"Medicare Tax Payable",
|
|
||||||
AccountType::Liability,
|
|
||||||
)?,
|
|
||||||
other_payable: self.ensure_control_account(
|
|
||||||
"2150",
|
|
||||||
"Other Deductions Payable",
|
|
||||||
AccountType::Liability,
|
|
||||||
)?,
|
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1563,8 +1529,8 @@ impl Store {
|
|||||||
cash_account: AccountId,
|
cash_account: AccountId,
|
||||||
) -> Result<PayrollRunId, StoreError> {
|
) -> Result<PayrollRunId, StoreError> {
|
||||||
self.rehearse(|l| {
|
self.rehearse(|l| {
|
||||||
for (code, name, account_type) in PAYROLL_ACCOUNTS {
|
for c in &ledger_core::PAYROLL {
|
||||||
ensure_on(l, code, name, account_type)?;
|
ensure_on(l, c)?;
|
||||||
}
|
}
|
||||||
l.run_payroll(date, lines.clone(), cash_account)
|
l.run_payroll(date, lines.clone(), cash_account)
|
||||||
})?;
|
})?;
|
||||||
@@ -2888,34 +2854,13 @@ fn get_str<'a>(p: &'a Value, key: &str) -> Result<&'a str, StoreError> {
|
|||||||
/// hydration, `verify`, and `rebuild`: the journal is the script, the engine
|
/// hydration, `verify`, and `rebuild`: the journal is the script, the engine
|
||||||
/// is the actor.
|
/// is the actor.
|
||||||
/// [`Store::ensure_control_account`] on a bare ledger, for rehearsals.
|
/// [`Store::ensure_control_account`] on a bare ledger, for rehearsals.
|
||||||
fn ensure_on(
|
fn ensure_on(l: &mut Ledger, c: &ledger_core::ControlAccount) -> Result<AccountId, LedgerError> {
|
||||||
l: &mut Ledger,
|
match l.chart().find_by_code(c.code) {
|
||||||
code: &str,
|
|
||||||
name: &str,
|
|
||||||
account_type: AccountType,
|
|
||||||
) -> Result<AccountId, LedgerError> {
|
|
||||||
match l.chart().find_by_code(code) {
|
|
||||||
Some(a) => Ok(a.id),
|
Some(a) => Ok(a.id),
|
||||||
None => l.add_account(code, name, account_type, None, false),
|
None => l.add_account(c.code, c.name, c.account_type, None, false),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// The payroll control accounts, in the order [`Store::run_payroll`]
|
|
||||||
/// creates them.
|
|
||||||
const PAYROLL_ACCOUNTS: [(&str, &str, AccountType); 7] = [
|
|
||||||
("6100", "Salary & Wage Expense", AccountType::Expense),
|
|
||||||
("6110", "Payroll Tax Expense", AccountType::Expense),
|
|
||||||
(
|
|
||||||
"2110",
|
|
||||||
"Federal Withholding Payable",
|
|
||||||
AccountType::Liability,
|
|
||||||
),
|
|
||||||
("2120", "State Withholding Payable", AccountType::Liability),
|
|
||||||
("2130", "SS Tax Payable", AccountType::Liability),
|
|
||||||
("2140", "Medicare Tax Payable", AccountType::Liability),
|
|
||||||
("2150", "Other Deductions Payable", AccountType::Liability),
|
|
||||||
];
|
|
||||||
|
|
||||||
pub(crate) fn replay_line_into_ledger(
|
pub(crate) fn replay_line_into_ledger(
|
||||||
ledger: &mut Ledger,
|
ledger: &mut Ledger,
|
||||||
line: &JournalLine,
|
line: &JournalLine,
|
||||||
|
|||||||
Reference in new issue
Block a user