diff --git a/crates/ledger-core/src/error.rs b/crates/ledger-core/src/error.rs index 99bb1dd..c200313 100644 --- a/crates/ledger-core/src/error.rs +++ b/crates/ledger-core/src/error.rs @@ -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), diff --git a/crates/ledger-core/src/ledger.rs b/crates/ledger-core/src/ledger.rs index bcd241f..ddaf2b2 100644 --- a/crates/ledger-core/src/ledger.rs +++ b/crates/ledger-core/src/ledger.rs @@ -25,6 +25,111 @@ use rust_decimal::Decimal; use serde::{Deserialize, Serialize}; 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. #[derive(Clone, Debug, Serialize, Deserialize)] pub struct UnclearedLine { @@ -1124,6 +1229,9 @@ impl Ledger { return Err(LedgerError::CloseOutOfOrder(id, later.id)); } 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 // account with a balance. Account creation (Retained Earnings) does // not consume entry ids. @@ -1257,6 +1365,56 @@ impl Ledger { .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 (Stephen, + /// 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 /// and name if missing. Returns the account id. 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())); } let total: Decimal = lines.iter().map(|l| l.amount).sum(); - let ar = self.ensure_account("1200", "Accounts Receivable", AccountType::Asset); - let revenue = self.ensure_account("4000", "Revenue", AccountType::Revenue); + self.check_controls(&[RECEIVABLES, 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 money = Money::new(total, self.base_currency); self.add_line(entry, ar, Side::Debit, money)?; @@ -1329,8 +1488,9 @@ impl Ledger { return Err(LedgerError::ClosedPeriod(pid, date.to_string())); } let total: Decimal = lines.iter().map(|l| l.amount).sum(); - let expense = self.ensure_account("6999", "General Expenses", AccountType::Expense); - let ap = self.ensure_account("2000", "Accounts Payable", AccountType::Liability); + self.check_controls(&[GENERAL_EXPENSES, PAYABLES])?; + 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 money = Money::new(total, self.base_currency); self.add_line(entry, expense, Side::Debit, money)?; @@ -1389,7 +1549,8 @@ impl Ledger { if let Some(pid) = self.closed_period_containing(date) { 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 money = Money::new(amount, self.base_currency); self.add_line(entry, cash_account, Side::Debit, money)?; @@ -1425,7 +1586,8 @@ impl Ledger { if let Some(pid) = self.closed_period_containing(date) { 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 money = Money::new(amount, self.base_currency); self.add_line(entry, ap, Side::Debit, money)?; @@ -1788,6 +1950,7 @@ impl Ledger { cash_account: AccountId, ) -> Result<(), LedgerError> { validate_payroll_lines(lines)?; + self.check_controls(&PAYROLL)?; self.chart .get(cash_account) .ok_or(LedgerError::UnknownAccount(cash_account))?; @@ -1801,38 +1964,15 @@ impl Ledger { /// missing. Journaling stores ensure these through their own journaled /// path first; this then no-ops and replay stays deterministic. fn payroll_accounts(&mut self) -> PayrollAccounts { + let [salary, tax, federal, state, ss, medicare, other] = PAYROLL; PayrollAccounts { - salary_expense: self.ensure_account( - "6100", - "Salary & Wage Expense", - AccountType::Expense, - ), - payroll_tax_expense: self.ensure_account( - "6110", - "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, - ), + salary_expense: self.ensure_control(&salary), + payroll_tax_expense: self.ensure_control(&tax), + federal_payable: self.ensure_control(&federal), + state_payable: self.ensure_control(&state), + ss_payable: self.ensure_control(&ss), + medicare_payable: self.ensure_control(&medicare), + other_payable: self.ensure_control(&other), } } @@ -1904,6 +2044,7 @@ impl Ledger { self.chart .get(cash_account) .ok_or(LedgerError::UnknownAccount(cash_account))?; + self.check_controls(&PAYROLL)?; let payables = self.payroll_tax_payables(); let total: Decimal = payables.iter().map(|(_, b)| *b).sum(); if total.is_zero() { diff --git a/crates/ledger-core/src/lib.rs b/crates/ledger-core/src/lib.rs index 9754fb1..ce5448a 100644 --- a/crates/ledger-core/src/lib.rs +++ b/crates/ledger-core/src/lib.rs @@ -49,7 +49,10 @@ pub use classes::{Class, ClassId}; pub use error::LedgerError; pub use form1099::{Form1099, Vendor1099Line, Vendor1099Report, vendor_1099_report}; 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 payouts::{Payout, PayoutAdjustment, PayoutLine, PayoutTotals, payout_plan}; pub use payroll::{PayrollLine, PayrollRun, PayrollRunId}; diff --git a/crates/ledger-core/tests/invariants.rs b/crates/ledger-core/tests/invariants.rs index 220985a..de7107c 100644 --- a/crates/ledger-core/tests/invariants.rs +++ b/crates/ledger-core/tests/invariants.rs @@ -774,3 +774,135 @@ fn cash_flow_with_registered_asset_and_disposal() { assert_eq!(cf.cash_from_financing, Decimal::new(10000000, 2)); // Sum: 0 - 400 + 100,000 = 99,600. Matches cash. } + +// --------------------------------------------------------------------------- +// Control accounts (Stephen, 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 { + 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()); +} diff --git a/crates/ledger-store/src/store.rs b/crates/ledger-store/src/store.rs index 55860a3..5ac4520 100644 --- a/crates/ledger-store/src/store.rs +++ b/crates/ledger-store/src/store.rs @@ -984,11 +984,7 @@ impl Store { // Retained Earnings must exist before the close so its creation // (if needed) is journaled as account.added; the close's own // ensure then no-ops and replay stays deterministic. - self.ensure_control_account( - ledger_core::RETAINED_EARNINGS_CODE, - "Retained Earnings", - AccountType::Equity, - )?; + self.ensure_control_account(&ledger_core::RETAINED_EARNINGS)?; } let closing_entry = self.ledger.close_period_to(id, to)?; let entry_detail = match closing_entry { @@ -1091,14 +1087,12 @@ impl Store { fn ensure_control_account( &mut self, - code: &str, - name: &str, - account_type: AccountType, + c: &ledger_core::ControlAccount, ) -> Result { - 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); } - 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 @@ -1197,8 +1191,8 @@ impl Store { lines: Vec, ) -> Result { self.rehearse(|l| { - ensure_on(l, "1200", "Accounts Receivable", AccountType::Asset)?; - ensure_on(l, "4000", "Revenue", AccountType::Revenue)?; + ensure_on(l, &ledger_core::RECEIVABLES)?; + ensure_on(l, &ledger_core::REVENUE)?; l.create_invoice(customer_id, date, due_date, lines.clone()) })?; // 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) { return Err(LedgerError::ClosedPeriod(pid, date.to_string()).into()); } - let ar = self.ensure_control_account("1200", "Accounts Receivable", AccountType::Asset)?; - let revenue = self.ensure_control_account("4000", "Revenue", AccountType::Revenue)?; + let ar = self.ensure_control_account(&ledger_core::RECEIVABLES)?; + let revenue = self.ensure_control_account(&ledger_core::REVENUE)?; let invoice_id = self.ledger.next_invoice_id(); let total: Decimal = lines.iter().map(|l| l.amount).sum(); let description = format!("Invoice {invoice_id}"); @@ -1261,8 +1255,8 @@ impl Store { lines: Vec, ) -> Result { self.rehearse(|l| { - ensure_on(l, "6999", "General Expenses", AccountType::Expense)?; - ensure_on(l, "2000", "Accounts Payable", AccountType::Liability)?; + ensure_on(l, &ledger_core::GENERAL_EXPENSES)?; + ensure_on(l, &ledger_core::PAYABLES)?; l.create_bill(vendor_id, date, due_date, lines.clone()) })?; 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) { return Err(LedgerError::ClosedPeriod(pid, date.to_string()).into()); } - let expense = - self.ensure_control_account("6999", "General Expenses", AccountType::Expense)?; - let ap = self.ensure_control_account("2000", "Accounts Payable", AccountType::Liability)?; + let expense = self.ensure_control_account(&ledger_core::GENERAL_EXPENSES)?; + let ap = self.ensure_control_account(&ledger_core::PAYABLES)?; let bill_id = self.ledger.next_bill_id(); let total: Decimal = lines.iter().map(|l| l.amount).sum(); let description = format!("Bill {bill_id}"); @@ -1317,7 +1310,7 @@ impl Store { cash_account: AccountId, ) -> Result { 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) })?; // Pure pre-validation mirroring Ledger::receive_payment, plus the @@ -1345,7 +1338,7 @@ impl Store { if cash.placeholder { 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 (entry_id, mut journaled) = self.journal_auto_entry(date, &description, cash_account, ar, amount)?; @@ -1379,7 +1372,7 @@ impl Store { cash_account: AccountId, ) -> Result { 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) })?; if amount <= Decimal::ZERO { @@ -1404,7 +1397,7 @@ impl Store { if cash.placeholder { 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 (entry_id, mut journaled) = 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 /// creation is an `account.added` event. fn ensure_payroll_accounts(&mut self) -> Result { + let [salary, tax, federal, state, ss, medicare, other] = ledger_core::PAYROLL; Ok(PayrollAccounts { - salary_expense: self.ensure_control_account( - "6100", - "Salary & Wage Expense", - AccountType::Expense, - )?, - payroll_tax_expense: self.ensure_control_account( - "6110", - "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, - )?, + salary_expense: self.ensure_control_account(&salary)?, + payroll_tax_expense: self.ensure_control_account(&tax)?, + federal_payable: self.ensure_control_account(&federal)?, + state_payable: self.ensure_control_account(&state)?, + ss_payable: self.ensure_control_account(&ss)?, + medicare_payable: self.ensure_control_account(&medicare)?, + other_payable: self.ensure_control_account(&other)?, }) } @@ -1563,8 +1529,8 @@ impl Store { cash_account: AccountId, ) -> Result { self.rehearse(|l| { - for (code, name, account_type) in PAYROLL_ACCOUNTS { - ensure_on(l, code, name, account_type)?; + for c in &ledger_core::PAYROLL { + ensure_on(l, c)?; } 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 /// is the actor. /// [`Store::ensure_control_account`] on a bare ledger, for rehearsals. -fn ensure_on( - l: &mut Ledger, - code: &str, - name: &str, - account_type: AccountType, -) -> Result { - match l.chart().find_by_code(code) { +fn ensure_on(l: &mut Ledger, c: &ledger_core::ControlAccount) -> Result { + match l.chart().find_by_code(c.code) { 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( ledger: &mut Ledger, line: &JournalLine,