//! The ledger: the command surface of the engine. //! //! A [`Ledger`] owns the chart of accounts and the append-only log of posted //! journal entries. Every mutation is a method on `Ledger` — create, add //! line, post, reverse — and every method validates before it applies. There //! is no other way to change the books, which is what makes the ledger an //! audit trail and what will make the future oracle harness trustworthy. use crate::schedules::{ self, AmortizationSchedule, NewRecurring, NewSchedule, RecurringEntry, RecurringId, ScheduleId, }; use crate::{ Account, AccountId, AccountType, AgingRow, Asset, AssetId, AssetStatus, Bill, BillId, ChartOfAccounts, Class, ClassId, Currency, Customer, CustomerId, DocumentLine, DocumentStatus, EntryId, EntryStatus, Invoice, InvoiceId, JournalEntry, JournalLine, LedgerError, Money, NewAsset, Period, PeriodId, PeriodStatus, Side, Vendor, VendorId, arap, assets, kernel, payroll::{ PayrollAccounts, PayrollLine, PayrollRun, PayrollRunId, payroll_entry_plan, payroll_remit_plan, validate_payroll_lines, }, periods, }; use chrono::{Months, NaiveDate}; 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; a command refuses one of the wrong type /// (`LedgerError::ControlAccountMismatch`). #[derive(Clone, Copy, Debug)] pub struct ControlAccount { /// 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, /// 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( key: &'static str, code: &'static str, name: &'static str, role: &'static str, account_type: AccountType, ) -> ControlAccount { ControlAccount { key, code, name, role, account_type, } } /// 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( "revenue", "4000", "Revenue", "invoice revenue", AccountType::Revenue, ); /// A control account; see [`ControlAccount`]. pub const PAYABLES: ControlAccount = control( "payables", "2000", "Accounts Payable", "accounts payable", AccountType::Liability, ); /// A control account; see [`ControlAccount`]. pub const GENERAL_EXPENSES: ControlAccount = control( "general_expense", "6999", "General Expenses", "bill expense", AccountType::Expense, ); /// A control account; see [`ControlAccount`]. pub const RETAINED_EARNINGS: ControlAccount = control( "retained_earnings", periods::RETAINED_EARNINGS_CODE, "Retained Earnings", "retained earnings", AccountType::Equity, ); /// Every control account the engine posts to by code. pub fn control_accounts() -> impl Iterator { [ &RECEIVABLES, &REVENUE, &PAYABLES, &GENERAL_EXPENSES, &RETAINED_EARNINGS, ] .into_iter() .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", AccountType::Liability, ), ]; /// A posted line that has not cleared, for the reconciliation report. #[derive(Clone, Debug, Serialize, Deserialize)] pub struct UnclearedLine { /// Entry id. pub entry_id: EntryId, /// Index within the entry. pub line_index: usize, /// Entry date. pub date: NaiveDate, /// Entry description. pub description: String, /// Line side. pub side: Side, /// Line amount (base currency). pub amount: Decimal, } /// The bank reconciliation report. #[derive(Clone, Debug, Serialize, Deserialize)] pub struct ReconciliationReport { /// Account id reconciled. pub account: AccountId, /// Sum of all posted lines on the account (book balance). pub book_balance: Decimal, /// Sum of cleared lines dated on or before the statement date. pub cleared_balance: Decimal, /// The bank statement's balance. pub statement_balance: Decimal, /// cleared_balance - statement_balance. Zero means reconciled. pub difference: Decimal, /// Posted, uncleared lines dated on or before the statement date. pub uncleared_lines: Vec, } /// The books. Single-currency (see R-5); owns id allocation. #[derive(Clone, Debug, PartialEq)] pub struct Ledger { base_currency: Currency, chart: ChartOfAccounts, entries: HashMap, /// Entry ids in posting order: the append-only log. order: Vec, /// Fixed-asset register: master data, journaled like accounts. assets: HashMap, /// Accounting periods. periods: HashMap, /// Customers for A/R. customers: HashMap, /// Vendors for A/P. vendors: HashMap, /// Sales invoices. invoices: HashMap, /// Vendor bills. bills: HashMap, /// Cleared flags for bank reconciliation: (entry_id, line_index) -> cleared. /// Posted entries are immutable; this side table holds the workflow flag. cleared: HashMap<(EntryId, usize), bool>, /// Historical FX rates for reference: (currency, date) -> rate (foreign -> base). fx_rates: HashMap<(Currency, NaiveDate), Decimal>, /// Payroll runs. payroll_runs: HashMap, /// Classes for divisional P&L tracking. classes: HashMap, next_account_id: AccountId, next_entry_id: EntryId, next_asset_id: AssetId, next_period_id: PeriodId, next_customer_id: CustomerId, next_vendor_id: VendorId, next_invoice_id: InvoiceId, next_bill_id: BillId, next_payroll_run_id: PayrollRunId, next_class_id: ClassId, /// Amortization schedules (prepaid expenses, deferred revenue). schedules: std::collections::BTreeMap, next_schedule_id: ScheduleId, /// Recurring entries. recurring: std::collections::BTreeMap, next_recurring_id: RecurringId, /// Who each entry paid or was billed by, for 1099 reporting. entry_vendors: HashMap, /// Each invoice's payments (entry, amount), for aging as of a date. invoice_payments: HashMap>, /// Each bill's payments (entry, amount), for aging as of a date. bill_payments: HashMap>, /// 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>, /// Every asset tag ever held ([`assets::tag_key`] → the asset that /// held it): a tag never moves to another asset. asset_tags: std::collections::BTreeMap, } impl Ledger { /// Open a new, empty ledger denominated in `base_currency`. pub fn new(base_currency: Currency) -> Self { Self { base_currency, chart: ChartOfAccounts::default(), entries: HashMap::new(), order: Vec::new(), assets: HashMap::new(), periods: HashMap::new(), customers: HashMap::new(), vendors: HashMap::new(), invoices: HashMap::new(), bills: HashMap::new(), cleared: HashMap::new(), fx_rates: HashMap::new(), payroll_runs: HashMap::new(), classes: HashMap::new(), next_account_id: 1, next_entry_id: 1, next_asset_id: 1, next_period_id: 1, next_customer_id: 1, next_vendor_id: 1, next_invoice_id: 1, next_bill_id: 1, next_payroll_run_id: 1, next_class_id: 1, schedules: std::collections::BTreeMap::new(), next_schedule_id: 1, recurring: std::collections::BTreeMap::new(), next_recurring_id: 1, entry_vendors: HashMap::new(), invoice_payments: HashMap::new(), bill_payments: HashMap::new(), roles: std::collections::BTreeMap::new(), asset_tags: std::collections::BTreeMap::new(), } } // ------------------------------------------------------------------ // Amortization schedules and recurring entries // ------------------------------------------------------------------ /// Register an amortization schedule. Returns its id. pub fn register_schedule(&mut self, new: NewSchedule) -> Result { schedules::validate_schedule(&new, &self.chart)?; self.replay_register_schedule(new) } /// [`Self::register_schedule`] without the total's range and minor-unit /// checks added on 2026-10-13, for replaying journals written before /// them. Never for new work. pub fn replay_register_schedule( &mut self, new: NewSchedule, ) -> Result { schedules::validate_schedule_shape(&new, &self.chart)?; let id = self.next_schedule_id; self.next_schedule_id += 1; self.schedules.insert( id, AmortizationSchedule { id, description: new.description, kind: new.kind, total: new.total, start: new.start, months: new.months, balance_account: new.balance_account, pl_account: new.pl_account, posted: Default::default(), }, ); Ok(id) } /// The id the next schedule will receive (for journaling stores). pub fn next_schedule_id(&self) -> ScheduleId { self.next_schedule_id } /// All schedules, by id. pub fn schedules(&self) -> impl Iterator + '_ { self.schedules.values() } /// Look up a schedule. pub fn get_schedule(&self, id: ScheduleId) -> Option<&AmortizationSchedule> { self.schedules.get(&id) } /// The entry [`Self::post_schedule_month`] would post: (date, /// description, lines). Errors if the month isn't due and unposted. pub fn preview_schedule_month( &self, id: ScheduleId, month_end: NaiveDate, ) -> Result<(NaiveDate, String, Vec), LedgerError> { let s = self .schedules .get(&id) .ok_or_else(|| LedgerError::InvalidSchedule(format!("unknown schedule {id}")))?; let (date, amount) = schedules::amortization_plan(s.total.amount, s.start, s.months) .into_iter() .find(|(d, _)| *d == month_end) .ok_or_else(|| { LedgerError::InvalidSchedule(format!("{month_end} is not a month of schedule {id}")) })?; if s.posted.contains(&date) { return Err(LedgerError::InvalidSchedule(format!( "schedule {id} already posted {date}" ))); } let money = Money::new(amount, s.total.currency); let (debit, credit) = s.accounts(); let what = match s.kind { schedules::ScheduleKind::PrepaidExpense => "Prepaid expense", schedules::ScheduleKind::DeferredRevenue => "Deferred revenue", }; Ok(( date, format!("{what}: {} ({})", s.description, date.format("%Y-%m")), vec![(debit, Side::Debit, money), (credit, Side::Credit, money)], )) } /// Post one month of a schedule: the entry from /// [`Self::preview_schedule_month`], posted and recorded so the month /// never posts twice. Returns the entry id. pub fn post_schedule_month( &mut self, id: ScheduleId, month_end: NaiveDate, ) -> Result { self.atomically(|l| l.post_schedule_month_steps(id, month_end)) } /// The steps of [`Self::post_schedule_month`], run inside [`Self::atomically`]. fn post_schedule_month_steps( &mut self, id: ScheduleId, month_end: NaiveDate, ) -> Result { let (date, description, lines) = self.preview_schedule_month(id, month_end)?; if let Some(p) = self.closed_period_containing(date) { return Err(LedgerError::ClosedPeriod(p, date.to_string())); } let entry = self.create_entry(date, &description); for (account, side, money) in lines { self.add_line(entry, account, side, money)?; } self.post_entry(entry)?; self.schedules .get_mut(&id) .expect("previewed") .posted .insert(date); Ok(entry) } /// Register a recurring entry. Returns its id. pub fn register_recurring(&mut self, new: NewRecurring) -> Result { schedules::validate_recurring(&new, &self.chart)?; let id = self.next_recurring_id; self.next_recurring_id += 1; self.recurring.insert( id, RecurringEntry { id, description: new.description, lines: new.lines, frequency: new.frequency, start: new.start, end: new.end, generated: Default::default(), }, ); Ok(id) } /// The id the next recurring entry will receive (for journaling stores). pub fn next_recurring_id(&self) -> RecurringId { self.next_recurring_id } /// All recurring entries, by id. pub fn recurring_entries(&self) -> impl Iterator + '_ { self.recurring.values() } /// Generate one occurrence of a recurring entry as a *draft* (never /// posted: the agent attaches the receipt and posts it). `date` must be /// an occurrence not yet generated. Returns the draft's id. /// /// ```text /// GOAL: turn one due occurrence of a recurring entry into a draft. /// SOURCE: docs/spec/rules.md B-12; Lean invariant Inv.draftsOpen. /// SCENARIOS: /// 1. IF the date falls in a closed period: /// - SHOULD refuse (ClosedPeriod) /// - NEVER create a draft there /// BECAUSE a closed period takes no new entries, however they /// are made (found by the store's refusal property, 2026-10-13). /// 2. IF the date is not an ungenerated occurrence: SHOULD refuse. /// 3. IF a line is refused: SHOULD leave the ledger as it was (no /// half-built draft, no id spent). /// 4. Otherwise: SHOULD create a draft with the template's lines and /// mark the occurrence generated. /// ``` pub fn generate_recurring( &mut self, id: RecurringId, date: NaiveDate, ) -> Result { if let Some(pid) = self.closed_period_containing(date) { return Err(LedgerError::ClosedPeriod(pid, date.to_string())); } self.replay_generate_recurring(id, date) } /// [`Self::generate_recurring`] without the closed-period check, for /// replaying journals written before that check existed. pub fn replay_generate_recurring( &mut self, id: RecurringId, date: NaiveDate, ) -> Result { let r = self .recurring .get(&id) .ok_or_else(|| LedgerError::InvalidSchedule(format!("unknown recurring entry {id}")))?; if !r.due(date).contains(&date) { return Err(LedgerError::InvalidSchedule(format!( "{date} is not an ungenerated occurrence of recurring entry {id}" ))); } let (description, lines) = (r.description.clone(), r.lines.clone()); let entry = self.create_entry(date, &description); for (account, side, money) in lines { if let Err(e) = self.add_line(entry, account, side, money) { self.entries.remove(&entry); self.next_entry_id = entry; return Err(e); } } self.recurring .get_mut(&id) .expect("checked") .generated .insert(date); Ok(entry) } /// Occurrences due through `through` that can't be generated because /// their date falls in a closed period, as (recurring entry, date). /// They need a human decision (book them in an open period by hand, /// or not at all). pub fn recurring_in_closed_periods(&self, through: NaiveDate) -> Vec<(RecurringId, NaiveDate)> { self.recurring .values() .flat_map(|r| r.due(through).into_iter().map(move |d| (r.id, d))) .filter(|(_, d)| self.closed_period_containing(*d).is_some()) .collect() } /// The parts of the ledger that differ from `other`, by field name /// (empty when equal). For diagnosing a store whose journal replay and /// projection disagree. pub fn differs_from(&self, other: &Ledger) -> Vec<&'static str> { [ ("base_currency", self.base_currency == other.base_currency), ("chart", self.chart == other.chart), ("entries", self.entries == other.entries), ("order", self.order == other.order), ("assets", self.assets == other.assets), ("periods", self.periods == other.periods), ("customers", self.customers == other.customers), ("vendors", self.vendors == other.vendors), ("invoices", self.invoices == other.invoices), ("bills", self.bills == other.bills), ("cleared", self.cleared == other.cleared), ("fx_rates", self.fx_rates == other.fx_rates), ("payroll_runs", self.payroll_runs == other.payroll_runs), ("classes", self.classes == other.classes), ( "next_account_id", self.next_account_id == other.next_account_id, ), ("next_entry_id", self.next_entry_id == other.next_entry_id), ("next_asset_id", self.next_asset_id == other.next_asset_id), ( "next_period_id", self.next_period_id == other.next_period_id, ), ( "next_customer_id", self.next_customer_id == other.next_customer_id, ), ( "next_vendor_id", self.next_vendor_id == other.next_vendor_id, ), ( "next_invoice_id", self.next_invoice_id == other.next_invoice_id, ), ("next_bill_id", self.next_bill_id == other.next_bill_id), ( "next_payroll_run_id", self.next_payroll_run_id == other.next_payroll_run_id, ), ("next_class_id", self.next_class_id == other.next_class_id), ("schedules", self.schedules == other.schedules), ( "next_schedule_id", self.next_schedule_id == other.next_schedule_id, ), ("recurring", self.recurring == other.recurring), ( "next_recurring_id", self.next_recurring_id == other.next_recurring_id, ), ("entry_vendors", self.entry_vendors == other.entry_vendors), ( "invoice_payments", self.invoice_payments == other.invoice_payments, ), ("bill_payments", self.bill_payments == other.bill_payments), ("roles", self.roles == other.roles), ("asset_tags", self.asset_tags == other.asset_tags), ] .into_iter() .filter(|(_, same)| !same) .map(|(name, _)| name) .collect() } /// Run a command made of several steps as one: if any step is /// refused, the ledger is exactly as it was (no half-built draft, no /// id spent). Found 2026-10-13: a schedule month whose amount was out /// of range left a draft behind. fn atomically( &mut self, steps: impl FnOnce(&mut Self) -> Result, ) -> Result { let before = self.clone(); steps(self).inspect_err(|_| *self = before) } /// The id the next account added will receive. /// /// Exposed for journaling stores that record the id before mutating. /// The value is only stable if no other mutation intervenes. pub fn next_account_id(&self) -> AccountId { self.next_account_id } /// The id the next journal entry created will receive. /// /// See [`Self::next_account_id`]. pub fn next_entry_id(&self) -> EntryId { self.next_entry_id } /// The id the next fixed asset registered will receive. /// /// See [`Self::next_account_id`]. pub fn next_asset_id(&self) -> AssetId { self.next_asset_id } /// The id the next accounting period created will receive. /// /// See [`Self::next_account_id`]. pub fn next_period_id(&self) -> PeriodId { self.next_period_id } /// The id the next customer added will receive. /// /// See [`Self::next_account_id`]. pub fn next_customer_id(&self) -> CustomerId { self.next_customer_id } /// The id the next vendor added will receive. /// /// See [`Self::next_account_id`]. pub fn next_vendor_id(&self) -> VendorId { self.next_vendor_id } /// The id the next invoice created will receive. /// /// See [`Self::next_account_id`]. pub fn next_invoice_id(&self) -> InvoiceId { self.next_invoice_id } /// The id the next bill created will receive. /// /// See [`Self::next_account_id`]. pub fn next_bill_id(&self) -> BillId { self.next_bill_id } /// The id the next payroll run will receive. /// /// See [`Self::next_account_id`]. pub fn next_payroll_run_id(&self) -> PayrollRunId { self.next_payroll_run_id } /// The id the next class added will receive. /// /// See [`Self::next_account_id`]. pub fn next_class_id(&self) -> ClassId { self.next_class_id } /// The currency every account and line in this ledger uses. pub fn base_currency(&self) -> Currency { self.base_currency } /// Read-only view of the chart of accounts. pub fn chart(&self) -> &ChartOfAccounts { &self.chart } /// Add an account to the chart. Returns its id. /// /// ```text /// GOAL: register an account under a unique code. /// SOURCE: rules.md B-4 (the five elements); R-4; DECISION (Stephen, /// 2026-10-13): a child has its parent's type. /// SCENARIOS: /// 1. IF the code is empty or taken: SHOULD refuse. /// 2. IF the parent doesn't exist: SHOULD refuse. /// 3. IF the parent is another type: /// - SHOULD refuse with ParentTypeMismatch /// - NEVER add the account /// BECAUSE a child subdivides its parent. /// ``` /// /// Replay of journals written before the parent rule uses /// [`Self::replay_add_account`]. pub fn add_account( &mut self, code: &str, name: &str, account_type: AccountType, parent: Option, placeholder: bool, ) -> Result { // A chart lists each account by number and title; neither may be // blank (2026-10-13; the core refused only an empty code, so a code // of spaces or an unnamed account got in). if code.trim().is_empty() { return Err(LedgerError::EmptyAccountCode); } if name.trim().is_empty() { return Err(LedgerError::EmptyAccountName); } if let Some(p) = parent.and_then(|p| self.chart.get(p)) && p.account_type != account_type { return Err(LedgerError::ParentTypeMismatch { code: code.to_string(), parent_code: p.code.clone(), expected: p.account_type, found: account_type, }); } self.replay_add_account(code, name, account_type, parent, placeholder) } /// [`Self::add_account`] without the rules added on 2026-10-13 (a /// child's type; a blank code or name), for replaying journals written before them. Never /// for new work. pub fn replay_add_account( &mut self, code: &str, name: &str, account_type: AccountType, parent: Option, placeholder: bool, ) -> Result { // The id is used only if the account is: a refusal uses up nothing. let id = self.next_account_id; self.chart.add(Account { id, code: code.to_string(), name: name.to_string(), account_type, parent, placeholder, currency: self.base_currency, tax_category: None, deductible_percent: None, })?; self.next_account_id += 1; 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 && !self.roles.contains_key(c.key)) && (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(()) } /// 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 { 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 { 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, ) -> 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, ) -> 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`]). pub fn set_account_tax( &mut self, account: AccountId, category: Option<&str>, deductible_percent: Option, ) -> Result<(), LedgerError> { self.check_account_tax(account, category, deductible_percent)?; self.chart .set_tax(account, category.map(str::to_string), deductible_percent) } /// The validation behind [`Self::set_account_tax`], for stores that /// journal before mutating. pub fn check_account_tax( &self, account: AccountId, category: Option<&str>, deductible_percent: Option, ) -> Result<(), LedgerError> { let a = self .chart .get(account) .ok_or(LedgerError::UnknownAccount(account))?; if let Some(key) = category { crate::tax::check_category(key, a.account_type, None)?; } else if deductible_percent.is_some() { return Err(LedgerError::InvalidTaxCategory( "a deductible percent needs a tax category".to_string(), )); } if let Some(p) = deductible_percent && (p < Decimal::ZERO || p > Decimal::from(100)) { return Err(LedgerError::InvalidTaxCategory(format!( "deductible percent {p} must be between 0 and 100" ))); } Ok(()) } /// Start a draft journal entry. Returns its id. /// /// This is the unchecked constructor: replay and internal callers /// (closing entries) use it. For the checked version that rejects /// dates in closed periods, see [`Self::try_create_entry`]. pub fn create_entry(&mut self, date: NaiveDate, description: &str) -> EntryId { let id = self.next_entry_id; self.next_entry_id += 1; self.entries .insert(id, JournalEntry::draft(id, date, description.to_string())); id } /// Start a draft journal entry, rejecting dates that fall in a closed /// period. This is the entry point for new work; [`Self::create_entry`] /// is for replay and internal use. /// /// ```text /// GOAL: start a draft entry for new work. /// SOURCE: docs/spec/rules.md B-12; Lean invariant Inv.draftsOpen. /// SCENARIOS: /// 1. IF the date falls in a closed period: /// - SHOULD refuse (ClosedPeriod) /// - NEVER create a draft there /// BECAUSE a closed period takes no new entries. /// 2. Otherwise: SHOULD return a fresh id, one past the last. /// ``` pub fn try_create_entry( &mut self, date: NaiveDate, description: &str, ) -> Result { if let Some(pid) = self.closed_period_containing(date) { return Err(LedgerError::ClosedPeriod(pid, date.to_string())); } Ok(self.create_entry(date, description)) } /// The id of a closed period containing `date`, if any. /// /// Pure query. Journaling stores use it to pre-validate dates before /// journaling, so a journal write is never followed by a failed mutation. pub fn closed_period_containing(&self, date: NaiveDate) -> Option { self.periods .values() .find(|p| p.status == PeriodStatus::Closed && p.contains(date)) .map(|p| p.id) } fn draft_mut(&mut self, entry: EntryId) -> Result<&mut JournalEntry, LedgerError> { let e = self .entries .get_mut(&entry) .ok_or(LedgerError::UnknownEntry(entry))?; if e.status() != EntryStatus::Draft { return Err(LedgerError::PostedEntryImmutable); } Ok(e) } /// Add a line to a draft entry. The amount must be in the ledger's base /// currency; drafts reject anything else rather than converting. pub fn add_line( &mut self, entry: EntryId, account: AccountId, side: Side, amount: Money, ) -> Result<(), LedgerError> { self.check_line_account(account)?; check_minor_units(amount)?; self.replay_add_line(entry, account, side, amount) } /// A line's account must exist and take postings, checked when the /// line is added (Stephen, 2026-10-13); posting checks again. fn check_line_account(&self, account: AccountId) -> Result<(), LedgerError> { let a = self .chart .get(account) .ok_or(LedgerError::UnknownAccount(account))?; if a.placeholder { return Err(LedgerError::PlaceholderAccount(a.code.clone())); } Ok(()) } /// [`Self::add_line`] without the account check added on 2026-10-13, /// for replaying journals written before it. Never for new work. pub fn replay_add_line( &mut self, entry: EntryId, account: AccountId, side: Side, amount: Money, ) -> Result<(), LedgerError> { if amount.currency != self.base_currency { return Err(LedgerError::CurrencyMismatch { expected: self.base_currency.code().to_string(), found: amount.currency.code().to_string(), }); } let e = self.draft_mut(entry)?; e.push_line(JournalLine { account, side, amount, foreign_currency: None, foreign_amount: None, class_id: None, }); Ok(()) } /// Add a foreign-currency line to a draft entry. `foreign_amount` is the /// amount in `foreign_currency`; `fx_rate` converts foreign -> base. /// The stored `amount` is foreign * rate rounded to the base currency's /// minor unit (cents for USD), half away from zero; the foreign /// original is kept for reference. Rates are exact Decimals, never /// floats. /// /// ```text /// GOAL: book a foreign-currency amount in the ledger's one money. /// SOURCE: docs/spec/rules.md B-3 (Pacioli ch. 36: one money; ASC /// 830-20-30-1: at the transaction-date rate); minor units: ISO /// 4217; rounding: DECISION (Stephen, 2026-10-13). /// INVARIANTS: /// 1. The base amount MUST be a whole number of the base currency's /// smallest unit, within half a unit of foreign * rate. /// 2. NEVER convert into the base currency itself. /// SCENARIOS: /// 1. IF 100.00 EUR at 1.0834567 into USD: SHOULD book 108.35. /// 2. IF 10.00 USD at 151.237 into JPY: SHOULD book 1512. /// 3. IF the rounded amount is zero: SHOULD refuse. /// ``` pub fn add_line_fx( &mut self, entry: EntryId, account: AccountId, side: Side, foreign_currency: Currency, foreign_amount: Decimal, fx_rate: Decimal, ) -> Result<(), LedgerError> { self.check_line_account(account)?; if foreign_amount <= Decimal::ZERO { return Err(LedgerError::InvalidDocumentLineAmount(foreign_amount)); } if fx_rate <= Decimal::ZERO { return Err(LedgerError::InvalidFxRate(fx_rate)); } if foreign_currency == self.base_currency { return Err(LedgerError::CurrencyMismatch { expected: "foreign currency".to_string(), found: foreign_currency.code().to_string(), }); } let exact = foreign_amount .checked_mul(fx_rate) .ok_or(LedgerError::AmountOutOfRange)?; // Booked in the base currency's smallest unit (cents for USD), half // away from zero: a ledger in one money holds no fractions of its // smallest unit (rules.md B-3; DECISION, Stephen 2026-10-13, after // the GnuCash cross-check found the difference). let base_amount = match self.base_currency.minor_units() { Some(dp) => exact .round_dp_with_strategy(dp, rust_decimal::RoundingStrategy::MidpointAwayFromZero), None => exact, }; if base_amount <= Decimal::ZERO { return Err(LedgerError::InvalidFxRate(fx_rate)); } self.replay_add_line_fx( entry, account, side, Money::new(base_amount, self.base_currency), foreign_currency, foreign_amount, ) } /// A foreign-currency line exactly as a journal recorded it: the base /// amount as booked (rounded or not, by the rule of its day) and the /// foreign original. For replay only; new work uses /// [`Self::add_line_fx`]. pub fn replay_add_line_fx( &mut self, entry: EntryId, account: AccountId, side: Side, amount: Money, foreign_currency: Currency, foreign_amount: Decimal, ) -> Result<(), LedgerError> { if amount.currency != self.base_currency { return Err(LedgerError::CurrencyMismatch { expected: self.base_currency.code().to_string(), found: amount.currency.code().to_string(), }); } let e = self.draft_mut(entry)?; e.push_line(JournalLine { account, side, amount, foreign_currency: Some(foreign_currency), foreign_amount: Some(foreign_amount), class_id: None, }); Ok(()) } /// Validate and post a draft entry. One-way: a posted entry is immutable. /// /// Rejects entries dated in a closed period. /// /// ```text /// GOAL: make a draft part of the books, once and for good. /// SOURCE: docs/spec/rules.md B-1, B-2, B-3, B-8 (Pacioli ch. 36: write /// only forward), B-12. /// INVARIANTS: /// 1. MUST post only an entry that passes JournalEntry::validate. /// 2. MUST append it to the log; NEVER reorder, change, or remove an /// earlier posted entry. /// 3. A refusal MUST leave the books unchanged. /// SCENARIOS: /// 1. IF the entry is unknown: SHOULD refuse (UnknownEntry). /// 2. IF it is already posted: /// - SHOULD refuse (EntryAlreadyPosted) /// - NEVER post it twice /// 3. IF it is dated in a closed period: SHOULD refuse (ClosedPeriod). /// 4. IF it doesn't balance, has fewer than two lines, a zero, negative, /// or out-of-range amount, or an unknown or placeholder account: /// SHOULD refuse with that reason. /// 5. IF a line is finer than its currency's minor unit (a fraction /// of a cent): SHOULD refuse (FinerThanMinorUnit) /// BECAUSE a ledger in one money holds no fractions of its /// smallest unit (B-3); found when a payroll run posted $65.27794 /// (the store's refusal property in CI, 2026-10-13). /// ``` pub fn post_entry(&mut self, entry: EntryId) -> Result<(), LedgerError> { if let Some(e) = self.entries.get(&entry) { for line in e.lines() { check_minor_units(line.amount)?; } } self.replay_post_entry(entry) } /// [`Self::post_entry`] without the minor-unit check added on /// 2026-10-13, for replaying journals written before it. Never for new /// work. pub fn replay_post_entry(&mut self, entry: EntryId) -> Result<(), LedgerError> { let snapshot = self .entries .get(&entry) .ok_or(LedgerError::UnknownEntry(entry))? .clone(); if snapshot.status() == EntryStatus::Posted { return Err(LedgerError::EntryAlreadyPosted(entry)); } // Closed periods reject new postings. if let Some(pid) = self.closed_period_containing(snapshot.date()) { return Err(LedgerError::ClosedPeriod(pid, snapshot.date().to_string())); } // Clone so validation borrows end before the mutable mark. snapshot.validate(&self.chart)?; self.entries .get_mut(&entry) .ok_or(LedgerError::UnknownEntry(entry))? .mark_posted(); self.order.push(entry); Ok(()) } /// Look up an entry by id (draft or posted). pub fn get_entry(&self, entry: EntryId) -> Option<&JournalEntry> { self.entries.get(&entry) } /// Pure pre-check for [`Self::reverse_entry`]: every way a reversal can /// be refused, checked without touching the books, so journaling stores /// can validate before they journal. /// /// ```text /// GOAL: decide whether `entry` may be reversed on `date`. /// SOURCE: docs/spec/rules.md B-9 (Pacioli ch. 31, 36), B-10 (DECISION, /// Stephen 2026-10-13), B-12 (closed periods). /// INVARIANTS: /// 1. MUST NOT mutate anything. /// 2. NEVER accepts a reversal that `reverse_entry` would then refuse. /// SCENARIOS: /// 1. IF the entry is unknown or still a draft: /// - SHOULD refuse (`UnknownEntry`, `EntryNotPosted`) /// BECAUSE only posted entries are in the books to correct. /// 2. IF the entry was already reversed: /// - SHOULD refuse with `EntryAlreadyReversed(entry, by)` /// - NEVER undo it a second time /// BECAUSE a second reversal would undo the original twice /// (Stephen, 2026-10-13). Reversing the reversal re-instates it. /// 3. IF the entry closes a period: /// - SHOULD refuse with `ClosingEntryIrreversible` /// BECAUSE the period would stay closed with its earnings /// unswept (Stephen, 2026-10-13). /// 4. IF `date` falls in a closed period: /// - SHOULD refuse with `ClosedPeriod` /// BECAUSE closed periods take no new postings. /// ``` pub fn check_reversal(&self, entry: EntryId, date: NaiveDate) -> Result<(), LedgerError> { let original = self .entries .get(&entry) .ok_or(LedgerError::UnknownEntry(entry))?; if original.status() != EntryStatus::Posted { return Err(LedgerError::EntryNotPosted(entry)); } if let Some(by) = self .posted_entries() .find(|e| e.reversal_of() == Some(entry)) { return Err(LedgerError::EntryAlreadyReversed(entry, by.id())); } if self.closing_entry_ids().contains(&entry) { return Err(LedgerError::ClosingEntryIrreversible(entry)); } if let Some(pid) = self.closed_period_containing(date) { return Err(LedgerError::ClosedPeriod(pid, date.to_string())); } Ok(()) } /// Post a reversing entry: same lines with sides flipped, linked back to /// the original via `reversal_of`. The books never forget; they correct. /// Refused (see [`Self::check_reversal`]) before anything changes. pub fn reverse_entry( &mut self, entry: EntryId, date: NaiveDate, description: &str, ) -> Result { self.atomically(|l| l.reverse_entry_steps(entry, date, description)) } /// The steps of [`Self::reverse_entry`], run inside [`Self::atomically`]. fn reverse_entry_steps( &mut self, entry: EntryId, date: NaiveDate, description: &str, ) -> Result { self.check_reversal(entry, date)?; let original = self .entries .get(&entry) .ok_or(LedgerError::UnknownEntry(entry))? .clone(); let id = self.create_entry(date, description); for (idx, line) in original.lines().iter().enumerate() { self.add_line(id, line.account, kernel::flip(line.side), line.amount)?; // The reversal offsets the original line for line: carry the // class so divisional P&L nets correctly. if line.class_id.is_some() { self.set_line_class(id, idx, line.class_id)?; } } self.entries .get_mut(&id) .ok_or(LedgerError::UnknownEntry(id))? .mark_reversal_of(entry); self.post_entry(id)?; // A reversed payment un-pays the vendor: carry the tag. if let Some(v) = self.entry_vendor(entry) { self.entry_vendors.insert(id, v); } Ok(id) } /// Posted entries in posting order: the append-only log. pub fn posted_entries(&self) -> impl Iterator + '_ { self.order.iter().filter_map(|id| self.entries.get(id)) } // ------------------------------------------------------------------ // Fixed assets // ------------------------------------------------------------------ /// Pure pre-validation for [`Self::register_asset`]: every check that /// does not mutate, so journaling stores can validate before journaling. pub fn validate_new_asset(&self, new: &NewAsset) -> Result<(), LedgerError> { if new.description.trim().is_empty() { return Err(LedgerError::EmptyAssetDescription); } if new.cost.amount <= Decimal::ZERO { return Err(LedgerError::InvalidAssetCost(new.cost.amount)); } // Every depreciation and disposal line derives from the cost: one // that no line could carry is refused here, not months later. if new.cost.amount > kernel::MAX_LINE_AMOUNT { return Err(LedgerError::AmountOutOfRange); } if new.cost.currency != self.base_currency { return Err(LedgerError::CurrencyMismatch { expected: self.base_currency.code().to_string(), found: new.cost.currency.code().to_string(), }); } if new.salvage_value.amount < Decimal::ZERO || new.salvage_value.amount >= new.cost.amount { return Err(LedgerError::InvalidSalvageValue(new.salvage_value.amount)); } if new.salvage_value.currency != self.base_currency { return Err(LedgerError::CurrencyMismatch { expected: self.base_currency.code().to_string(), found: new.salvage_value.currency.code().to_string(), }); } if new.useful_life_months < 1 { return Err(LedgerError::InvalidUsefulLife(new.useful_life_months)); } // The schedule walks every month of the asset's life; a purchase // date near the end of the calendar would overflow date arithmetic // (and hang or panic the schedule). Reject it up front. if new .purchase_date .checked_add_months(Months::new(new.useful_life_months)) .is_none() { return Err(LedgerError::AssetDateOverflow); } assets::validate_asset_accounts( &self.chart, new.cost_account, new.accum_depr_account, new.expense_account, ) } /// Register a fixed asset. Validates the economics (positive cost, /// salvage below cost, at least one month of life, base currency) and /// the account links (asset/asset/expense types, three distinct /// accounts). /// /// ```text /// GOAL: put a fixed asset on the register, to depreciate monthly. /// SOURCE: docs/spec/rules.md B-15; B-22 (capitalize vs expense). /// SCENARIOS: /// 1. IF the description is blank, cost isn't positive, salvage is /// negative or not below cost, the useful life is zero months, or the /// accounts aren't the right types: SHOULD refuse. /// ``` pub fn register_asset(&mut self, new: NewAsset) -> Result { self.validate_new_asset(&new)?; let id = self.next_asset_id; self.next_asset_id += 1; self.assets.insert( id, Asset { id, description: new.description, purchase_date: new.purchase_date, cost: new.cost, salvage_value: new.salvage_value, useful_life_months: new.useful_life_months, method: crate::DepreciationMethod::StraightLine, cost_account: new.cost_account, accum_depr_account: new.accum_depr_account, expense_account: new.expense_account, status: AssetStatus::Active, tag: None, serial: None, acquired_by: None, }, ); Ok(id) } /// Look up a fixed asset. pub fn get_asset(&self, id: AssetId) -> Option<&Asset> { self.assets.get(&id) } /// All registered assets, in registration order. pub fn registered_assets(&self) -> impl Iterator + '_ { let mut ids: Vec = self.assets.keys().copied().collect(); ids.sort_unstable(); ids.into_iter().filter_map(|id| self.assets.get(&id)) } /// Give an asset a tag (or a new one): the label on the thing itself. /// /// ```text /// GOAL: find an asset in the books from the label on it, and the /// label from the books. /// SOURCE: DECISION (Stephen, 2026-10-15). /// INVARIANTS: /// 1. Tags are unique among all assets, ignoring case. /// 2. A tag is never reused by another asset, even after the asset /// that held it was retagged or disposed: an old label never /// points at the wrong thing. The same asset may take back a tag /// it held. /// 3. A refusal MUST leave the books unchanged. /// SCENARIOS: /// 1. IF the asset is unknown: SHOULD refuse. /// 2. IF the tag's form is wrong (assets::check_asset_tag): SHOULD /// refuse with InvalidAssetTag. /// 3. IF another asset holds or held the tag: SHOULD refuse with /// AssetTagTaken. /// 4. IF the asset already has that tag: SHOULD accept (no change). /// ``` pub fn set_asset_tag(&mut self, id: AssetId, tag: &str) -> Result<(), LedgerError> { self.check_asset_tag_for(Some(id), tag)?; self.asset_tags.insert(assets::tag_key(tag), id); self.assets.get_mut(&id).expect("checked").tag = Some(tag.to_string()); Ok(()) } /// Whether `tag` can go on asset `id` (`None`: on an asset not yet /// registered), for stores that journal before mutating. pub fn check_asset_tag_for(&self, id: Option, tag: &str) -> Result<(), LedgerError> { if let Some(id) = id && !self.assets.contains_key(&id) { return Err(LedgerError::UnknownAsset(id)); } assets::check_asset_tag(tag).map_err(|why| LedgerError::InvalidAssetTag { tag: tag.to_string(), why, })?; match self.asset_tags.get(&assets::tag_key(tag)) { Some(&holder) if Some(holder) != id => Err(LedgerError::AssetTagTaken { tag: tag.to_string(), asset: holder, }), _ => Ok(()), } } /// Record (or, with `None`, clear) an asset's serial number. /// /// ```text /// GOAL: keep the manufacturer's serial with the asset, for warranty, /// insurance, and proving which thing the books mean. /// SOURCE: DECISION (Stephen, 2026-10-15). /// SCENARIOS: /// 1. IF the asset is unknown, or the serial's form is wrong /// (assets::check_serial): SHOULD refuse, changing nothing. /// ``` pub fn set_asset_serial( &mut self, id: AssetId, serial: Option<&str>, ) -> Result<(), LedgerError> { self.check_asset_serial(id, serial)?; self.assets.get_mut(&id).expect("checked").serial = serial.map(str::to_string); Ok(()) } /// The validation behind [`Self::set_asset_serial`]. pub fn check_asset_serial(&self, id: AssetId, serial: Option<&str>) -> Result<(), LedgerError> { if !self.assets.contains_key(&id) { return Err(LedgerError::UnknownAsset(id)); } if let Some(s) = serial { assets::check_serial(s).map_err(LedgerError::InvalidSerial)?; } Ok(()) } /// Link an asset to the posted entry that put it on the books: its /// purchase, or a member's contribution of it. /// /// ```text /// GOAL: from an asset, its source document and capital entry; from /// the entry, the asset. /// SOURCE: DECISION (Stephen, 2026-10-15). /// INVARIANTS: /// 1. The linked entry is posted, is not a reversal, has not been /// reversed, and debits the asset's cost account. /// 2. A refusal MUST leave the books unchanged. /// SCENARIOS: /// 1. IF the asset or entry is unknown, or the entry fails 1: SHOULD /// refuse with AcquisitionEntryMismatch (or UnknownAsset / /// UnknownEntry). /// 2. IF a later correction reverses the linked entry: link the /// replacement (a reversed link stays as history in the journal). /// ``` pub fn set_asset_acquisition( &mut self, id: AssetId, entry: EntryId, ) -> Result<(), LedgerError> { self.check_asset_acquisition(id, entry)?; self.assets.get_mut(&id).expect("checked").acquired_by = Some(entry); Ok(()) } /// The validation behind [`Self::set_asset_acquisition`]. pub fn check_asset_acquisition(&self, id: AssetId, entry: EntryId) -> Result<(), LedgerError> { let asset = self.assets.get(&id).ok_or(LedgerError::UnknownAsset(id))?; let e = self .entries .get(&entry) .ok_or(LedgerError::UnknownEntry(entry))?; let why = if e.status() != crate::EntryStatus::Posted { Some("it isn't posted") } else if e.reversal_of().is_some() { Some("it is a reversal") } else if self .entries .values() .any(|x| x.reversal_of() == Some(entry)) { Some("it has been reversed") } else if !e .lines() .iter() .any(|l| l.account == asset.cost_account && l.side == Side::Debit) { Some("it doesn't debit the asset's cost account") } else { None }; match why { Some(why) => Err(LedgerError::AcquisitionEntryMismatch { asset: id, entry, why: why.to_string(), }), None => Ok(()), } } /// Change an asset's description (what it is, as the register and its /// label print it). Its accounting doesn't change. /// /// ```text /// GOAL: keep the register's words right when they go stale (a /// corrected entry, a better name). /// SOURCE: DECISION (Stephen, 2026-10-15). /// SCENARIOS: /// 1. IF the asset is unknown, or the description is blank: SHOULD /// refuse, changing nothing. /// ``` pub fn set_asset_description( &mut self, id: AssetId, description: &str, ) -> Result<(), LedgerError> { self.check_asset_description(id, description)?; self.assets.get_mut(&id).expect("checked").description = description.to_string(); Ok(()) } /// The validation behind [`Self::set_asset_description`]. pub fn check_asset_description( &self, id: AssetId, description: &str, ) -> Result<(), LedgerError> { if !self.assets.contains_key(&id) { return Err(LedgerError::UnknownAsset(id)); } if description.trim().is_empty() { return Err(LedgerError::EmptyAssetDescription); } Ok(()) } /// The asset whose current tag is `tag`, ignoring case. pub fn asset_by_tag(&self, tag: &str) -> Option<&Asset> { let id = self.asset_tags.get(&assets::tag_key(tag))?; self.assets .get(id) .filter(|a| a.tag.as_deref().map(assets::tag_key) == Some(assets::tag_key(tag))) } /// The first free generated tag, `PREFIX-0001`, `PREFIX-0002`, ...: /// the lowest number no asset holds or held under that prefix. /// Numbers past 9999 widen. pub fn next_asset_tag(&self, prefix: &str) -> String { (1u64..) .map(|n| format!("{prefix}-{n:04}")) .find(|t| !self.asset_tags.contains_key(&assets::tag_key(t))) .expect("some number is free") } /// Mark an asset disposed. The disposal *entry* is posted through the /// normal machinery by the caller; this records the lifecycle change. /// /// ```text /// GOAL: take an asset off the books when sold or scrapped. /// SOURCE: docs/spec/rules.md B-15; CONVENTION (gain or loss on disposal). /// INVARIANTS: /// 1. The gain (or loss) MUST equal proceeds minus book value at the /// disposal date. /// 2. The asset's cost and accumulated depreciation MUST both leave the /// books. /// SCENARIOS: /// 1. IF the asset is unknown or already disposed: SHOULD refuse. /// ``` pub fn dispose_asset( &mut self, id: AssetId, date: NaiveDate, proceeds: Money, ) -> Result<(), LedgerError> { if proceeds.currency != self.base_currency { return Err(LedgerError::CurrencyMismatch { expected: self.base_currency.code().to_string(), found: proceeds.currency.code().to_string(), }); } let asset = self.assets.get(&id).ok_or(LedgerError::UnknownAsset(id))?; // Pure math validates activeness, proceeds sign, and date order. crate::disposal_amounts(asset, date, proceeds)?; self.assets.get_mut(&id).expect("asset exists").status = AssetStatus::Disposed { date, proceeds }; Ok(()) } // ------------------------------------------------------------------ // Accounting periods // ------------------------------------------------------------------ /// Create an accounting period. The date range must be valid /// (end >= start). Returns the period id. /// /// ```text /// GOAL: name a span of days that can later be closed. /// SOURCE: rules.md B-12 (IRS Pub. 538: tax years, calendar or fiscal); /// DECISION (Stephen, 2026-10-13): periods nest, never /// partially overlap. /// SCENARIOS: /// 1. IF end is before start: SHOULD refuse. /// 2. IF it lies inside, or around, every period it touches: SHOULD /// accept (a month in a quarter in a year). /// 3. IF it partially overlaps a period: /// - SHOULD refuse with PeriodsOverlap /// - NEVER create it, or use up an id /// BECAUSE a span split across two periods could close twice or /// never. /// ``` pub fn create_period( &mut self, name: &str, start: NaiveDate, end: NaiveDate, ) -> Result { periods::validate_period_range(start, end)?; let nested = |a: (NaiveDate, NaiveDate), b: (NaiveDate, NaiveDate)| { (a.0 >= b.0 && a.1 <= b.1) || (b.0 >= a.0 && b.1 <= a.1) }; for q in self.list_periods() { let touches = start <= q.end && q.start <= end; if touches && !nested((start, end), (q.start, q.end)) { return Err(LedgerError::PeriodsOverlap { name: name.to_string(), start, end, other: q.name.clone(), other_start: q.start, other_end: q.end, }); } } self.replay_create_period(name, start, end) } /// [`Self::create_period`] without the nesting rule added on /// 2026-10-13, for replaying journals written before it. pub fn replay_create_period( &mut self, name: &str, start: NaiveDate, end: NaiveDate, ) -> Result { periods::validate_period_range(start, end)?; let id = self.next_period_id; self.next_period_id += 1; self.periods.insert( id, Period { id, name: name.to_string(), start, end, status: PeriodStatus::Open, closing_entry: None, close_seq: None, member_close: None, }, ); Ok(id) } /// Look up a period by id. pub fn get_period(&self, id: PeriodId) -> Option<&Period> { self.periods.get(&id) } /// Ids of every period-closing entry. Period statements (P&L, cash /// flow) exclude them: a close moves earnings into retained earnings; it /// earns and spends nothing, so counting it would zero out the period. pub fn closing_entry_ids(&self) -> std::collections::BTreeSet { self.periods .values() .filter_map(|p| p.closing_entry) .collect() } /// All periods, in creation order. pub fn list_periods(&self) -> Vec<&Period> { let mut ids: Vec = self.periods.keys().copied().collect(); ids.sort_unstable(); ids.into_iter() .filter_map(|id| self.periods.get(&id)) .collect() } /// Close a period into Retained Earnings: see [`Self::close_period_to`]. pub fn close_period(&mut self, id: PeriodId) -> Result, LedgerError> { self.close_period_to(id, &periods::CloseTo::RetainedEarnings { sweep: vec![] }) } /// Close a period. This: /// 1. Rejects if the period is already closed, if any draft is dated /// inside it, if a later period is already closed (closing out of /// order would sweep the same income twice), or if `to` is invalid. /// 2. Posts a closing entry dated at period end that zeroes every revenue /// and expense account's balance *as of the period end* (entries /// dated after it stay open for their own period), puts the net in /// `to`'s target, and zeroes `to`'s sweep accounts (draws, /// distributions, dividends) into their target too: /// - `RetainedEarnings`: account `3999`, created if missing. /// - `Account`: one equity account (a sole proprietor's capital). /// - `Members`: each member's capital account gets their share of /// the net, to the cent (odd cents by largest remainder; closes /// journaled before 2026-10-13 replay with the last member /// absorbing them), and their own distribution account. /// 3. Marks the period closed. /// /// Returns the closing entry's id, or `None` if there was nothing to /// close. /// /// ```text /// GOAL: close a period: zero every revenue and expense account's balance /// as of its last day into equity, and lock it. /// SOURCE: docs/spec/rules.md B-11 (Pacioli ch. 27, 34), B-12, B-13 (26 /// U.S.C. §704(a)); R-? Periods and close. /// INVARIANTS: /// 1. After a close, every revenue and expense account MUST have a zero /// balance as of the period's last day. /// 2. The closing entry MUST balance, be dated the period's last day, and /// credit equity with exactly the net income through that day (split /// among members by share, to the cent, by largest remainder). /// 3. Entries dated after the period MUST stay untouched (their income /// belongs to their own period). /// 4. A sweep account (draws, distributions, dividends) MUST end at zero, /// its balance moved into its target. /// 5. NEVER post a closing entry when there is nothing to close. /// SCENARIOS: /// 1. IF the period is unknown or already closed: SHOULD refuse. /// 2. IF a draft is dated inside it: SHOULD refuse (DraftsInPeriod). /// 3. IF a later period is already closed: SHOULD refuse (CloseOutOfOrder). /// 4. IF a target or sweep account isn't a distinct equity account, or /// member shares aren't positive and summing to 1: SHOULD refuse. /// 5. IF closing to retained earnings and 3999 exists but isn't a /// postable equity account: SHOULD refuse (ControlAccountMismatch). /// ``` pub fn close_period_to( &mut self, id: PeriodId, to: &periods::CloseTo, ) -> Result, LedgerError> { self.atomically(|l| l.close_period_to_steps(id, to)) } /// The steps of [`Self::close_period_to`], run inside [`Self::atomically`]. fn close_period_to_steps( &mut self, id: PeriodId, to: &periods::CloseTo, ) -> Result, LedgerError> { self.preview_close_period_to(id, to)?; let period = self.periods.get(&id).expect("previewed").clone(); let (closings, net) = self.income_closings(period.end); // (account, amount to credit; negative debits): the net to its // targets, and each sweep account's balance into its target. let mut equity: Vec<(AccountId, Decimal)> = Vec::new(); let mut sweep_lines: Vec<(AccountId, AccountId, Decimal)> = Vec::new(); let mut sweep = |from: AccountId, into: AccountId, this: &Self| { // An equity balance (credit-normal) is −movement; zeroing it // moves exactly that much into the target. let balance = -this.movement_through(from, period.end); if !balance.is_zero() { sweep_lines.push((from, into, balance)); } }; match to { periods::CloseTo::RetainedEarnings { sweep: accounts } => { let has_work = !closings.is_empty() || accounts .iter() .any(|a| !self.movement_through(*a, period.end).is_zero()); if has_work { let re = self.retained_earnings_account()?; equity.push((re, net)); for a in accounts { sweep(*a, re, self); } } } periods::CloseTo::Account { into, sweep: accounts, } => { equity.push((*into, net)); for a in accounts { sweep(*a, *into, self); } } periods::CloseTo::Members(members) | periods::CloseTo::MembersPerClose(members) | periods::CloseTo::MembersLastAbsorbs(members) => { let shares: Vec = members.iter().map(|m| m.share).collect(); let parts = match to { periods::CloseTo::MembersLastAbsorbs(_) => { periods::allocate_last_absorbs(net, &shares) } periods::CloseTo::MembersPerClose(_) => periods::allocate(net, &shares), _ => { // Cumulative: what the run gives each member with // this close, less what it gave them before. let before = self.member_close_run(&member_key(members)); let after = periods::allocate(before + net, &shares); let prior = periods::allocate(before, &shares); after.iter().zip(&prior).map(|(a, p)| a - p).collect() } }; for (m, part) in members.iter().zip(parts) { equity.push((m.capital, part)); if let Some(d) = m.distributions { sweep(d, m.capital, self); } } } } let closing_entry = if closings.is_empty() && sweep_lines.is_empty() { None } else { let entry = self.create_entry(period.end, &format!("Close period {}", period.name)); let post = |this: &mut Self, account, credit: Decimal| -> Result<(), LedgerError> { match kernel::credit_line(credit) { Some((side, amount)) => { this.add_line(entry, account, side, Money::new(amount, this.base_currency)) } None => Ok(()), } }; for (account, side, amount) in closings { self.add_line(entry, account, side, Money::new(amount, self.base_currency))?; } for (account, share) in equity { post(self, account, share)?; } // A sweep zeroes the source (opposite its balance) and moves // the balance into the target. for (from, into, balance) in sweep_lines { post(self, from, -balance)?; post(self, into, balance)?; } self.post_entry(entry)?; Some(entry) }; let close_seq = self .periods .values() .filter(|p| p.status == PeriodStatus::Closed) .count() as u64 + 1; let p = self.periods.get_mut(&id).expect("period exists"); p.status = PeriodStatus::Closed; p.closing_entry = closing_entry; p.close_seq = Some(close_seq); p.member_close = match to { periods::CloseTo::Members(members) => Some(periods::MemberCloseRecord { members: member_key(members), net, }), _ => None, }; Ok(closing_entry) } /// The net closed by the run of cumulative members closes, with exactly /// these members and shares, that ends with the latest close: zero if /// the latest close was anything else (or there is none). fn member_close_run(&self, members: &[(AccountId, Decimal)]) -> Decimal { let mut closed: Vec<&periods::Period> = self .periods .values() .filter(|p| p.status == PeriodStatus::Closed) .collect(); closed.sort_by_key(|p| std::cmp::Reverse(p.close_seq)); let mut total = Decimal::ZERO; for p in closed { match &p.member_close { Some(r) if r.members == members => total += r.net, _ => break, } } total } /// Revenue and expense balances as of `end` as closing lines (code /// order), and the net income they carry (revenue debits minus expense /// credits). fn income_closings(&self, end: NaiveDate) -> (Vec<(AccountId, Side, Decimal)>, Decimal) { let mut closings = Vec::new(); let mut accounts: Vec<_> = self.chart.all().collect(); accounts.sort_by(|a, b| a.code.cmp(&b.code)); for account in accounts { if !matches!( account.account_type, AccountType::Revenue | AccountType::Expense ) { continue; } // To zero the account: post the opposite of its net movement. if let Some((side, amount)) = kernel::closing_line(self.movement_through(account.id, end)) { closings.push((account.id, side, amount)); } } let net = kernel::net(closings.iter().map(|&(_, side, amount)| (side, amount))) .expect("posted lines are in range, so their sums are"); (closings, net) } fn retained_earnings_account(&mut self) -> Result { 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, None => self.add_account( periods::RETAINED_EARNINGS_CODE, "Retained Earnings", AccountType::Equity, None, false, )?, }, ) } /// Validate a close target: every account an equity account, targets /// and sweeps distinct, members' shares positive and summing to 1. fn validate_close_to(&self, to: &periods::CloseTo) -> Result<(), LedgerError> { let mut accounts: Vec = Vec::new(); match to { periods::CloseTo::RetainedEarnings { sweep } => accounts.extend(sweep), periods::CloseTo::Account { into, sweep } => { accounts.push(*into); accounts.extend(sweep); } periods::CloseTo::Members(members) | periods::CloseTo::MembersPerClose(members) | periods::CloseTo::MembersLastAbsorbs(members) => { let total: Decimal = members.iter().map(|m| m.share).sum(); if members.is_empty() || total != Decimal::ONE || members.iter().any(|m| m.share <= Decimal::ZERO) { return Err(LedgerError::InvalidMembers(format!( "member shares must be positive and sum to 1, got {total}" ))); } for m in members { accounts.push(m.capital); accounts.extend(m.distributions); } } } let mut seen = std::collections::BTreeSet::new(); for a in accounts { let account = self.chart.get(a).ok_or(LedgerError::UnknownAccount(a))?; if account.account_type != AccountType::Equity || !seen.insert(a) { return Err(LedgerError::InvalidMembers(format!( "close account {} must be a distinct equity account", account.code ))); } } Ok(()) } /// Debits minus credits on an account across posted entries dated on or /// before `end`. fn movement_through(&self, account: AccountId, end: NaiveDate) -> Decimal { self.net_of(account, |e| e.date() <= end) } /// Debits minus credits on an account across the posted entries /// `include` accepts. fn net_of(&self, account: AccountId, include: impl Fn(&JournalEntry) -> bool) -> Decimal { kernel::net( self.posted_entries() .filter(|e| include(e)) .flat_map(|e| e.lines()) .filter(|l| l.account == account) .map(|l| (l.side, l.amount.amount)), ) .expect("posted lines are in range, so their sums are") } /// [`Self::preview_close_period_to`] closing into Retained Earnings. pub fn preview_close_period(&self, id: PeriodId) -> Result, LedgerError> { self.preview_close_period_to(id, &periods::CloseTo::RetainedEarnings { sweep: vec![] }) } /// Pure pre-close check for journaling stores: verifies that /// [`Self::close_period_to`] would succeed — the period exists and is /// open, no draft entries are dated inside it, no later period is /// closed, the closing date isn't inside another closed period, and the /// target is valid — and returns the id the closing entry will receive, /// or `None` when the close posts no entry. Stores call this before /// journaling so a failed close leaves no journal line behind. pub fn preview_close_period_to( &self, id: PeriodId, to: &periods::CloseTo, ) -> Result, LedgerError> { let period = self .periods .get(&id) .ok_or(LedgerError::UnknownPeriod(id))?; if period.status == PeriodStatus::Closed { return Err(LedgerError::PeriodAlreadyClosed(id)); } let has_drafts = self .entries .values() .any(|e| e.status() == EntryStatus::Draft && period.contains(e.date())); if has_drafts { return Err(LedgerError::DraftsInPeriod(id)); } if let Some(other) = self.closed_period_containing(period.end) && other != id { return Err(LedgerError::ClosedPeriod(other, period.end.to_string())); } // Periods close in order: a later closed period already swept this // one's income, so closing it now would sweep it again. if let Some(later) = self .periods .values() .find(|p| p.status == PeriodStatus::Closed && p.end > period.end) { 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. let (closings, _) = self.income_closings(period.end); let sweeps: Vec = match to { periods::CloseTo::RetainedEarnings { sweep } | periods::CloseTo::Account { sweep, .. } => sweep.clone(), periods::CloseTo::Members(members) | periods::CloseTo::MembersPerClose(members) | periods::CloseTo::MembersLastAbsorbs(members) => { members.iter().filter_map(|m| m.distributions).collect() } }; let any = !closings.is_empty() || sweeps .iter() .any(|a| !self.movement_through(*a, period.end).is_zero()); Ok(any.then_some(self.next_entry_id)) } // ------------------------------------------------------------------ // Accounts receivable / payable // ------------------------------------------------------------------ /// Add a customer. Returns its id. pub fn add_customer(&mut self, name: &str) -> Result { if name.trim().is_empty() { return Err(LedgerError::EmptyAssetDescription); } let id = self.next_customer_id; self.next_customer_id += 1; self.customers.insert( id, Customer { id, name: name.to_string(), }, ); Ok(id) } /// Add a vendor. Returns its id. pub fn add_vendor(&mut self, name: &str) -> Result { if name.trim().is_empty() { return Err(LedgerError::EmptyAssetDescription); } let id = self.next_vendor_id; self.next_vendor_id += 1; self.vendors.insert( id, Vendor { id, name: name.to_string(), form_1099: None, tin_on_file: false, }, ); Ok(id) } /// Look up a customer. pub fn get_customer(&self, id: CustomerId) -> Option<&Customer> { self.customers.get(&id) } /// Look up a vendor. pub fn get_vendor(&self, id: VendorId) -> Option<&Vendor> { self.vendors.get(&id) } /// Set which information return a vendor gets (or none), and whether /// their W-9 is on file. pub fn set_vendor_1099( &mut self, id: VendorId, form: Option, tin_on_file: bool, ) -> Result<(), LedgerError> { let v = self .vendors .get_mut(&id) .ok_or(LedgerError::UnknownVendor(id))?; v.form_1099 = form; v.tin_on_file = tin_on_file; Ok(()) } /// Tag an entry (draft or posted) with the vendor it paid or was billed /// by, or clear the tag. The tag is reporting metadata, like a receipt /// link: it changes no amounts, so posted entries may be tagged. /// /// ```text /// GOAL: tag an entry with the vendor it paid, for 1099s. /// SOURCE: docs/spec/rules.md B-19. /// SCENARIOS: /// 1. IF the entry or vendor is unknown: SHOULD refuse. /// 2. Tagging MUST change no amount (it's metadata, allowed on posted /// entries). /// ``` pub fn set_entry_vendor( &mut self, entry: EntryId, vendor: Option, ) -> Result<(), LedgerError> { if !self.entries.contains_key(&entry) { return Err(LedgerError::UnknownEntry(entry)); } match vendor { Some(v) => { if !self.vendors.contains_key(&v) { return Err(LedgerError::UnknownVendor(v)); } self.entry_vendors.insert(entry, v); } None => { self.entry_vendors.remove(&entry); } } Ok(()) } /// The vendor an entry is tagged with. pub fn entry_vendor(&self, entry: EntryId) -> Option { self.entry_vendors.get(&entry).copied() } /// All customers, in id order. pub fn list_customers(&self) -> Vec<&Customer> { let mut ids: Vec = self.customers.keys().copied().collect(); ids.sort_unstable(); ids.into_iter() .filter_map(|id| self.customers.get(&id)) .collect() } /// All vendors, in id order. pub fn list_vendors(&self) -> Vec<&Vendor> { let mut ids: Vec = self.vendors.keys().copied().collect(); ids.sort_unstable(); ids.into_iter() .filter_map(|id| self.vendors.get(&id)) .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 { // 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) { 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 { 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 /// and name if missing. Returns the account id. fn ensure_account(&mut self, code: &str, name: &str, account_type: AccountType) -> AccountId { if let Some(a) = self.chart.find_by_code(code) { return a.id; } self.add_account(code, name, account_type, None, false) .expect("fresh code cannot fail") } /// Create a sales invoice. Auto-posts: Dr `1200` Accounts Receivable, /// Cr `4000` Revenue. The invoice date must not fall in a closed period. /// /// ```text /// GOAL: bill a customer and recognize the revenue (accrual basis). /// SOURCE: IRS Pub. 538, accrual method (docs/spec/sources/irs-periods-and-methods.md: /// "report income in the year it is earned"); ASC 606-10-25-23; /// control accounts: Ledger::check_controls. /// INVARIANTS: /// 1. MUST post one balanced entry: Dr 1200 receivables, Cr 4000 revenue, /// for the sum of the lines, dated the invoice date. /// 2. The invoice MUST start open, with nothing paid. /// 3. A refusal MUST leave the books unchanged. /// SCENARIOS: /// 1. IF the customer is unknown, a line amount isn't positive, the due /// date is before the invoice date, or the date is in a closed /// period: SHOULD refuse. /// ``` pub fn create_invoice( &mut self, customer_id: CustomerId, date: NaiveDate, due_date: NaiveDate, lines: Vec, ) -> Result { self.atomically(|l| l.create_invoice_steps(customer_id, date, due_date, lines)) } /// The steps of [`Self::create_invoice`], run inside [`Self::atomically`]. fn create_invoice_steps( &mut self, customer_id: CustomerId, date: NaiveDate, due_date: NaiveDate, lines: Vec, ) -> Result { if !self.customers.contains_key(&customer_id) { return Err(LedgerError::UnknownCustomer(customer_id)); } arap::validate_lines(&lines)?; arap::validate_due_date(date, due_date)?; if let Some(pid) = self.closed_period_containing(date) { return Err(LedgerError::ClosedPeriod(pid, date.to_string())); } let total: Decimal = lines.iter().map(|l| l.amount).sum(); 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)?; self.add_line(entry, revenue, Side::Credit, money)?; self.post_entry(entry)?; let id = self.next_invoice_id; self.next_invoice_id += 1; self.invoices.insert( id, Invoice { id, customer_id, date, due_date, lines, amount_paid: Decimal::ZERO, status: DocumentStatus::Open, entry_id: entry, }, ); Ok(id) } /// Create a vendor bill. Auto-posts: Dr `6999` General Expenses, /// Cr `2000` Accounts Payable. The bill date must not fall in a closed /// period. /// /// ```text /// GOAL: record a vendor's bill as an expense owed (accrual basis). /// SOURCE: IRS Pub. 538, accrual method ("deduct ... expenses in the year /// incurred"); control accounts: Ledger::check_controls. /// INVARIANTS: /// 1. MUST post one balanced entry: Dr 6999 general expenses, Cr 2000 /// payables, for the sum of the lines, dated the bill date, tagged /// with the vendor (1099s). /// 2. A refusal MUST leave the books unchanged. /// SCENARIOS: /// 1. IF the vendor is unknown, a line amount isn't positive, the due date /// is before the bill date, or the date is in a closed period: SHOULD /// refuse. /// ``` pub fn create_bill( &mut self, vendor_id: VendorId, date: NaiveDate, due_date: NaiveDate, lines: Vec, ) -> Result { self.atomically(|l| l.create_bill_steps(vendor_id, date, due_date, lines)) } /// The steps of [`Self::create_bill`], run inside [`Self::atomically`]. fn create_bill_steps( &mut self, vendor_id: VendorId, date: NaiveDate, due_date: NaiveDate, lines: Vec, ) -> Result { if !self.vendors.contains_key(&vendor_id) { return Err(LedgerError::UnknownVendor(vendor_id)); } arap::validate_lines(&lines)?; arap::validate_due_date(date, due_date)?; if let Some(pid) = self.closed_period_containing(date) { return Err(LedgerError::ClosedPeriod(pid, date.to_string())); } let total: Decimal = lines.iter().map(|l| l.amount).sum(); 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)?; self.add_line(entry, ap, Side::Credit, money)?; self.post_entry(entry)?; let id = self.next_bill_id; self.next_bill_id += 1; self.bills.insert( id, Bill { id, vendor_id, date, due_date, lines, amount_paid: Decimal::ZERO, status: DocumentStatus::Open, entry_id: entry, }, ); self.entry_vendors.insert(entry, vendor_id); Ok(id) } /// Look up an invoice. pub fn get_invoice(&self, id: InvoiceId) -> Option<&Invoice> { self.invoices.get(&id) } /// Look up a bill. pub fn get_bill(&self, id: BillId) -> Option<&Bill> { self.bills.get(&id) } /// Record a customer payment against an invoice. Posts: Dr Cash, /// Cr A/R. Updates the invoice's paid amount and status. /// /// ```text /// GOAL: record a customer's payment against an invoice. /// SOURCE: IRS Pub. 538 (accrual); CONVENTION (A/R workflow). /// INVARIANTS: /// 1. MUST post Dr cash, Cr 1200 receivables, for the amount. /// 2. The invoice's paid amount MUST rise by the amount; it is Paid /// exactly when fully paid, Partial when partly. /// SCENARIOS: /// 1. IF the amount isn't positive: SHOULD refuse. /// 2. IF it exceeds what's still owed: /// - SHOULD refuse (Overpayment) /// - NEVER let paid exceed the total /// ``` pub fn receive_payment( &mut self, invoice_id: InvoiceId, date: NaiveDate, amount: Decimal, cash_account: AccountId, ) -> Result { self.atomically(|l| l.receive_payment_steps(invoice_id, date, amount, cash_account)) } /// The steps of [`Self::receive_payment`], run inside [`Self::atomically`]. fn receive_payment_steps( &mut self, invoice_id: InvoiceId, date: NaiveDate, amount: Decimal, cash_account: AccountId, ) -> Result { if amount <= Decimal::ZERO { return Err(LedgerError::InvalidPaymentAmount(amount)); } let invoice = self .invoices .get(&invoice_id) .ok_or(LedgerError::UnknownInvoice(invoice_id))? .clone(); let balance = invoice.balance(); if amount > balance { return Err(LedgerError::Overpayment(amount, balance)); } if let Some(pid) = self.closed_period_containing(date) { return Err(LedgerError::ClosedPeriod(pid, date.to_string())); } if date < invoice.date { return Err(LedgerError::PaymentBeforeDocument { date, document_date: invoice.date, }); } self.check_controls(&[RECEIVABLES])?; // Every check before the entry exists: a refusal leaves no draft. self.check_line_account(cash_account)?; 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)?; self.add_line(entry, ar, Side::Credit, money)?; self.post_entry(entry)?; let inv = self.invoices.get_mut(&invoice_id).expect("invoice exists"); inv.amount_paid += amount; inv.status = arap::derive_status(inv.amount_paid, inv.total()); self.invoice_payments .entry(invoice_id) .or_default() .push((entry, amount)); Ok(entry) } /// Pay a vendor bill. Posts: Dr A/P, Cr Cash. Updates the bill's paid /// amount and status. /// /// ```text /// GOAL: record paying a vendor's bill. /// SOURCE: IRS Pub. 538 (accrual); CONVENTION (A/P workflow). /// INVARIANTS: /// 1. MUST post Dr 2000 payables, Cr cash, for the amount, tagged with /// the vendor. /// 2. The bill's paid amount MUST rise by the amount. /// SCENARIOS: /// 1. IF the amount isn't positive, or exceeds what's still owed: SHOULD /// refuse. /// 2. IF dated before the bill: SHOULD refuse (PaymentBeforeDocument); /// the bill's own date is fine (DECISION, Stephen 2026-10-13). /// ``` pub fn pay_bill( &mut self, bill_id: BillId, date: NaiveDate, amount: Decimal, cash_account: AccountId, ) -> Result { self.atomically(|l| l.pay_bill_steps(bill_id, date, amount, cash_account)) } /// The steps of [`Self::pay_bill`], run inside [`Self::atomically`]. fn pay_bill_steps( &mut self, bill_id: BillId, date: NaiveDate, amount: Decimal, cash_account: AccountId, ) -> Result { if amount <= Decimal::ZERO { return Err(LedgerError::InvalidPaymentAmount(amount)); } let bill = self .bills .get(&bill_id) .ok_or(LedgerError::UnknownBill(bill_id))? .clone(); let balance = bill.balance(); if amount > balance { return Err(LedgerError::Overpayment(amount, balance)); } if let Some(pid) = self.closed_period_containing(date) { return Err(LedgerError::ClosedPeriod(pid, date.to_string())); } if date < bill.date { return Err(LedgerError::PaymentBeforeDocument { date, document_date: bill.date, }); } self.check_controls(&[PAYABLES])?; // Every check before the entry exists: a refusal leaves no draft. self.check_line_account(cash_account)?; 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)?; self.add_line(entry, cash_account, Side::Credit, money)?; self.post_entry(entry)?; let b = self.bills.get_mut(&bill_id).expect("bill exists"); b.amount_paid += amount; b.status = arap::derive_status(b.amount_paid, b.total()); self.bill_payments .entry(bill_id) .or_default() .push((entry, amount)); self.entry_vendors.insert(entry, bill.vendor_id); Ok(entry) } /// Record an invoice without posting: replay support for journaling /// stores. The invoice's entry was already rebuilt from its own /// `entry.created` / `entry.line_added` / `entry.posted` events; this /// only restores the invoice record. The id must be the next invoice /// id — anything else means the journal and the allocator disagree. #[doc(hidden)] pub fn replay_invoice( &mut self, id: InvoiceId, customer_id: CustomerId, date: NaiveDate, due_date: NaiveDate, lines: Vec, entry_id: EntryId, ) -> Result<(), LedgerError> { if id != self.next_invoice_id { return Err(LedgerError::ReplayIdMismatch(id, self.next_invoice_id)); } self.next_invoice_id += 1; self.invoices.insert( id, Invoice { id, customer_id, date, due_date, lines, amount_paid: Decimal::ZERO, status: DocumentStatus::Open, entry_id, }, ); Ok(()) } /// Record a vendor bill without posting: replay support for journaling /// stores. See [`Self::replay_invoice`]. #[doc(hidden)] pub fn replay_bill( &mut self, id: BillId, vendor_id: VendorId, date: NaiveDate, due_date: NaiveDate, lines: Vec, entry_id: EntryId, ) -> Result<(), LedgerError> { if id != self.next_bill_id { return Err(LedgerError::ReplayIdMismatch(id, self.next_bill_id)); } self.next_bill_id += 1; self.bills.insert( id, Bill { id, vendor_id, date, due_date, lines, amount_paid: Decimal::ZERO, status: DocumentStatus::Open, entry_id, }, ); self.entry_vendors.insert(entry_id, vendor_id); Ok(()) } /// Apply an invoice payment without posting: replay support for /// journaling stores. The payment entry was already rebuilt from its /// own entry events; this only moves the invoice's paid amount and /// status. `entry_id` is the journaled payment entry, carried for /// audit; the invoice does not link payment entries. #[doc(hidden)] pub fn replay_invoice_payment( &mut self, invoice_id: InvoiceId, amount: Decimal, entry_id: EntryId, ) -> Result<(), LedgerError> { let inv = self .invoices .get_mut(&invoice_id) .ok_or(LedgerError::UnknownInvoice(invoice_id))?; inv.amount_paid += amount; self.invoice_payments .entry(invoice_id) .or_default() .push((entry_id, amount)); inv.status = arap::derive_status(inv.amount_paid, inv.total()); Ok(()) } /// Apply a bill payment without posting: replay support for journaling /// stores. See [`Self::replay_invoice_payment`]. #[doc(hidden)] pub fn replay_bill_payment( &mut self, bill_id: BillId, amount: Decimal, entry_id: EntryId, ) -> Result<(), LedgerError> { let bill = self .bills .get_mut(&bill_id) .ok_or(LedgerError::UnknownBill(bill_id))?; bill.amount_paid += amount; bill.status = arap::derive_status(bill.amount_paid, bill.total()); self.bill_payments .entry(bill_id) .or_default() .push((entry_id, amount)); let vendor = bill.vendor_id; self.entry_vendors.insert(entry_id, vendor); Ok(()) } /// A/R aging as of a date: one row per customer with an outstanding /// balance, bucketed by days past due. pub fn ar_aging(&self, as_of: NaiveDate) -> Vec { let mut by_customer: HashMap = HashMap::new(); for inv in self.invoices.values() { // Only what existed by `as_of`: issued by then, less payments // made by then (Stephen, 2026-10-13). if inv.date > as_of { continue; } let balance = inv.total() - self.paid_by(self.invoice_payments.get(&inv.id), as_of); if balance.is_zero() { continue; } let row = arap::aging_bucket(inv.due_date, as_of, balance); let entry = by_customer .entry(inv.customer_id) .or_insert_with(|| AgingRow { name: self .customers .get(&inv.customer_id) .map(|c| c.name.clone()) .unwrap_or_default(), current: Decimal::ZERO, days_30: Decimal::ZERO, days_60: Decimal::ZERO, days_90: Decimal::ZERO, days_90_plus: Decimal::ZERO, }); entry.current += row.current; entry.days_30 += row.days_30; entry.days_60 += row.days_60; entry.days_90 += row.days_90; entry.days_90_plus += row.days_90_plus; } let mut rows: Vec = by_customer.into_values().collect(); rows.sort_by(|a, b| a.name.cmp(&b.name)); rows } /// What a document's payments add up to through `as_of`: payments are /// posted entries, dated. fn paid_by(&self, payments: Option<&Vec<(EntryId, Decimal)>>, as_of: NaiveDate) -> Decimal { payments .into_iter() .flatten() .filter(|(e, _)| self.get_entry(*e).is_some_and(|e| e.date() <= as_of)) .map(|(_, amount)| *amount) .sum() } /// A/P aging as of a date: one row per vendor with an outstanding /// balance, bucketed by days past due. pub fn ap_aging(&self, as_of: NaiveDate) -> Vec { let mut by_vendor: HashMap = HashMap::new(); for bill in self.bills.values() { // Only what existed by `as_of` (see `ar_aging`). if bill.date > as_of { continue; } let balance = bill.total() - self.paid_by(self.bill_payments.get(&bill.id), as_of); if balance.is_zero() { continue; } let row = arap::aging_bucket(bill.due_date, as_of, balance); let entry = by_vendor.entry(bill.vendor_id).or_insert_with(|| AgingRow { name: self .vendors .get(&bill.vendor_id) .map(|v| v.name.clone()) .unwrap_or_default(), current: Decimal::ZERO, days_30: Decimal::ZERO, days_60: Decimal::ZERO, days_90: Decimal::ZERO, days_90_plus: Decimal::ZERO, }); entry.current += row.current; entry.days_30 += row.days_30; entry.days_60 += row.days_60; entry.days_90 += row.days_90; entry.days_90_plus += row.days_90_plus; } let mut rows: Vec = by_vendor.into_values().collect(); rows.sort_by(|a, b| a.name.cmp(&b.name)); rows } // ------------------------------------------------------------------ // Bank reconciliation // ------------------------------------------------------------------ /// Mark a posted entry's line as cleared (or uncleared). The cleared /// flag is a workflow marker, not accounting data: posted entries stay /// immutable, and the flag lives in a side table. /// /// ```text /// GOAL: mark a posted line as cleared (seen on the bank statement), or not. /// SOURCE: CONVENTION (bank reconciliation); R-? Bank reconciliation. /// SCENARIOS: /// 1. IF the entry is unknown, a draft, or has no such line: SHOULD refuse. /// 2. Marking MUST change no amount, no report but the reconciliation. /// ``` pub fn mark_cleared( &mut self, entry_id: EntryId, line_index: usize, cleared: bool, ) -> Result<(), LedgerError> { let entry = self .entries .get(&entry_id) .ok_or(LedgerError::UnknownEntry(entry_id))?; if entry.status() != EntryStatus::Posted { return Err(LedgerError::ClearedOnDraft(entry_id)); } if line_index >= entry.lines().len() { return Err(LedgerError::LineIndexOutOfRange(line_index, entry_id)); } if cleared { self.cleared.insert((entry_id, line_index), true); } else { self.cleared.remove(&(entry_id, line_index)); } Ok(()) } /// True if the given posted line is marked cleared. pub fn is_cleared(&self, entry_id: EntryId, line_index: usize) -> bool { self.cleared .get(&(entry_id, line_index)) .copied() .unwrap_or(false) } /// Reconcile a bank/cash account against a statement. Compares the /// cleared balance (posted lines marked cleared, dated on or before /// the statement date) to the statement balance, and lists every /// uncleared line so the human can see what's outstanding. /// /// ```text /// GOAL: compare the books with a bank statement. /// SOURCE: CONVENTION (bank reconciliation); R-? Bank reconciliation. /// INVARIANTS: /// 1. book_balance MUST be the account's net of all posted lines. /// 2. cleared_balance MUST be the net of cleared lines dated on or /// before the statement date. /// 3. difference MUST equal cleared_balance - statement_balance; zero /// means reconciled. /// 4. uncleared_lines MUST list exactly the posted, uncleared lines on /// the account dated on or before the statement date. /// ``` pub fn reconciliation_report( &self, account: AccountId, statement_date: NaiveDate, statement_balance: Decimal, ) -> Result { let acct = self .chart .get(account) .ok_or(LedgerError::UnknownAccount(account))?; let normal = acct.account_type.normal_side(); let mut book = Decimal::ZERO; let mut cleared = Decimal::ZERO; let mut uncleared_lines = Vec::new(); for entry in self.posted_entries() { for (idx, line) in entry.lines().iter().enumerate() { if line.account != account { continue; } // Signed in the account's normal side: positive = normal balance. let signed = match (line.side, normal) { (Side::Debit, Side::Debit) => line.amount.amount, (Side::Credit, Side::Credit) => line.amount.amount, (Side::Debit, Side::Credit) => -line.amount.amount, (Side::Credit, Side::Debit) => -line.amount.amount, }; book += signed; if entry.date() > statement_date { continue; } if self.is_cleared(entry.id(), idx) { cleared += signed; } else { uncleared_lines.push(UnclearedLine { entry_id: entry.id(), line_index: idx, date: entry.date(), description: entry.description().to_string(), side: line.side, amount: line.amount.amount, }); } } } // Sort uncleared by date for a readable report. uncleared_lines.sort_by(|a, b| a.date.cmp(&b.date).then(a.entry_id.cmp(&b.entry_id))); Ok(ReconciliationReport { account, book_balance: if book.is_zero() { Decimal::ZERO } else { book }, cleared_balance: if cleared.is_zero() { Decimal::ZERO } else { cleared }, statement_balance, difference: cleared - statement_balance, uncleared_lines, }) } // ------------------------------------------------------------------ // Foreign exchange // ------------------------------------------------------------------ /// Record a historical FX rate for reference: how much base currency /// one unit of `currency` buys on `date`. Lines store their own rate at /// entry time; this table is for lookup and audit, not conversion. pub fn set_fx_rate( &mut self, currency: Currency, date: NaiveDate, rate: Decimal, ) -> Result<(), LedgerError> { if rate <= Decimal::ZERO { return Err(LedgerError::InvalidFxRate(rate)); } if currency == self.base_currency { return Err(LedgerError::CurrencyMismatch { expected: "foreign currency".to_string(), found: currency.code().to_string(), }); } self.fx_rates.insert((currency, date), rate); Ok(()) } /// Look up a recorded FX rate. pub fn get_fx_rate(&self, currency: Currency, date: NaiveDate) -> Option { self.fx_rates.get(&(currency, date)).copied() } /// Signed net movement of an account across all posted entries: /// debits minus credits. Positive means net debit movement. pub fn account_movement(&self, account: AccountId) -> Decimal { self.net_of(account, |_| true) } /// Balance expressed in the account's normal side: positive means a /// normal balance, negative means a contra balance. /// /// ```text /// GOAL: an account's balance on its normal side. /// SOURCE: docs/spec/rules.md B-5. /// INVARIANTS: /// 1. MUST equal the account's debits minus credits for debit-normal /// types (asset, expense), and credits minus debits otherwise. /// 2. NEVER return negative zero. /// SCENARIOS: /// 1. IF the account is unknown: SHOULD refuse. /// ``` pub fn account_balance(&self, account: AccountId) -> Result { let account = self .chart .get(account) .ok_or(LedgerError::UnknownAccount(account))?; let movement = self.account_movement(account.id); let balance = kernel::normal_balance(account.account_type, movement) .expect("Decimal negation is exact"); // Never leak negative zero into reports: it is a representation // artifact of negating an exact zero, and money must never print "-0". Ok(if balance.is_zero() { Decimal::ZERO } else { balance }) } // ------------------------------------------------------------------ // Payroll // ------------------------------------------------------------------ /// Pure pre-validation for [`Self::run_payroll`]: every check that does /// not mutate, so journaling stores can validate before journaling. pub fn validate_payroll( &self, date: NaiveDate, lines: &[PayrollLine], cash_account: AccountId, ) -> Result<(), LedgerError> { validate_payroll_lines(lines)?; self.check_controls(&PAYROLL)?; self.chart .get(cash_account) .ok_or(LedgerError::UnknownAccount(cash_account))?; if let Some(pid) = self.closed_period_containing(date) { return Err(LedgerError::ClosedPeriod(pid, date.to_string())); } Ok(()) } /// Resolve the seven payroll chart accounts by code, creating them when /// 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_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), } } /// Run payroll: validate, auto-create the payroll chart accounts, and /// post one balanced entry — Dr `6100` Salary & Wage Expense for gross, /// Dr `6110` Payroll Tax Expense for the employer FICA share, Cr the /// `2110`–`2150` payables for withholdings and deductions, Cr cash for /// net pay. Zero totals are skipped, never posted as zero-amount lines. /// /// Accounting only: there are no tax tables here. Every withholding /// number is supplied by the caller. /// /// ```text /// GOAL: record a payroll: wages, the employer's FICA, what is withheld /// and owed, and net pay. /// SOURCE: IRS Pub. 15 (docs/spec/sources/irs-payroll.md); R-? Payroll /// (accounting only: the caller supplies every number). /// INVARIANTS: /// 1. MUST post one balanced entry: Dr 6100 gross wages, Dr 6110 employer /// FICA, Cr 2110-2150 the withholdings and both shares of FICA, Cr cash /// net pay. /// 2. Net pay MUST equal gross minus the employee's withholdings. /// SCENARIOS: /// 1. IF any amount is negative, gross is zero, or withholdings exceed /// gross: SHOULD refuse. /// ``` pub fn run_payroll( &mut self, date: NaiveDate, lines: Vec, cash_account: AccountId, ) -> Result { self.atomically(|l| l.run_payroll_steps(date, lines, cash_account)) } /// The steps of [`Self::run_payroll`], run inside [`Self::atomically`]. fn run_payroll_steps( &mut self, date: NaiveDate, lines: Vec, cash_account: AccountId, ) -> Result { self.validate_payroll(date, &lines, cash_account)?; let accounts = self.payroll_accounts(); let entry = self.try_create_entry(date, &format!("Payroll {date}"))?; for (account, side, amount) in payroll_entry_plan(&lines, accounts, cash_account, self.base_currency) { self.add_line(entry, account, side, amount)?; } self.post_entry(entry)?; let id = self.next_payroll_run_id; self.next_payroll_run_id += 1; self.payroll_runs.insert( id, PayrollRun { id, date, lines, entry_id: entry, }, ); Ok(id) } /// Current balances of the payroll tax payable accounts (`2110`–`2150`), /// in code order. Accounts that don't exist yet count as zero and are /// skipped, as are zero balances. Balances are normal-side: positive /// means owed. pub fn payroll_tax_payables(&self) -> Vec<(AccountId, Decimal)> { let mut out = Vec::new(); for c in &PAYROLL[2..] { let Some(account) = self.role_account(c) else { continue; }; // 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, balance)); } } out } /// Remit payroll taxes: pay the `2110`–`2150` payables out of cash. /// Posts one entry — Dr each payable for its balance, Cr cash for the /// total. Fails with [`LedgerError::NothingToRemit`] when nothing is /// owed, and rejects dates in closed periods. /// /// ```text /// GOAL: pay the payroll taxes and withholdings owed. /// SOURCE: IRS Pub. 15 (deposits). /// INVARIANTS: /// 1. MUST zero every payroll payable (2110-2150) against cash. /// SCENARIOS: /// 1. IF nothing is owed: SHOULD refuse (NothingToRemit). /// ``` pub fn remit_payroll_taxes( &mut self, date: NaiveDate, cash_account: AccountId, ) -> Result { self.atomically(|l| l.remit_payroll_taxes_steps(date, cash_account)) } /// The steps of [`Self::remit_payroll_taxes`], run inside [`Self::atomically`]. fn remit_payroll_taxes_steps( &mut self, date: NaiveDate, cash_account: AccountId, ) -> Result { 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() { return Err(LedgerError::NothingToRemit); } if let Some(pid) = self.closed_period_containing(date) { return Err(LedgerError::ClosedPeriod(pid, date.to_string())); } let entry = self.try_create_entry(date, &format!("Payroll tax remittance {date}"))?; for (account, side, amount) in payroll_remit_plan(&payables, cash_account, self.base_currency) { self.add_line(entry, account, side, amount)?; } self.post_entry(entry)?; Ok(entry) } /// All payroll runs, in id order. pub fn list_payroll_runs(&self) -> Vec<&PayrollRun> { let mut ids: Vec = self.payroll_runs.keys().copied().collect(); ids.sort_unstable(); ids.into_iter() .filter_map(|id| self.payroll_runs.get(&id)) .collect() } /// Look up a payroll run. pub fn get_payroll_run(&self, id: PayrollRunId) -> Option<&PayrollRun> { self.payroll_runs.get(&id) } /// Record a payroll run without posting: replay support for journaling /// stores. The run's entry was already rebuilt from its own /// `entry.created` / `entry.line_added` / `entry.posted` events; this /// only restores the run record. The id must be the next payroll run /// id — anything else means the journal and the allocator disagree. #[doc(hidden)] pub fn replay_payroll_run( &mut self, id: PayrollRunId, date: NaiveDate, lines: Vec, entry_id: EntryId, ) -> Result<(), LedgerError> { if id != self.next_payroll_run_id { return Err(LedgerError::ReplayIdMismatch(id, self.next_payroll_run_id)); } self.next_payroll_run_id += 1; self.payroll_runs.insert( id, PayrollRun { id, date, lines, entry_id, }, ); Ok(()) } // ------------------------------------------------------------------ // Classes // ------------------------------------------------------------------ /// Add a class (division) for line-level P&L tracking. Returns its id. pub fn add_class(&mut self, name: &str) -> Result { if name.trim().is_empty() { return Err(LedgerError::EmptyClassName); } let id = self.next_class_id; self.next_class_id += 1; self.classes.insert( id, Class { id, name: name.to_string(), }, ); Ok(id) } /// Look up a class. pub fn get_class(&self, id: ClassId) -> Option<&Class> { self.classes.get(&id) } /// All classes, in id order. pub fn list_classes(&self) -> Vec<&Class> { let mut ids: Vec = self.classes.keys().copied().collect(); ids.sort_unstable(); ids.into_iter() .filter_map(|id| self.classes.get(&id)) .collect() } /// Find a class by name, case-insensitive. pub fn find_class_by_name(&self, name: &str) -> Option<&Class> { self.classes .values() .find(|c| c.name.eq_ignore_ascii_case(name)) } /// Tag a draft entry's line with a class, or clear it with `None`. /// Draft-only: posted entries are immutable, so their classes are /// frozen at posting time. /// /// ```text /// GOAL: tag a draft line with a class (or clear it). /// SOURCE: R-? Tracking classes. /// SCENARIOS: /// 1. IF the entry is posted: /// - SHOULD refuse /// - NEVER change a posted line (B-8) /// 2. IF the line or class doesn't exist: SHOULD refuse. /// ``` pub fn set_line_class( &mut self, entry: EntryId, line_index: usize, class_id: Option, ) -> Result<(), LedgerError> { if let Some(cid) = class_id && !self.classes.contains_key(&cid) { return Err(LedgerError::UnknownClass(cid)); } let e = self.draft_mut(entry)?; if line_index >= e.lines().len() { return Err(LedgerError::LineIndexOutOfRange(line_index, entry)); } e.set_line_class(line_index, class_id); Ok(()) } /// Record a class without allocating: replay support for journaling /// stores. The id must be the next class id — anything else means the /// journal and the allocator disagree. #[doc(hidden)] pub fn replay_class(&mut self, id: ClassId, name: String) -> Result<(), LedgerError> { if id != self.next_class_id { return Err(LedgerError::ReplayIdMismatch( id as u64, self.next_class_id as u64, )); } self.next_class_id += 1; self.classes.insert(id, Class { id, name }); Ok(()) } } /// Refuse an amount finer than its currency's minor unit (rules.md B-3). /// A currency with no defined minor unit (ISO 4217 "N.A.") is not checked. pub(crate) fn check_minor_units(amount: Money) -> Result<(), LedgerError> { match amount.currency.minor_units() { Some(places) if amount.amount.normalize().scale() > places => { Err(LedgerError::FinerThanMinorUnit { amount: amount.amount, currency: amount.currency.code().to_string(), places, }) } _ => Ok(()), } } /// A members close's (capital account, share) list, in order: what a run /// of cumulative closes must share. fn member_key(members: &[periods::MemberClose]) -> Vec<(AccountId, Decimal)> { members.iter().map(|m| (m.capital, m.share)).collect() }