//! 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, } /// What a posting template drafts: its description, and the lines /// `(account, side, amount)` ([`Ledger::template_lines`]). pub type TemplateDraft = (Option, Vec<(AccountId, Side, Money)>); /// 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, /// The project facts set for classes (rules.md B-37); a class not here /// has the defaults. projects: std::collections::BTreeMap, 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, /// The payment methods ([`crate::instruments`]), by id. instruments: std::collections::BTreeMap, next_instrument_id: crate::InstrumentId, /// The payment method each entry was paid with, for 1099 reporting. entry_instruments: HashMap, /// The document each receivables or payables line belongs to, by /// (entry, line index) (rules.md B-38, plan P4b.3): written by the /// document commands in the step that posts the line, derived on /// replay from the document events, and copied onto a reversal's /// mirror line. Every figure about a document is read from these. line_docs: std::collections::BTreeMap<(EntryId, usize), arap::DocTag>, /// Every payment event recorded against a document, each amount once, /// whether its entry stands and whether its line could be tagged: /// what a version before document standing counts as paid /// (`payments_recorded`; plan P4b.7, `document_standing`). payment_events: std::collections::BTreeMap>, /// Indexes derived from the fields above, kept as they change so the /// figures read from them stay linear (none is state of its own): /// each document's tagged lines (`line_docs` by document)... doc_index: std::collections::BTreeMap>, /// ... the accounts holding a document's line, and whether an /// invoice's (receivables) or a bill's (payables)... doc_accounts: std::collections::BTreeSet<(AccountId, bool)>, /// ... each posted entry's place in `order`... order_pos: HashMap, /// ... and the posted entry reversing each reversed entry (the first /// posted, as `reversal_chain` reads it). reversed_by: 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, /// Each asset's depreciation runs (`asset.depreciation_posted`), in /// the order recorded: what the books carry for it /// ([`Ledger::accumulated_posted`]). asset_runs: std::collections::BTreeMap>, /// The party directory ([`crate::parties`]). parties: std::collections::BTreeMap, next_party_id: crate::PartyId, /// The parties each entry is with (its counterparties), each with /// their share ([`crate::shares`]); never an empty list. entry_parties: HashMap>, /// Entries marked as having no counterparty, with why. entry_no_party: HashMap, /// The settings the journal holds ([`crate::settings`]). settings: crate::settings::Settings, /// Conventions, decisions, open items and approvals /// ([`crate::conventions`]). conventions: crate::conventions::Conventions, } 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(), projects: std::collections::BTreeMap::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(), instruments: std::collections::BTreeMap::new(), next_instrument_id: 1, entry_instruments: HashMap::new(), line_docs: std::collections::BTreeMap::new(), payment_events: std::collections::BTreeMap::new(), doc_index: std::collections::BTreeMap::new(), doc_accounts: std::collections::BTreeSet::new(), order_pos: HashMap::new(), reversed_by: HashMap::new(), roles: std::collections::BTreeMap::new(), asset_tags: std::collections::BTreeMap::new(), asset_runs: std::collections::BTreeMap::new(), parties: std::collections::BTreeMap::new(), next_party_id: 1, entry_parties: HashMap::new(), entry_no_party: HashMap::new(), settings: crate::settings::Settings::default(), conventions: crate::conventions::Conventions::default(), } } // ------------------------------------------------------------------ // 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, or the calendar bound and the /// equal-months start on the 1st (D-37) added since, 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(), convention: new.convention, postings: Default::default(), }, ); Ok(id) } /// Whether a schedule's month was ever posted (and so, if it isn't /// posted now, a reversal un-posted it): posting it again needs a /// journal an older version refuses (store feature `month_reposting`). pub fn schedule_month_posted_before(&self, id: ScheduleId, month_end: NaiveDate) -> bool { self.schedules .get(&id) .and_then(|s| s.postings.get(&month_end)) .is_some_and(|p| !p.is_empty()) } /// The latest date after a schedule month's end that one of its /// postings was reversed (or re-instated) on, if any: posting the month /// again on its own date would have the books carry it twice from then /// until that date, so it is left for a human /// ([`Self::preview_schedule_month`], [`Self::schedule_month_hold`]). /// /// ```text /// GOAL: a schedule month is never recognized twice at a month end. /// SOURCE: docs/spec/rules.md B-9 (a correction is dated when it is /// made), B-16; P3 depreciation review R3 (a reversed month is /// due again: when the reversal is the month's own). /// ``` pub fn schedule_month_reversed_after( &self, id: ScheduleId, month_end: NaiveDate, ) -> Option { self.schedules .get(&id)? .postings .get(&month_end)? .iter() .flat_map(|&e| { self.reversal_chain(e) .into_iter() .skip_while(move |&c| c != e) .skip(1) }) .filter_map(|c| self.entries.get(&c).map(JournalEntry::date)) .filter(|d| *d > month_end) .max() } /// Why a run of schedules leaves a due month for a human, if it must: /// it is dated in a closed period (which takes no new entries), or its /// posting was reversed after the month ended /// ([`Self::schedule_month_reversed_after`]). pub fn schedule_month_hold(&self, id: ScheduleId, month_end: NaiveDate) -> Option { if let Some(p) = self.closed_period_containing(month_end) { return Some(format!( "{month_end} is in closed period {p}: book it in an open one by hand, or not at all" )); } self.schedule_month_reversed_after(id, month_end).map(|r| { format!( "its posting was reversed on {r}, after the month ended: posting it again on {month_end} would recognize the month twice until then; book it by hand in an open period, or not at all" ) }) } /// 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) = s .plan()? .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}" ))); } if let Some(r) = self.schedule_month_reversed_after(id, date) { return Err(LedgerError::InvalidSchedule(format!( "schedule {id}'s {date} posting was reversed on {r}, after its month ended: posting it again on {date} would recognize the month twice until then (book it by hand in an open period, or not at all)" ))); } 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)?; let s = self.schedules.get_mut(&id).expect("previewed"); s.posted.insert(date); s.postings.entry(date).or_default().push(entry); Ok(entry) } /// Register a recurring entry. Returns its id. A template naming a /// receivables or payables account is refused (UntaggedControlLine, /// the control guard: plan P4b.3), since every draft it made would be. pub fn register_recurring(&mut self, new: NewRecurring) -> Result { schedules::validate_recurring(&new, &self.chart)?; if let Some((i, (account, ..))) = new .lines .iter() .enumerate() .find(|(_, (a, ..))| self.control_role_of(*a).is_some()) { return Err(LedgerError::UntaggedControlLine { account: self.account_label(*account), at: format!("line {i} of the recurring template"), }); } self.insert_recurring(new) } /// [`Self::register_recurring`] with the checks every journaled /// `recurring.registered` passed when it was written /// (schedules::validate_recurring_journaled), for replay only /// (rules.md B-28). Never for new work. pub fn replay_register_recurring( &mut self, new: NewRecurring, ) -> Result { schedules::validate_recurring_journaled(&new, &self.chart)?; self.insert_recurring(new) } /// Put a checked recurring entry in under the next id. fn insert_recurring(&mut self, new: NewRecurring) -> Result { 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 { // The template's lines as journaled: no size bound (rules.md // B-28); a draft too large to post is refused at its post. if let Err(e) = self.add_line_journaled(entry, account, side, money, false) { 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), ("projects", self.projects == other.projects), ( "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), ("instruments", self.instruments == other.instruments), ( "next_instrument_id", self.next_instrument_id == other.next_instrument_id, ), ( "entry_instruments", self.entry_instruments == other.entry_instruments, ), ("line_docs", self.line_docs == other.line_docs), ( "payment_events", self.payment_events == other.payment_events, ), ("roles", self.roles == other.roles), ("asset_tags", self.asset_tags == other.asset_tags), ("asset_runs", self.asset_runs == other.asset_runs), ("parties", self.parties == other.parties), ("next_party_id", self.next_party_id == other.next_party_id), ("entry_parties", self.entry_parties == other.entry_parties), ( "entry_no_party", self.entry_no_party == other.entry_no_party, ), ("settings", self.settings == other.settings), ("conventions", self.conventions == other.conventions), ] .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 and a name no /// sibling has. /// SOURCE: rules.md B-4 (the five elements); R-4; DECISION (owner, /// 2026-10-13): a child has its parent's type; rules.md B-31 /// (names: QuickBooks matches accounts by name, /// docs/spec/sources/intuit-iif.md; no full TIN in what every /// report prints, B-26; DECISION, owner, 2026-10-20). /// 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. /// 4. IF the name is blank: SHOULD refuse with EmptyAccountName. /// 5. IF an account under the same parent (or, for a top-level /// account, another top-level one) has the name, ignoring /// capitals and surrounding space: SHOULD refuse with /// DuplicateAccountName. /// 6. IF the name holds a run of nine digits (a full SSN, EIN or /// account number): SHOULD refuse with AccountNameHoldsTin. /// 7. IF the name, trimmed, is longer than MAX_ACCOUNT_NAME /// characters, or holds a line break, tab or other control /// character: SHOULD refuse with InvalidAccountName /// BECAUSE every report, export and the IIF print it on one line. /// 8. The name is kept trimmed, as a rename keeps it. /// ``` /// /// Replay of journals written before these rules uses /// [`Self::replay_add_account`]; the accounts the engine creates for a /// control role use [`Self::add_control_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); } let name = name.trim(); if name.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, }); } if self.chart.find_by_code(code).is_some() { return Err(LedgerError::DuplicateAccountCode(code.to_string())); } self.check_account_name(None, name, parent)?; self.replay_add_account(code, name, account_type, parent, placeholder) } /// Create the account a control role posts to, at its code with its /// name (invoices, bills, payroll and closes create them when first /// needed). The engine's own names: never refused for a sibling's /// name, and never for a missing tax category (rules.md B-32), so an /// invoice is never refused over a chart label. pub fn add_control_account(&mut self, c: &ControlAccount) -> Result { self.replay_add_account(c.code, c.name, c.account_type, None, false) } /// The name rules for a new name (a new account's, or a rename's), /// given trimmed and not blank: one line of at most /// [`crate::account::MAX_ACCOUNT_NAME`] characters, no run of nine /// digits, and none of `account`'s siblings under `parent` has it, /// ignoring capitals and surrounding space ([`Self::add_account`] /// SCENARIOS 5-7). `account` is the one being renamed, never its own /// sibling. fn check_account_name( &self, account: Option, name: &str, parent: Option, ) -> Result<(), LedgerError> { let max = crate::account::MAX_ACCOUNT_NAME; if name.chars().count() > max { return Err(LedgerError::InvalidAccountName(format!( "is longer than {max} characters: every report prints it on one line" ))); } if name.chars().any(char::is_control) { return Err(LedgerError::InvalidAccountName( "holds a line break, tab or other control character: every report prints it on one line".into(), )); } if crate::parties::has_full_tin(name) { return Err(LedgerError::AccountNameHoldsTin); } let key = crate::parties::name_key(name); let mut siblings: Vec<&Account> = self .chart .all() .filter(|a| a.parent == parent && Some(a.id) != account) .filter(|a| crate::parties::name_key(&a.name) == key) .collect(); siblings.sort_by_key(|a| a.id); match siblings.first() { Some(a) => Err(LedgerError::DuplicateAccountName { name: name.trim().to_string(), sibling: format!("{} {}", a.code, a.name), }), None => Ok(()), } } /// [`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 (owner, 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) } /// Give an account a new name: `name`, trimmed. /// /// ```text /// GOAL: let the human rename an account without touching its history. /// SOURCE: rules.md B-31; DECISION (owner, plan/served.md 4.9 and P3 /// "Settled in design", 2026-10-20); rules.md B-8 (posted is /// forever: entries name accounts by id, so a name is a label). /// INVARIANTS: /// 1. Only the name changes: the account's id, code, type, parent, /// placeholder flag, tax category, and every entry posted to it /// stay as they were, so no entry, balance or statement amount /// changes; every report, past periods included, prints the new /// name. /// 2. A refusal MUST leave the books unchanged. /// 3. The rules are the command's: replay takes any journaled name /// for an account that exists (Self::replay_rename_account, /// rules.md B-28). /// SCENARIOS: /// 1. IF the account is unknown: SHOULD refuse with UnknownAccount. /// 2. IF the name is blank: SHOULD refuse with EmptyAccountName. /// 3. IF the name, trimmed, is the one it has: SHOULD refuse with /// AccountNameUnchanged (nothing to journal). /// 4. IF another account under the same parent has it, ignoring /// capitals and surrounding space: SHOULD refuse with /// DuplicateAccountName. /// 5. IF it holds a run of nine digits: SHOULD refuse with /// AccountNameHoldsTin. /// 6. IF it is longer than MAX_ACCOUNT_NAME characters or holds a /// control character: SHOULD refuse with InvalidAccountName (the /// rules a new account's name meets, Self::add_account). /// 7. Approval rules, roles, settings and closed periods never /// block it: they name accounts by code or id, and a name is no /// posting. /// ``` pub fn rename_account(&mut self, account: AccountId, name: &str) -> Result<(), LedgerError> { self.check_rename_account(account, name)?; self.chart.set_name(account, name.trim()) } /// The validation behind [`Self::rename_account`], for stores that /// journal before mutating. pub fn check_rename_account(&self, account: AccountId, name: &str) -> Result<(), LedgerError> { let a = self .chart .get(account) .ok_or(LedgerError::UnknownAccount(account))?; let name = name.trim(); if name.is_empty() { return Err(LedgerError::EmptyAccountName); } if name == a.name { return Err(LedgerError::AccountNameUnchanged { code: a.code.clone(), name: a.name.clone(), }); } self.check_account_name(Some(account), name, a.parent) } /// Replay a journaled rename: the account must exist; the name is /// taken as journaled, whatever later versions refuse in a new one /// (rules.md B-28). pub fn replay_rename_account( &mut self, account: AccountId, name: &str, ) -> Result<(), LedgerError> { self.chart.set_name(account, name) } /// Check a rename's reason before it is journaled: one to /// [`crate::settings::MAX_LONG_TEXT`] characters, line breaks and tabs /// allowed but no other control character, and no full TIN (rules.md /// B-26). Returns it trimmed. pub fn check_rename_reason(reason: &str) -> Result { let r = crate::settings::long_text("reason", reason, crate::settings::MAX_LONG_TEXT) .map_err(LedgerError::InvalidRenameReason)?; if crate::parties::has_full_tin(&r) { return Err(LedgerError::InvalidRenameReason( "reason holds nine digits in a row, which read as a full SSN or EIN: write only the last four".into(), )); } Ok(r) } /// Whether `policy.require_tax_category` lets an account be created /// with `category` (rules.md B-32): under `refuse`, a revenue or /// expense account that isn't a header needs one. The command's check /// only, never replay's (rules.md B-28); the accounts the engine /// creates for a control role never meet it. pub fn check_tax_category_required( &self, code: &str, name: &str, account_type: AccountType, placeholder: bool, category: Option<&str>, ) -> Result<(), LedgerError> { if self.settings.require_tax_category() == crate::settings::TaxCategoryPolicy::Refuse && crate::tax::needs_category(account_type, placeholder) && category.is_none() { return Err(LedgerError::TaxCategoryRequired { account: format!("{} {}", code.trim(), name.trim()), clearing: false, }); } Ok(()) } /// Whether `policy.require_tax_category` lets `account`'s category be /// set to `category` (rules.md B-32): under `refuse`, an account that /// needs one ([`crate::tax::account_needs_category`]) keeps one. The /// command's check only, never replay's. pub fn check_tax_category_kept( &self, account: AccountId, category: Option<&str>, ) -> Result<(), LedgerError> { let a = self .chart .get(account) .ok_or(LedgerError::UnknownAccount(account))?; if self.settings.require_tax_category() == crate::settings::TaxCategoryPolicy::Refuse && category.is_none() && crate::tax::account_needs_category(a) { return Err(LedgerError::TaxCategoryRequired { account: format!("{} {}", a.code, a.name), clearing: a.tax_category.is_some(), }); } Ok(()) } /// 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())); } // An approval rule names accounts by code: renumbering one it names // would leave the rule covering nothing (or, later, another // account). It is revised first (crate::conventions INVARIANT 3). if let Some((rule, _)) = self .conventions .approval_rules() .find(|(_, r)| r.as_ref().is_none_or(|r| r.accounts.contains(&a.code))) { return Err(LedgerError::InvalidConvention(format!( "approval rule {} names account {}: retire it (books.retire_convention) or revise it without that account (books.set_convention), renumber, then set the rule again with the new code", rule.id, a.code ))); } if let Some(c) = control_accounts().find(|c| c.code == code && !self.roles.contains_key(c.key)) && (a.account_type != c.account_type || a.placeholder) { return Err(LedgerError::ControlAccountMismatch { 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 (owner, 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. /// - For receivables and payables: DocumentRoleHoldsBalance /// instead, which names no hand entry (the control guard /// refuses one, B-38): their balance moves with its documents, /// a later command (plan P4b S7). Only on their control account /// (Self::control_role_of): the account at the default code, /// before any document posted there, is the chart's own, and /// its balance no document's. /// 5. IF the role already resolves to that account: SHOULD accept /// (the assignment is recorded). /// 6. IF the role is receivables or payables and the account it /// would move to holds a posted line that is no document's: /// - SHOULD refuse (RoleAccountHasHandLines), naming the entry /// - NEVER make a hand line a control account's /// BECAUSE no command adds an untagged control line (B-38 /// INVARIANT 1). Replay never refuses (B-28). /// An account the role moves off keeps its documents' lines, and /// stays guarded while it holds them (Self::control_role_of). /// ``` 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 documents = c.key == RECEIVABLES.key || c.key == PAYABLES.key; // SCENARIO 6: the new account's lines must all be documents'. if documents && !crate::parties::is_replaying() && let Some(a) = target && now != Some(a.id) && let Some(entry) = self.posted_entries().find_map(|e| { e.lines() .iter() .enumerate() .any(|(i, l)| l.account == a.id && !self.line_docs.contains_key(&(e.id(), i))) .then_some(e.id()) }) { return Err(LedgerError::RoleAccountHasHandLines { role: c.role.to_string(), code: a.code.clone(), name: a.name.clone(), entry, }); } let balance_sheet = !matches!(c.account_type, AccountType::Revenue | AccountType::Expense); // A document role's balance is its documents' only on its control // account (Self::control_role_of): the account at its default // code before any document posted there is a chart's own. let controls = |current: AccountId| { !documents || self .control_role_of(current) .is_some_and(|r| r.key == c.key) }; if let Some(current) = now && balance_sheet && now != target.map(|a| a.id) && controls(current) { 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"); if documents { return Err(LedgerError::DocumentRoleHoldsBalance { role: c.role.to_string(), code: a.code.clone(), name: a.name.clone(), balance, }); } 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: /// 0. IF the date is outside the calendar the books keep /// (crate::calendar): SHOULD refuse (DateOutOfRange). /// 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 { crate::calendar::check(date)?; 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.add_line_journaled(entry, account, side, amount, true) } /// [`Self::add_line`]'s checks: the account, the minor unit, and (when /// `bounded`, for new work) the size. A draft line larger than any /// posting could carry is refused when it is added, not only at the /// post (P0 review round 3, S1); a recurring entry's draft is generated /// with the checks its template passed when it was journaled /// (`bounded` false), so a journaled `recurring.generated` always /// replays (rules.md B-28). fn add_line_journaled( &mut self, entry: EntryId, account: AccountId, side: Side, amount: Money, bounded: bool, ) -> Result<(), LedgerError> { self.check_line_account(account)?; self.check_hand_line(entry, account)?; check_minor_units(amount)?; if bounded { check_line_size(amount.amount)?; } self.replay_add_line(entry, account, side, amount) } /// The document role an account is the control account of, if any: /// receivables or payables (rules.md B-23, B-38; a later control role /// joins them). An account is one when the role is assigned to it /// ([`Self::assign_role`]), or when it holds a line of one of the /// role's documents (the role's default code, once a document has /// posted there; an account the role has moved off, whose documents' /// lines stay on it). /// /// ```text /// GOAL: guard exactly the accounts whose balance is documents, and /// no other: a chart's own account at a role's default code /// (1200 Equipment, a card at 2000) is anyone's until a document /// posts there. /// SOURCE: docs/spec/rules.md B-23, B-38 INVARIANT 1. /// INVARIANTS: /// 1. Some(role) when the role is assigned to the account, or the /// account holds a line tagged with one of the role's documents /// (an invoice's for receivables, a bill's for payables). /// 2. None for every other account, the default code's included. /// ``` pub fn control_role_of(&self, account: AccountId) -> Option<&'static ControlAccount> { [(&RECEIVABLES, true), (&PAYABLES, false)] .into_iter() .find(|&(c, invoices)| { self.roles.get(c.key) == Some(&account) || self.doc_accounts.contains(&(account, invoices)) }) .map(|(c, _)| c) } /// The accounts that are a document role's control accounts /// ([`Self::control_role_of`]), in account order. fn document_role_accounts(&self, c: &ControlAccount) -> Vec { let invoices = c.key == RECEIVABLES.key; let mut out: Vec = self .doc_accounts .iter() .filter(|&&(_, i)| i == invoices) .map(|&(a, _)| a) .collect(); if let Some(&a) = self.roles.get(c.key) && !out.contains(&a) { out.push(a); out.sort_unstable(); } out } /// Refuse a line by hand on a control account (the control guard, /// plan P4b.3): such a line is the document commands' alone, so the /// account always equals its documents. Replay never refuses (B-28). fn check_hand_line(&self, entry: EntryId, account: AccountId) -> Result<(), LedgerError> { if crate::parties::is_replaying() || self.control_role_of(account).is_none() { return Ok(()); } let line = self.entries.get(&entry).map_or(0, |e| e.lines().len()); Err(LedgerError::UntaggedControlLine { account: self.account_label(account), at: format!("line {line} of entry {entry}"), }) } /// An account as a refusal names it: `1200 "Accounts Receivable"`. fn account_label(&self, account: AccountId) -> String { self.chart.get(account).map_or_else( || account.to_string(), |a| format!("{} \"{}\"", a.code, a.name), ) } /// A document command's line on a control account: [`Self::add_line`]'s /// checks but the control guard's, then tagged with its document in /// the same step (so the guard at the post finds it tagged). fn add_tagged_line( &mut self, entry: EntryId, account: AccountId, side: Side, amount: Money, tag: arap::DocTag, ) -> Result<(), LedgerError> { self.check_line_account(account)?; check_minor_units(amount)?; check_line_size(amount.amount)?; self.replay_add_line(entry, account, side, amount)?; let idx = self.entries[&entry].lines().len() - 1; self.tag_line(entry, idx, tag); Ok(()) } /// Tag a ledger line with its document, keeping the indexes read from /// the tags (`doc_index`, `doc_accounts`) in step. The only way a tag /// is written; tags are never removed (a refused command's are rolled /// back with the rest of the ledger by [`Self::atomically`]). fn tag_line(&mut self, entry: EntryId, idx: usize, tag: arap::DocTag) { if let Some(l) = self.entries.get(&entry).and_then(|e| e.lines().get(idx)) { self.doc_accounts .insert((l.account, matches!(tag.doc, arap::DocRef::Invoice(_)))); } self.doc_index .entry(tag.doc) .or_default() .insert((entry, idx)); self.line_docs.insert((entry, idx), tag); } /// The control guard at the post (plan P4b.3): every line of `entry` on /// a receivables or payables account carries its document, but the /// mirror of an untagged line in a reversal that takes it off the /// books (reversing an old hand line is how it is cleaned up). /// Replay never refuses (B-28). /// /// ```text /// GOAL: no command adds a line on a control account that is no /// document's; one only takes a legacy one away. /// SOURCE: docs/spec/rules.md B-38 INVARIANT 1 and item 3 (the /// exemption). /// INVARIANTS: /// 1. A tagged line passes. /// 2. An untagged line passes only as the mirror of an untagged line /// on the same account in the entry it reverses, when that entry /// stands at an even place in its reversal chain (the hand line /// itself, or a reversal of its reversal): the mirror removes the /// line. Reversing the hand line's reversal would put it back, and /// is refused. /// ``` fn check_control_lines(&self, entry: &JournalEntry) -> Result<(), LedgerError> { if crate::parties::is_replaying() { return Ok(()); } for (i, l) in entry.lines().iter().enumerate() { if self.control_role_of(l.account).is_none() || self.line_docs.contains_key(&(entry.id(), i)) { continue; } let removes_untagged = entry.reversal_of().is_some_and(|o| { self.entries .get(&o) .and_then(|o| o.lines().get(i)) .is_some_and(|ol| ol.account == l.account) && !self.line_docs.contains_key(&(o, i)) && self .reversal_chain(o) .iter() .position(|&e| e == o) .is_some_and(|at| at % 2 == 0) }); if !removes_untagged { return Err(LedgerError::UntaggedControlLine { account: self.account_label(l.account), at: format!("line {i} of entry {}", entry.id()), }); } } Ok(()) } /// A line's account must exist and take postings, checked when the /// line is added (owner, 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 or /// the size bound added in P0, for replaying journals written before /// them (rules.md B-28): a draft line replays as journaled, and its post /// refuses it if it is out of range. 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 (owner, 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)?; self.check_hand_line(entry, 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, owner 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)); } check_line_size(base_amount)?; 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 of any /// size: the bound is the command's, rules.md B-28) 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). /// 6. IF a line is on a receivables or payables account (the role's /// account now) and carries no document: /// - SHOULD refuse (UntaggedControlLine), naming the document /// tools /// - NEVER refuse the mirror of an untagged line in a reversal /// BECAUSE a control account must equal its documents (B-23, /// B-38): only the document commands post there, and reversing /// an old hand line is how it is cleaned up. Replay never /// refuses (B-28). /// ``` 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)?; } if e.status() == EntryStatus::Draft { self.check_control_lines(e)?; } } 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_pos.insert(entry, self.order.len()); self.order.push(entry); if let Some(o) = snapshot.reversal_of() { self.reversed_by.entry(o).or_insert(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, /// owner 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 /// (owner, 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 (owner, 2026-10-13). /// 4. IF `date` falls in a closed period: /// - SHOULD refuse with `ClosedPeriod` /// BECAUSE closed periods take no new postings. /// 5. IF the entry reverses a schedule month's posting (so reversing /// it re-instates that posting) and the month has been posted /// again since: /// - SHOULD refuse (InvalidSchedule), naming the entry that posts /// it now /// - NEVER recognize one month of a schedule twice /// BECAUSE a reversed month is due again and may have been run /// again (P3 depreciation review R3). /// 6. IF the entry carries a document's lines (an invoice's or a /// bill's own entry, a payment, or a reversal of one) and `date` /// is before the entry's own date: /// - SHOULD refuse (DocumentReversalBeforeOriginal) /// BECAUSE a correction comes after what it corrects (B-9; plan /// P4b.3, "Dating rules"). /// 7. IF reversing it would leave a document it carries owing less /// than nothing or more than its total at some date (a void /// under a payment that stands; re-instating a payment beside /// another), where it didn't before: /// - SHOULD refuse (DocumentBounds), naming the entries to /// reverse first /// - NEVER refuse reversing a payment, which only reopens its /// document /// BECAUSE 0 <= outstanding <= total at every date (B-38 /// INVARIANT 2). Replay never refuses (B-28). /// ``` 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.reversed_by.get(&entry) { return Err(LedgerError::EntryAlreadyReversed(entry, by)); } 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())); } // SCENARIO 5. `entry` ends its chain (it isn't reversed), so // reversing it flips whether the chain's original stands. if let Some(top) = self.schedule_chain_top(entry) && !self.in_effect(top) && let Some((s, month)) = self.schedule_month_of(top) && let Some(now) = s.postings[&month] .iter() .find(|&&e| e != top && self.in_effect(e)) { return Err(LedgerError::InvalidSchedule(format!( "reversing entry {entry} would post schedule {}'s {month} a second time: entry {now} posts it now (reverse that one instead)", s.id ))); } if !crate::parties::is_replaying() { self.check_document_reversal(original, date)?; } Ok(()) } /// [`Self::check_reversal`] SCENARIOS 6 and 7: a reversal of an entry /// carrying documents' lines. fn check_document_reversal( &self, original: &JournalEntry, date: NaiveDate, ) -> Result<(), LedgerError> { let mut moves: Vec<(arap::DocRef, Decimal)> = Vec::new(); for (i, l) in original.lines().iter().enumerate() { if let Some(tag) = self.line_docs.get(&(original.id(), i)) { // The mirror moves the document by the opposite. let by = -tag.doc.counted(l.side, l.amount.amount); match moves.iter_mut().find(|(d, _)| *d == tag.doc) { Some((_, m)) => *m += by, None => moves.push((tag.doc, by)), } } } let Some(&(first, _)) = moves.first() else { return Ok(()); }; if date < original.date() { return Err(LedgerError::DocumentReversalBeforeOriginal { entry: original.id(), document: first.to_string(), original: original.date(), date, }); } for (doc, by) in moves { self.check_item_bounds(doc, &[(date, by)])?; } Ok(()) } /// The original an entry's reversal chain starts from (the entry itself /// when it reverses nothing), when some schedule month was ever posted: /// books without one skip the chain walk. fn schedule_chain_top(&self, entry: EntryId) -> Option { if self.schedules.values().all(|s| s.postings.is_empty()) { return None; } Some(self.reversal_chain(entry).first().copied().unwrap_or(entry)) } /// Whether a posted entry's effect stands: nothing reverses it, or what /// reverses it is itself reversed, and so on (an even number of /// reversals after it in its chain). A draft or an unknown id stands /// for nothing. /// /// ```text /// SOURCE: docs/spec/rules.md B-9 (a mistake is corrected by an /// opposite entry), B-10 (reversed at most once; reversing the /// reversal re-instates it). /// ``` pub fn in_effect(&self, entry: EntryId) -> bool { self.stands_on(entry, NaiveDate::MAX) } /// Whether a posted entry's effect stands as of a day: it is dated on /// or before `as_of`, and of the reversals after it in its chain, an /// even number are dated on or before `as_of` (none, or one reversed /// again, and so on). A draft or an unknown id stands for nothing. /// /// ```text /// GOAL: say whether an entry counts on a given day, so a document's /// status (a void) and its aging are right as of any date. /// SOURCE: docs/spec/rules.md B-9, B-10, B-38; plan/served.md P4b.3 /// ("Standing as of a date"). /// INVARIANTS: /// 1. stands_on(e, NaiveDate::MAX) is in_effect(e). /// 2. NEVER true for an entry dated after `as_of`, a draft, or an /// unknown id. /// 3. A reversal dated after `as_of` doesn't count against it, nor /// does any later link of its chain (a re-reversal an older /// journal dated before the reversal it undoes counts from that /// reversal's date). /// SCENARIOS: /// 1. IF a payment is reversed on the 25th: SHOULD stand as of the /// 22nd, and not as of the 31st. /// ``` pub fn stands_on(&self, entry: EntryId, as_of: NaiveDate) -> bool { if self .entries .get(&entry) .is_none_or(|e| e.status() != EntryStatus::Posted || e.date() > as_of) { return false; } let chain = self.reversal_chain(entry); let Some(i) = chain.iter().position(|&e| e == entry) else { return false; }; // A reversal counts only once what it reverses counts: an older // journal may date a re-reversal before the reversal it undoes, // and it can't undo what hasn't happened yet. chain[i + 1..] .iter() .take_while(|r| self.entries.get(r).is_some_and(|r| r.date() <= as_of)) .count() .is_multiple_of(2) } /// The schedule and month an entry posted, if it posted one (a /// schedule month's posting; not its reversal). pub fn schedule_posting(&self, entry: EntryId) -> Option<(ScheduleId, NaiveDate)> { self.schedule_month_of(entry).map(|(s, m)| (s.id, m)) } /// The schedule and month an entry posted, if it posted one. fn schedule_month_of(&self, entry: EntryId) -> Option<(&AmortizationSchedule, NaiveDate)> { self.schedules.values().find_map(|s| { s.postings .iter() .find(|(_, es)| es.contains(&entry)) .map(|(m, _)| (s, *m)) }) } /// 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)) } /// [`Self::reverse_entry`] as a journal recorded it: without the /// checks later versions added for documents (a reversal's date, a /// document's bounds, the control guard), for replaying journals /// written before them (rules.md B-28). Never for new work. pub fn replay_reverse_entry( &mut self, entry: EntryId, date: NaiveDate, description: &str, ) -> Result { crate::parties::replaying(|| self.reverse_entry(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() { // The mirror of each line, whatever its account: the control // guard at the post below exempts the mirror of an untagged // control line, and a tagged one carries its tag. self.check_line_account(line.account)?; self.replay_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, and the document so // it moves by exactly the opposite (B-38 INVARIANT 3). if line.class_id.is_some() { self.set_line_class(id, idx, line.class_id)?; } if let Some(tag) = self.line_docs.get(&(entry, idx)).copied() { self.tag_line(id, idx, tag); } } 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 tags. if let Some(v) = self.entry_vendor(entry) { self.entry_vendors.insert(id, v); } // Paid the same way: its 1099 buckets net to zero too. if let Some(i) = self.entry_instrument(entry) { self.entry_instruments.insert(id, i); } // Every party and share: the reversal undoes each one's part. if let Some(p) = self.entry_parties.get(&entry).cloned() { self.entry_parties.insert(id, p); } // An entry with no counterparty is reversed with none either // (set_entry_no_party INVARIANT 1). if let Some(why) = self.entry_no_party.get(&entry).cloned() { self.entry_no_party.insert(id, why); } // A reversed schedule month is un-posted, so it is due again; a // reversed reversal posts it back (P3 depreciation review R3). if let Some(top) = self.schedule_chain_top(entry) && let Some((sid, month)) = self.schedule_month_of(top).map(|(s, m)| (s.id, m)) { let posted = self.schedules[&sid].postings[&month] .iter() .any(|&e| self.in_effect(e)); let s = self.schedules.get_mut(&sid).expect("found"); if posted { s.posted.insert(month); } else { s.posted.remove(&month); } } self.refresh_docs_of(id); 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. /// [`Self::validate_new_asset_journaled`]; the in-service date and the /// life's last day of depreciation inside the calendar the books keep /// (crate::calendar): a life whose last day is after 9999-12-31 is /// refused like one past chrono's last day (P0 review round 3, S1; P3: /// the bound is the last day held, so a life ending on 9999-12-31 is /// taken); and the cost and salvage in the currency's minor unit /// (rules.md B-3: every depreciation and disposal line derives from /// them, and a line finer than a cent could never post). pub fn validate_new_asset(&self, new: &NewAsset) -> Result<(), LedgerError> { self.check_new_asset(new, true) } /// The checks every journaled `asset.registered` and `asset.corrected` /// passed when it was written (the rule through 2026-10-16): /// [`Self::validate_new_asset`] without the calendar bound or the minor /// unit, the end of the life only representable. Replay applies /// exactly these (rules.md B-28). pub fn validate_new_asset_journaled(&self, new: &NewAsset) -> Result<(), LedgerError> { self.check_new_asset(new, false) } /// The checks of [`Self::validate_new_asset`] (`strict`) or /// [`Self::validate_new_asset_journaled`], in one order. fn check_new_asset(&self, new: &NewAsset, strict: bool) -> 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)); } if strict { check_minor_units(new.cost)?; check_minor_units(new.salvage_value)?; } // 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. New ones // keep the in-service date and the life's last day (the day before // its exclusive end) inside the calendar the books keep, so every // month of the life is a month the books can post. if strict { crate::calendar::check(new.in_service_date)?; } let end = new .in_service_date .checked_add_months(Months::new(new.useful_life_months)); if !end .is_some_and(|end| !strict || end.pred_opt().is_some_and(crate::calendar::in_calendar)) { 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)?; self.insert_asset(new) } /// [`Self::register_asset`] with the checks every journaled /// `asset.registered` passed when it was written /// ([`Self::validate_new_asset_journaled`]), for replay only (rules.md /// B-28). Never for new work. pub fn replay_register_asset(&mut self, new: NewAsset) -> Result { self.validate_new_asset_journaled(&new)?; self.insert_asset(new) } /// Put a checked asset on the register under the next id. fn insert_asset(&mut self, new: NewAsset) -> Result { let id = self.next_asset_id; self.next_asset_id += 1; self.assets.insert( id, Asset { id, description: new.description, in_service_date: new.in_service_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, basis: 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 (owner, 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 (owner, 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. The entry's date is the /// asset's acquired date ([`Self::asset_acquired_date`]). /// /// ```text /// GOAL: from an asset, its source document and capital entry; from /// the entry, the asset; and the day the company acquired it. /// SOURCE: DECISION (owner, 2026-10-15); rules.md B-15 (P3, settled in /// design 2026-10-20: an asset's acquired date comes from the /// entry it is linked to). /// INVARIANTS: /// 1. The linked entry is posted, is not a reversal, has not been /// reversed, and debits the asset's cost account. /// 2. The linked entry is dated on or before the asset's in-service /// date, and on or before its disposal: the register then holds /// the asset exactly while the books carry its cost (B-24). /// 3. A refusal MUST leave the books unchanged. /// SCENARIOS: /// 1. IF the asset or entry is unknown, or the entry fails 1 or 2: /// 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(()) } /// [`Self::set_asset_acquisition`] with the checks every journaled /// `asset.acquisition_linked` passed when it was written (INVARIANT 1, /// without the dates of INVARIANT 2), for replay only (rules.md B-28). pub fn replay_set_asset_acquisition( &mut self, id: AssetId, entry: EntryId, ) -> Result<(), LedgerError> { self.check_asset_acquisition_journaled(id, entry)?; self.assets.get_mut(&id).expect("checked").acquired_by = Some(entry); Ok(()) } /// Restore a link recorded earlier (a projection read-back): it was /// checked when made, and a later correction (reversing that entry) /// doesn't undo it; it only becomes a link to fix, by linking the /// replacement. Checks only that the asset and entry exist. (Re-checking /// against today's books refused to open them once a linked entry was /// reversed, 2026-10-15.) pub fn restore_asset_acquisition( &mut self, id: AssetId, entry: EntryId, ) -> Result<(), LedgerError> { if !self.entries.contains_key(&entry) { return Err(LedgerError::UnknownEntry(entry)); } self.assets .get_mut(&id) .ok_or(LedgerError::UnknownAsset(id))? .acquired_by = Some(entry); Ok(()) } /// Whether an asset's acquisition link points at an entry that has /// since been reversed (to relink the replacement). pub fn acquisition_reversed(&self, id: AssetId) -> bool { self.assets .get(&id) .and_then(|a| a.acquired_by) .is_some_and(|e| self.entries.values().any(|x| x.reversal_of() == Some(e))) } /// The day the company acquired an asset: its acquisition entry's date, /// or its in-service date when no entry is linked (every asset /// registered before P3 without a link), never later than the /// in-service date. `None` for an unknown asset. /// /// ```text /// GOAL: the register holds an asset from the day the books carry its /// cost, which can be before it serves (B-24). /// SOURCE: rules.md B-15, B-24; P3, settled in design (2026-10-20): /// "an asset's acquired date comes from the entry it is linked /// to". /// INVARIANTS: /// 1. acquired = min(in_service_date, the linked entry's date); the /// in-service date with no link. One source of truth: no stored /// date to disagree with the entry. /// ``` pub fn asset_acquired_date(&self, id: AssetId) -> Option { let a = self.assets.get(&id)?; Some( a.acquired_by .and_then(|e| self.entries.get(&e)) .map_or(a.in_service_date, |e| e.date().min(a.in_service_date)), ) } /// The validation behind [`Self::set_asset_acquisition`]: INVARIANTS 1 /// and 2. pub fn check_asset_acquisition(&self, id: AssetId, entry: EntryId) -> Result<(), LedgerError> { self.check_asset_acquisition_journaled(id, entry)?; let asset = self.assets.get(&id).expect("checked"); let date = self.entries.get(&entry).expect("checked").date(); let why = if date > asset.in_service_date { Some(format!( "it is dated {date}, after the asset's in-service date {} (an asset is acquired before it serves: correct the in-service date with books.correct_asset, or the entry)", asset.in_service_date )) } else { match &asset.status { AssetStatus::Disposed { date: gone, .. } if date > *gone => Some(format!( "it is dated {date}, after the asset's disposal on {gone}" )), _ => None, } }; match why { Some(why) => Err(LedgerError::AcquisitionEntryMismatch { asset: id, entry, why, }), None => Ok(()), } } /// INVARIANT 1 of [`Self::set_asset_acquisition`]: the checks every /// journaled `asset.acquisition_linked` passed. fn check_asset_acquisition_journaled( &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 (owner, 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(()) } /// Record how the company got an asset and the facts its tax figures /// follow from ([`assets::AssetBasis`]): the rule, and for a member's /// contribution the member's adjusted basis, the fair market value at /// contribution, the §704(c) method if chosen, and the source. Book /// figures don't change. /// /// ```text /// GOAL: the register carries the tax figures beside book value, each /// named for what it is, so book and tax never get confused and /// the CPA sees both. /// SOURCE: docs/spec/rules.md B-25; docs/spec/sources/ /// irs-partnership-basis.md; DECISION D-41 (owner, 2026-10-20). /// INVARIANTS: /// 1. The record passes AssetBasis::check: a purchase takes no /// contribution facts; a contribution needs the adjusted basis and /// the value at contribution, each non-negative, in cents, at /// most one quadrillion. /// 2. Only facts are kept: the figures are derived when read /// (Asset::basis_figures), under the rule in force. /// 3. A refusal MUST leave the books unchanged. /// SCENARIOS: /// 1. IF the asset is unknown, or 1 fails: SHOULD refuse with /// UnknownAsset or InvalidTaxBasis. /// 2. IF set again: the new record replaces the old (the journal /// keeps both). /// ``` pub fn set_asset_basis( &mut self, id: AssetId, basis: assets::AssetBasis, ) -> Result<(), LedgerError> { self.check_asset_basis(id, &basis)?; self.assets.get_mut(&id).expect("checked").basis = Some(basis); Ok(()) } /// The validation behind [`Self::set_asset_basis`]. pub fn check_asset_basis( &self, id: AssetId, basis: &assets::AssetBasis, ) -> Result<(), LedgerError> { if !self.assets.contains_key(&id) { return Err(LedgerError::UnknownAsset(id)); } basis.check().map_err(LedgerError::InvalidTaxBasis) } /// [`Self::set_asset_basis`] with the checks every journaled /// `asset.basis_set` passed (AssetBasis::check_journaled: the facts fit /// the rule, none negative, no bound on size), for replay and the /// projection's read-back only (rules.md B-28). The figures the line /// carries are for its readers: replay never recomputes them, so a /// later change to how a figure is derived never refuses books an /// earlier version wrote. Never for new work. pub fn replay_set_asset_basis( &mut self, id: AssetId, basis: assets::AssetBasis, ) -> Result<(), LedgerError> { let asset = self .assets .get_mut(&id) .ok_or(LedgerError::UnknownAsset(id))?; basis .check_journaled() .map_err(LedgerError::InvalidTaxBasis)?; asset.basis = Some(basis); Ok(()) } /// Replay a pre-P3 `asset.tax_basis_set` (rule and contributor basis, /// no value at contribution, which reads as the book cost): the checks /// it passed when written (AssetBasis::check_legacy_journaled), for /// replay and the projection's read-back only (rules.md B-28). Never for /// new work. pub fn replay_set_asset_tax_basis( &mut self, id: AssetId, rule: assets::BasisRule, contributor_basis: Option, ) -> Result<(), LedgerError> { let asset = self .assets .get_mut(&id) .ok_or(LedgerError::UnknownAsset(id))?; let basis = assets::AssetBasis { rule, contributor_adjusted_basis: contributor_basis, fmv_at_contribution: None, sec704c_method: None, source: None, }; basis .check_legacy_journaled() .map_err(LedgerError::InvalidTaxBasis)?; asset.basis = Some(basis); Ok(()) } /// Correct a registered asset's economics (cost, salvage, useful life, /// in-service date) when it was registered wrong. Its accounts, tag, /// serial, links, and basis record stay. /// /// ```text /// GOAL: fix a registration mistake without re-registering (which would /// burn its tag, since tags are never reused). /// SOURCE: DECISION (owner, 2026-10-15). /// INVARIANTS: /// 1. The corrected figures pass every check a new registration does /// (validate_new_asset). /// 2. Only an active asset is corrected; a store also refuses once any /// depreciation for it is posted (the posted entries would disagree /// with the schedule; correct with entries instead). /// 3. The in-service date is never before the linked acquisition /// entry's date (set_asset_acquisition INVARIANT 2). /// 4. A contribution's value at contribution and built-in gain stay /// as recorded: they were fixed when it was contributed (Treas. /// Reg. §1.704-3(a)(3)(ii)), whatever the book cost becomes. /// 5. A refusal MUST leave the books unchanged. /// ``` pub fn correct_asset( &mut self, id: AssetId, fix: assets::AssetCorrection, ) -> Result<(), LedgerError> { self.check_correct_asset(id, &fix)?; self.apply_asset_correction(id, fix); Ok(()) } /// [`Self::correct_asset`] with the checks every journaled /// `asset.corrected` passed when it was written /// ([`Self::validate_new_asset_journaled`]), for replay only (rules.md /// B-28). Never for new work. pub fn replay_correct_asset( &mut self, id: AssetId, fix: assets::AssetCorrection, ) -> Result<(), LedgerError> { let corrected = self.corrected_asset(id, &fix)?; self.validate_new_asset_journaled(&corrected)?; self.apply_asset_correction(id, fix); Ok(()) } /// Write checked corrected figures onto the asset. fn apply_asset_correction(&mut self, id: AssetId, fix: assets::AssetCorrection) { let a = self.assets.get_mut(&id).expect("checked"); a.cost = fix.cost; a.salvage_value = fix.salvage_value; a.useful_life_months = fix.useful_life_months; a.in_service_date = fix.in_service_date; } /// The validation behind [`Self::correct_asset`] (INVARIANTS 1-3). pub fn check_correct_asset( &self, id: AssetId, fix: &assets::AssetCorrection, ) -> Result<(), LedgerError> { self.validate_new_asset(&self.corrected_asset(id, fix)?)?; if let Some(linked) = self .assets .get(&id) .and_then(|a| a.acquired_by) .and_then(|e| self.entries.get(&e)) .map(|e| e.date()) && fix.in_service_date < linked { return Err(LedgerError::InServiceBeforeAcquisition { asset: id, in_service: fix.in_service_date.to_string(), acquired: linked.to_string(), }); } Ok(()) } /// The active asset `id` as `fix` would leave it, as a registration to /// check. fn corrected_asset( &self, id: AssetId, fix: &assets::AssetCorrection, ) -> Result { let a = self.assets.get(&id).ok_or(LedgerError::UnknownAsset(id))?; if a.status != AssetStatus::Active { return Err(LedgerError::AssetAlreadyDisposed(id)); } Ok(NewAsset { description: a.description.clone(), in_service_date: fix.in_service_date, cost: fix.cost, salvage_value: fix.salvage_value, useful_life_months: fix.useful_life_months, cost_account: a.cost_account, accum_depr_account: a.accum_depr_account, expense_account: a.expense_account, }) } /// 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") } /// Whether a depreciation run can be recorded, for a run a store /// journals now: the asset is known; the period is a month, "YYYY-MM"; /// the amount is positive and in cents; and the entry is posted and /// credits the asset's accumulated depreciation account by exactly the /// amount. /// /// ```text /// GOAL: a run the books record is the entry that posted it, no more /// and no less, so accumulated_posted is what the books carry. /// SOURCE: rules.md B-15 rules 6 and 8, B-24; B-28 (new commands are bounded; replay checks /// only what every journaled run passed: /// record_depreciation_run). /// ``` pub fn check_depreciation_run( &self, asset: AssetId, period: &str, entry: EntryId, amount: Decimal, ) -> Result<(), LedgerError> { let a = self .assets .get(&asset) .ok_or(LedgerError::UnknownAsset(asset))?; let bad = |why: String| LedgerError::InvalidDepreciationRun { asset, why }; let month_ok = period.len() == 7 && period.as_bytes()[4] == b'-' && NaiveDate::parse_from_str(&format!("{period}-01"), "%Y-%m-%d").is_ok(); if !month_ok { return Err(bad(format!("period {period:?} isn't a month (YYYY-MM)"))); } if amount <= Decimal::ZERO || amount.normalize().scale() > 2 { return Err(bad(format!( "amount {amount} isn't a positive number of cents" ))); } let e = self .entries .get(&entry) .ok_or(LedgerError::UnknownEntry(entry))?; if e.status() != crate::EntryStatus::Posted { return Err(bad(format!("entry {entry} isn't posted"))); } let credited = kernel::net( e.lines() .iter() .filter(|l| l.account == a.accum_depr_account) .map(|l| (kernel::flip(l.side), l.amount.amount)), ) .ok_or_else(|| bad("its entry's lines are too large".into()))?; if credited != amount { return Err(bad(format!( "entry {entry} credits accumulated depreciation {credited}, not {amount}" ))); } Ok(()) } /// Record a depreciation run (`asset.depreciation_posted`). Checks only /// that the asset and the entry exist: every run an earlier version /// journaled replays, whatever its period or amount (rules.md B-28; a /// store checks new runs first, [`Self::check_depreciation_run`]). A /// second line for the same asset, period and entry (a journal edited /// by hand; a store never writes one) is the run already recorded: one /// entry is carried once, as the projection keys it (P3 review, /// 2026-10-21: replay counted it twice, the reload once). pub fn record_depreciation_run( &mut self, asset: AssetId, period: &str, entry: EntryId, amount: Decimal, ) -> Result<(), LedgerError> { if !self.assets.contains_key(&asset) { return Err(LedgerError::UnknownAsset(asset)); } if !self.entries.contains_key(&entry) { return Err(LedgerError::UnknownEntry(entry)); } let runs = self.asset_runs.entry(asset).or_default(); if runs.iter().any(|r| r.entry == entry && r.period == period) { return Ok(()); } runs.push(assets::DepreciationPosting { period: period.to_string(), entry, amount, }); Ok(()) } /// An asset's depreciation runs, in the order recorded. pub fn depreciation_runs(&self, asset: AssetId) -> &[assets::DepreciationPosting] { self.asset_runs.get(&asset).map_or(&[], Vec::as_slice) } /// Each posted reversal's original: original entry → its reversal (an /// entry is reversed at most once, B-10). Read once for many runs /// ([`assets::depreciation_effects`], [`Self::accumulated_posted_all`]), /// never per run: the viewer asks it of every asset. pub(crate) fn posted_reversals(&self) -> HashMap { self.posted_entries() .filter_map(|e| e.reversal_of().map(|orig| (orig, e.id()))) .collect() } /// An asset's runs as the catch-up rule reads them: (entry, amount), /// in the order recorded ([`assets::depreciation_effects`]). pub fn depreciation_run_entries(&self, asset: AssetId) -> Vec<(EntryId, Decimal)> { self.depreciation_runs(asset) .iter() .map(|r| (r.entry, r.amount)) .collect() } /// What the books carry for an asset's depreciation, entry by entry, /// each in its own month: every run and every reversal of one /// ([`assets::depreciation_effects`] of its runs). pub fn depreciation_effects_of(&self, asset: AssetId) -> Vec { assets::depreciation_effects(self, &self.depreciation_run_entries(asset)) } /// Accumulated depreciation the books carry for an asset on `as_of`: /// what its runs carry through that day, entry by entry /// ([`assets::depreciation_effects`]' reading, by day); zero once it /// is disposed (the disposal entry cleared it). /// /// ```text /// GOAL: book value is cost less what the books carry, never the /// schedule's figure. /// SOURCE: rules.md B-24 (the register reconciles to the general /// ledger); B-8, B-9, B-10 (a reversal undoes what it reverses, /// and reversing the reversal reinstates it); B-15 (what the /// catch-up rule reads, the same entries). /// INVARIANTS: /// 1. Disposed on or before as_of: 0. /// 2. Otherwise Σ, over the asset's runs and every entry of each /// one's reversal chain the books carry (assets::run_chain_entries: /// the run +amount, its reversal −amount, that reversal's /// reversal +amount, ...), of those dated on or before as_of. So /// it moves exactly as the trial balance does, even for a /// reversal dated before its run, and agrees with /// depreciation_effects summed through any month's end. /// 3. NEVER panics: a sum past exact arithmetic is refused /// (TooLargeToCompute). /// ``` pub fn accumulated_posted( &self, id: AssetId, as_of: NaiveDate, ) -> Result { self.accumulated_posted_with(id, as_of, &self.posted_reversals()) } /// [`Self::accumulated_posted`] for every registered asset, the /// reversal links read once (for the register and the viewer). pub fn accumulated_posted_all( &self, as_of: NaiveDate, ) -> Result, LedgerError> { let reversals = self.posted_reversals(); self.assets .keys() .map(|&id| Ok((id, self.accumulated_posted_with(id, as_of, &reversals)?))) .collect() } fn accumulated_posted_with( &self, id: AssetId, as_of: NaiveDate, reversals: &HashMap, ) -> Result { let asset = self.assets.get(&id).ok_or(LedgerError::UnknownAsset(id))?; if matches!(asset.status, AssetStatus::Disposed { date, .. } if date <= as_of) { return Ok(Decimal::ZERO); } self.carried_between(id, NaiveDate::MIN, as_of, reversals) } /// Depreciation the books posted for an asset through entries dated in /// `[from, to]` (its runs and their reversals, as /// [`Self::accumulated_posted`] counts them, the disposal aside): the /// period's depreciation expense from its runs. pub fn depreciation_posted_between( &self, id: AssetId, from: NaiveDate, to: NaiveDate, ) -> Result { if !self.assets.contains_key(&id) { return Err(LedgerError::UnknownAsset(id)); } self.carried_between(id, from, to, &self.posted_reversals()) } /// What the entries of the asset's runs' chains dated in `[from, to]` /// carry, summed exactly. fn carried_between( &self, id: AssetId, from: NaiveDate, to: NaiveDate, reversals: &HashMap, ) -> Result { let too_large = || LedgerError::TooLargeToCompute(format!("asset {id}'s posted depreciation")); assets::run_chain_entries(self, &self.depreciation_run_entries(id), reversals) .into_iter() .filter(|(d, _)| *d >= from && *d <= to) .try_fold(Decimal::ZERO, |t, (_, a)| t.checked_add(a)) .ok_or_else(too_large) } /// The run the books carry for an asset's month, if any: the first one /// recorded for `period` whose entry is in effect ([`Self::in_effect`]: /// posted, and an even number of posted reversals after it). A month /// whose run was reversed has none, so it can be run again; one whose /// reversal was itself reversed has its run back. At most one run of a /// month is in effect: a store refuses re-instating a run whose month /// was run again. pub fn live_depreciation_run( &self, id: AssetId, period: &str, ) -> Option<&assets::DepreciationPosting> { self.depreciation_runs(id) .iter() .find(|run| run.period == period && self.in_effect(run.entry)) } /// 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> { self.check_dispose_asset(id, date, proceeds, true)?; self.assets.get_mut(&id).expect("asset exists").status = AssetStatus::Disposed { date, proceeds }; Ok(()) } /// [`Self::dispose_asset`] with the checks every journaled /// `asset.disposed` passed when it was written (no bound on the /// proceeds or the date beyond the disposal's own), for replay only /// (rules.md B-28). Never for new work. pub fn replay_dispose_asset( &mut self, id: AssetId, date: NaiveDate, proceeds: Money, ) -> Result<(), LedgerError> { self.check_dispose_asset(id, date, proceeds, false)?; self.assets.get_mut(&id).expect("asset exists").status = AssetStatus::Disposed { date, proceeds }; Ok(()) } /// The checks of [`Self::dispose_asset`] (`strict`) or /// [`Self::replay_dispose_asset`]. fn check_dispose_asset( &self, id: AssetId, date: NaiveDate, proceeds: Money, strict: bool, ) -> 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))?; // Activeness and the proceeds' sign; no depreciation is computed, // so a journaled disposal replays even where the figure can't be // (rules.md B-28). assets::check_disposal(asset, proceeds)?; if strict { // Never before the company acquired it. A command's bound, not // replay's: the acquired date needs the link, which a // projection read-back restores after the disposal (rules.md // B-28). let acquired = self.asset_acquired_date(id).expect("known asset"); if date < acquired { return Err(LedgerError::DisposalBeforeAcquisition { date: date.to_string(), acquired: acquired.to_string(), }); } // Proceeds are a line of the disposal entry, and its date one // the books keep (P0 review round 3, S1). if !kernel::Amount::in_line_range(proceeds.amount) { return Err(LedgerError::AmountOutOfRange); } crate::calendar::check(date)?; } 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 (owner, 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 or the calendar bound added in P0 (only the order is /// checked, periods::validate_period_order), for replaying journals /// written before them (rules.md B-28). pub fn replay_create_period( &mut self, name: &str, start: NaiveDate, end: NaiveDate, ) -> Result { periods::validate_period_order(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, members_closed: 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() } /// Whether `entry` is a period's closing entry ([`Self::closing_entry_ids`] /// without building the set: O(periods)). pub fn is_closing_entry(&self, entry: EntryId) -> bool { self.periods .values() .any(|p| p.closing_entry == Some(entry)) } /// 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 /// everything closed to these members and shares so far, to the /// currency's minor unit (odd units by largest remainder, ties to /// the member listed first), less what earlier closes gave them /// (rules.md B-13; D-10), and their own distribution account. The /// count starts at the first members close, or at the first since /// the members or shares last changed (`Ledger::members_regime`); /// closes to anything else are skipped. A close that posts an /// entry may carry a one-time true-up of rounding by closes made /// under an earlier rule (the journaled `MembersRun`, /// `MembersPerClose` and `MembersLastAbsorbs`, which replay as /// they were made). /// 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 currency's minor unit, by largest /// remainder, cumulatively). /// 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. /// 6. After a members close that posts, each member's total from the /// count's closes MUST be one largest-remainder split of everything /// they closed: under one minor unit from their exact share. A /// members close that posts nothing credits no one (any true-up /// waits for the next close that posts). /// 7. NEVER changes a posted close: earlier closes replay under the /// rule they were journaled with. /// 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). /// 6. IF a journaled MembersCumulative names a start other than the /// one the books derive: SHOULD refuse (InvalidMembers) /// BECAUSE replay would split differently from the journal. /// 7. IF 50/50 members closed −287.53 in January (A −143.77, B /// −143.76), February closed to retained earnings, and March closes /// −287.53 to the members: SHOULD give A −143.76, B −143.77, so /// each has −287.53 (the count skips February's close). /// ``` 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); let close_seq = self.next_close_seq(); // (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 members_closed = None; 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); } } _ => { let members = to.members().expect("every other target is members"); // The sweeps first: whether an entry posts decides whether // the parts may carry a true-up. Their lines still follow // the parts in the entry. for m in members { if let Some(d) = m.distributions { sweep(d, m.capital, self); } } let posts = !closings.is_empty() || !sweep_lines.is_empty(); let (parts, rule) = self.members_split(to, net, posts, close_seq)?; for (m, part) in members.iter().zip(&parts) { equity.push((m.capital, *part)); } members_closed = Some(periods::MembersClosed { members: member_key(members), net, parts, rule, }); } } 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 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::MembersRun(members) => Some(periods::MemberCloseRecord { members: member_key(members), net, }), _ => None, }; p.members_closed = members_closed; Ok(closing_entry) } /// The number the next close takes ([`Period::close_seq`]): one more /// than the closes so far. fn next_close_seq(&self) -> u64 { self.periods .values() .filter(|p| p.status == PeriodStatus::Closed) .count() as u64 + 1 } /// What a members close (`to`, closing `net`; `posts` when its entry /// posts; numbered `close_seq`) credits each member, in `to`'s order, /// and the rule it is made under: the rule journaled for replayed /// closes, else the cumulative rule (rules.md B-13): each member's /// split of the count's running total, to the base currency's minor /// unit, less what the count's earlier closes gave them. A close that /// posts nothing credits nothing, so a true-up of earlier rounding /// rides only on an entry that posts (INVARIANT 5 stays true). fn members_split( &self, to: &periods::CloseTo, net: Decimal, posts: bool, close_seq: u64, ) -> Result<(Vec, periods::MemberRule), LedgerError> { use periods::{CloseTo, MemberRule}; let members = to.members().expect("a members close"); let shares: Vec = members.iter().map(|m| m.share).collect(); Ok(match to { CloseTo::MembersLastAbsorbs(_) => ( periods::allocate_last_absorbs(net, &shares), MemberRule::LastAbsorbs, ), CloseTo::MembersPerClose(_) => (periods::allocate(net, &shares), MemberRule::PerClose), CloseTo::MembersRun(_) => { // The run rule: 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); let parts = after.iter().zip(&prior).map(|(a, p)| a - p).collect(); (parts, MemberRule::Run) } _ => { let key = member_key(members); let regime = self.members_regime(&key, None); let since_seq = regime.since_seq.unwrap_or(close_seq); let parts = if posts { regime.allocate( &key, regime.closed + net, ®ime.given, self.base_currency.allocation_places(), )? } else { vec![Decimal::ZERO; members.len()] }; (parts, MemberRule::Cumulative { since_seq }) } }) } /// The members regime a close with `members` ((capital account, share) /// pairs) belongs to (rules.md B-13), counting only closes whose /// period ends on or before `through` (all when `None`): walking the /// closes newest first by [`Period::close_seq`], members closes under /// any rule with the same SET of (capital, share) pairs (order and /// distribution accounts ignored; 0.5 is 0.50), skipping closes to /// anything else (they allocate nothing to members), and stopping at /// the first members close with another set. A change of members or /// shares starts a new count (26 U.S.C. §706(d)(1); Treas. Reg. /// §1.706-4(a)(3)(vii): each segment its own distributive share /// period); income closed under the old shares is never re-split. /// /// The date filter keeps a prefix of the closes in close order because /// period ends rise with close order: a close is refused while a later /// period is closed (CloseOutOfOrder) or when its end lies in a closed /// period (`preview_close_period_to`), pinned by /// `period_ends_rise_with_close_order`. /// /// As of a day no count with `members` holds yet, the earnings not /// closed by then go to the count the next members close begins, when /// it is these members': its first close's order is the regime's /// [`Regime::order`], so their tied units go as they will once it /// counts them, and a reorder of the members in the settings since /// moves no cent on those days either. pub(crate) fn members_regime( &self, members: &[(AccountId, Decimal)], through: Option, ) -> Regime { let mut regime = self.regime_where(members, |p| through.is_none_or(|d| p.end <= d)); if let Some(day) = through && regime.order.is_empty() { let next = self .periods .values() .filter(|p| p.status == PeriodStatus::Closed && p.end > day) .filter_map(|p| Some((p.close_seq, p.members_closed.as_ref()?))) .min_by_key(|&(seq, _)| seq); if let Some((_, m)) = next && same_member_set(&m.members, members) { regime.order.clone_from(&m.members); } } regime } /// [`Self::members_regime`] over the closed periods `include` keeps. fn regime_where( &self, members: &[(AccountId, Decimal)], include: impl Fn(&Period) -> bool, ) -> Regime { let mut closed: Vec<&Period> = self .periods .values() .filter(|p| p.status == PeriodStatus::Closed && include(p)) .collect(); closed.sort_by_key(|p| std::cmp::Reverse(p.close_seq)); let mut regime = Regime { since_seq: None, closed: Decimal::ZERO, given: vec![Decimal::ZERO; members.len()], order: Vec::new(), }; for p in closed { let Some(m) = &p.members_closed else { continue; }; if !same_member_set(&m.members, members) { break; } regime.since_seq = p.close_seq; regime.order.clone_from(&m.members); regime.closed += m.net; for ((capital, _), part) in m.members.iter().zip(&m.parts) { if let Some(i) = members.iter().position(|(c, _)| c == capital) { regime.given[i] += *part; } } } regime } /// A closed members period's split under the cumulative rule /// (books.close_period's `members`): the close its count starts at, /// and per member, in the close's order, what this close credited /// them, their running total from the count's closes through it, and /// their exact share of everything the count closed through it. `None` /// for a period not closed to members, or closed under an earlier /// rule. pub fn member_close_split(&self, id: PeriodId) -> Option<(u64, Vec)> { let p = self.periods.get(&id)?; let m = p.members_closed.as_ref()?; let periods::MemberRule::Cumulative { since_seq } = m.rule else { return None; }; let seq = p.close_seq?; let regime = self.regime_where(&m.members, |q| q.close_seq <= Some(seq)); let split = m .members .iter() .zip(&m.parts) .zip(®ime.given) .map( |((&(capital, share), &part), &running)| periods::MemberSplit { capital, share, part, running, exact: share * regime.closed, }, ) .collect(); Some((since_seq, split)) } /// 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(RETAINED_EARNINGS.code) { Some(a) => a.id, None => self.add_control_account(&RETAINED_EARNINGS)?, }) } /// 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); } _ => { let members = to.members().expect("every other target is members"); if let periods::CloseTo::MembersCumulative { since_seq: 0, .. } = to { return Err(LedgerError::InvalidMembers( "a count of members closes starts at close 1 or later, not 0".into(), )); } 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 (for members: their split computes exactly, and a /// journaled start is the one the books derive) — 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, net) = self.income_closings(period.end); let sweeps: Vec = match to { periods::CloseTo::RetainedEarnings { sweep } | periods::CloseTo::Account { sweep, .. } => sweep.clone(), _ => to .members() .expect("every other target is 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()); if to.members().is_some() { // The journaled start must be the derived one, and the split // must be computable: a close this refuses journals nothing. let (_, rule) = self.members_split(to, net, any, self.next_close_seq())?; if let ( periods::CloseTo::MembersCumulative { since_seq, .. }, periods::MemberRule::Cumulative { since_seq: derived }, ) = (to, rule) && *since_seq != derived { return Err(LedgerError::InvalidMembers(format!( "this close was journaled counting the members' closes from close {since_seq}, but the books count them from close {derived}" ))); } } 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); } // Its party takes this name, so the name obeys party rules (no // full TIN; parties INVARIANT 1). crate::parties::check_text("name", name).map_err(LedgerError::InvalidParty)?; let id = self.next_customer_id; self.next_customer_id += 1; self.customers.insert( id, Customer { id, name: name.to_string(), }, ); self.link_record_party(name, false, id); Ok(id) } /// Add a vendor. Returns its id. /// /// ```text /// GOAL: a vendor record, linked to its party in the directory. /// SOURCE: docs/parties-and-attachments.md; crate::shares INVARIANT 5 /// (at most one party on an entry has a vendor record); /// Self::set_entry_parties INVARIANT 2 (the vendor tag follows /// the one party with a vendor record). /// INVARIANTS: /// 1. The record links to the live party of that name without one, /// else a new party (Self::link_record_party). /// 2. IF that party is named on an entry beside another party with a /// vendor record: SHOULD refuse, changing nothing (the entry would /// name two vendors). Record each vendor's part as its own entry /// first. /// 3. Every entry naming that party with no vendor tag is tagged with /// the new vendor, as set_entry_parties would have tagged it. /// ``` pub fn add_vendor(&mut self, name: &str) -> Result { if name.trim().is_empty() { return Err(LedgerError::EmptyAssetDescription); } // Its party takes this name, so the name obeys party rules (no // full TIN; parties INVARIANT 1). crate::parties::check_text("name", name).map_err(LedgerError::InvalidParty)?; if let Some(p) = self.record_party_match(name, true) { let mut ids: Vec = self.entry_parties.keys().copied().collect(); ids.sort_unstable(); if let Some(e) = ids.into_iter().find(|e| { let named = self.entry_parties(*e); named.iter().any(|s| s.party == p) && named .iter() .any(|s| s.party != p && self.parties[&s.party].vendor.is_some()) }) { return Err(LedgerError::InvalidParty(format!( "party {p} ({}) is named on entry {e} beside another party with a vendor record; a 1099 counts a payment toward one vendor, so record each vendor's part as its own entry before adding this vendor", self.parties[&p].name ))); } } let id = self.next_vendor_id; self.next_vendor_id += 1; self.vendors.insert( id, Vendor { id, name: name.to_string(), decisions_1099: Vec::new(), tin_on_file: false, }, ); self.link_record_party(name, true, id); // INVARIANT 3: the tag follows the party's new vendor record. if let Some(p) = self.party_of_vendor(id) { let untagged: Vec = self .entry_parties .iter() .filter(|(e, named)| { named.iter().any(|s| s.party == p) && !self.entry_vendors.contains_key(e) }) .map(|(e, _)| *e) .collect(); for e in untagged { self.entry_vendors.insert(e, id); } } 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) } /// Check a 1099 decision for a vendor before it is recorded /// ([`crate::form1099::check_new_decision`]): the vendor exists, the /// decision, its source and its date are sound, and a dated decision /// records a change. Returns what recording it would do. pub fn check_vendor_1099( &self, id: VendorId, new: &crate::form1099::Dated1099, ) -> Result { let v = self .vendors .get(&id) .ok_or(LedgerError::UnknownVendor(id))?; crate::form1099::check_new_decision(&v.decisions_1099, new) .map_err(LedgerError::Invalid1099) } /// Record a vendor's 1099 decision from the date it takes effect (or /// from the beginning), with who decided, and whether its W-9 is on /// file, checked ([`Self::check_vendor_1099`]). /// /// ```text /// GOAL: "no 1099" is a decision kept with its reason and who made it, /// never the same as "never decided" (plan defects 18, 23), and /// each payment takes the decision in force on its date (D-40). /// SOURCE: docs/spec/rules.md B-19; crate::form1099::plan_decision. /// SCENARIOS: /// 1. IF the vendor is unknown, or the decision fails its checks: /// SHOULD refuse, changing nothing. /// 2. Otherwise SHOULD record it (crate::form1099::apply_decision) /// and set the W-9 flag, returning what it did. /// ``` pub fn set_vendor_1099( &mut self, id: VendorId, new: crate::form1099::Dated1099, tin_on_file: bool, ) -> Result { self.check_vendor_1099(id, &new)?; self.replay_vendor_1099(id, new, tin_on_file) } /// [`Self::set_vendor_1099`] as journaled: the vendor must exist, and /// the decision is recorded as the line says, without the checks a /// new decision passes (rules.md B-28). A line written before /// decisions were kept replays as a decision from the beginning with /// no source (a null as undetermined: plan 5.5). pub fn replay_vendor_1099( &mut self, id: VendorId, new: crate::form1099::Dated1099, tin_on_file: bool, ) -> Result { let v = self .vendors .get_mut(&id) .ok_or(LedgerError::UnknownVendor(id))?; let applied = crate::form1099::apply_decision(&mut v.decisions_1099, new); v.tin_on_file = tin_on_file; Ok(applied) } /// Mark whether a vendor's W-9 is on file, keeping its 1099 decisions /// and who made them (P3 review R8). Returns whether it changed. pub fn set_vendor_w9_flag(&mut self, id: VendorId, on_file: bool) -> Result { let v = self .vendors .get_mut(&id) .ok_or(LedgerError::UnknownVendor(id))?; let changed = v.tin_on_file != on_file; v.tin_on_file = on_file; Ok(changed) } // ------------------------------------------------------------------ // Payment methods (crate::instruments) // ------------------------------------------------------------------ /// The id the next payment method gets. pub fn next_instrument_id(&self) -> crate::InstrumentId { self.next_instrument_id } /// Check a new payment method (crate::instruments INVARIANTS 1-4). /// Returns its label, trimmed. pub fn check_add_instrument( &self, label: &str, last4: Option<&str>, owner_party: Option, account: AccountId, ) -> Result { let label = crate::instruments::check_label(label).map_err(LedgerError::InvalidInstrument)?; let key = crate::instruments::label_key(&label); if let Some(i) = self .instruments .values() .find(|i| crate::instruments::label_key(&i.label) == key) { return Err(LedgerError::InvalidInstrument(format!( "payment method {} is already named {:?}: give this one another name", i.id, i.label ))); } if let Some(l) = last4 { crate::instruments::check_last4(l).map_err(LedgerError::InvalidInstrument)?; } if let Some(p) = owner_party && !self.parties.contains_key(&p) { return Err(LedgerError::UnknownParty(p)); } let a = self .chart .get(account) .ok_or(LedgerError::UnknownAccount(account))?; if a.placeholder { return Err(LedgerError::PlaceholderAccount(a.code.clone())); } if !matches!(a.account_type, AccountType::Asset | AccountType::Liability) { return Err(LedgerError::InvalidInstrument(format!( "account {} {:?} is {:?}: a payment method pays through an asset (a bank account) or a liability (a card, or what's owed to the members)", a.code, a.name, a.account_type ))); } let control = [&crate::PAYABLES, &crate::RECEIVABLES] .into_iter() .find(|c| self.role_account(c) == Some(account)); if let Some(c) = control { return Err(LedgerError::InvalidInstrument(format!( "account {} is the {} control account, which no payment is made from: name the bank, card, or members' account the money left", a.code, c.role ))); } Ok(label) } /// Add a payment method ([`Self::check_add_instrument`]). Returns its /// id. pub fn add_instrument( &mut self, kind: crate::InstrumentKind, label: &str, last4: Option<&str>, owner_party: Option, account: AccountId, ) -> Result { let label = self.check_add_instrument(label, last4, owner_party, account)?; let id = self.next_instrument_id; self.next_instrument_id += 1; self.instruments.insert( id, crate::Instrument { id, kind, label, last4: last4.map(str::to_string), owner_party, account, }, ); Ok(id) } /// Every payment method, in id order. pub fn instruments(&self) -> impl Iterator + '_ { self.instruments.values() } /// One payment method. pub fn get_instrument(&self, id: crate::InstrumentId) -> Option<&crate::Instrument> { self.instruments.get(&id) } /// The liability accounts that are company cards: the account of each /// business-card payment method that is a liability (a credit card's /// balance; a debit card draws on an asset and isn't one). A member's /// card pays through the members' financing account, and a platform /// through its own balance: neither is a card account. For whatever /// needs to tell a card from other liabilities (party money, the cash /// flow's sections), from what the books record rather than an /// account's name. pub fn card_accounts(&self) -> std::collections::BTreeSet { self.instruments .values() .filter(|i| i.kind == crate::InstrumentKind::BusinessCard) .filter(|i| { self.chart .get(i.account) .is_some_and(|a| a.account_type == AccountType::Liability) }) .map(|i| i.account) .collect() } /// The payment method with this label (ignoring case and surrounding /// space). pub fn instrument_by_label(&self, label: &str) -> Option<&crate::Instrument> { let key = crate::instruments::label_key(label); self.instruments .values() .find(|i| crate::instruments::label_key(&i.label) == key) } /// The payment method an entry is tagged with. pub fn entry_instrument(&self, entry: EntryId) -> Option { self.entry_instruments.get(&entry).copied() } /// Tag an entry (draft or posted) with the payment method it was paid /// with, or clear the tag. /// /// ```text /// GOAL: the 1099 worksheet knows how each payment was made. /// SOURCE: docs/spec/rules.md B-19; crate::instruments. /// SCENARIOS: /// 1. IF the entry or the payment method is unknown: SHOULD refuse. /// 2. Tagging MUST change no amount (it's metadata, allowed on posted /// entries, like the vendor tag). /// 3. The entry's reversal chain carries the same tag, so a payment /// and its reversal always sit in the same bucket and net to zero. /// ``` pub fn set_entry_instrument( &mut self, entry: EntryId, instrument: Option, ) -> Result<(), LedgerError> { if !self.entries.contains_key(&entry) { return Err(LedgerError::UnknownEntry(entry)); } if let Some(i) = instrument && !self.instruments.contains_key(&i) { return Err(LedgerError::UnknownInstrument(i)); } for e in self.reversal_chain(entry) { match instrument { Some(i) => self.entry_instruments.insert(e, i), None => self.entry_instruments.remove(&e), }; } 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; Self::set_entry_parties /// INVARIANTS 2 and 5 (the vendor tag follows the parties, never /// the other way round, on an entry naming several). /// SCENARIOS: /// 1. IF the entry or vendor is unknown: SHOULD refuse. /// 2. Tagging MUST change no amount (it's metadata, allowed on posted /// entries). /// 3. On an entry naming no party or one: Some(v) makes v's party the /// entry's one party (all of it) and clears a no-party mark /// (set_entry_parties INVARIANT 3: an entry never names a party /// and carries the mark at once); None clears the party too when /// it was the vendor's, and leaves no mark. /// 4. On an entry naming several parties, Some(v) with v's party /// among them sets the tag and keeps every party and share as /// they are. IF v's party is not among them (or v has none): /// SHOULD refuse, changing nothing: naming v alone would drop the /// others' shares and count all of it toward v's 1099. The list /// is Self::set_entry_parties's to change. /// 5. On an entry naming several parties, one of them the tagged /// vendor's party, None SHOULD refuse, changing nothing: the tag /// follows that party (set_entry_parties INVARIANT 2), so the /// 1099 would drop a payment the party still shows. Drop the party /// with Self::set_entry_parties instead. /// 6. The entry's reversal chain (an entry, its reversal, theirs) /// carries the same tags (set_entry_parties INVARIANT 5). /// ``` pub fn set_entry_vendor( &mut self, entry: EntryId, vendor: Option, ) -> Result<(), LedgerError> { self.check_set_entry_vendor(entry, vendor)?; for e in self.reversal_chain(entry) { self.tag_entry_vendor(e, vendor); } Ok(()) } /// The validation behind [`Self::set_entry_vendor`] (SCENARIOS 1, 4, /// 5). pub fn check_set_entry_vendor( &self, entry: EntryId, vendor: Option, ) -> Result<(), LedgerError> { if !self.entries.contains_key(&entry) { return Err(LedgerError::UnknownEntry(entry)); } let named = self.entry_parties(entry); match vendor { Some(v) => { if !self.vendors.contains_key(&v) { return Err(LedgerError::UnknownVendor(v)); } let p = self.party_of_vendor(v); if named.len() >= 2 && !named.iter().any(|s| Some(s.party) == p) { return Err(LedgerError::InvalidParty(format!( "entry {entry} names {} parties and vendor {v}'s party isn't one of them; tagging the vendor would drop their shares: name the parties with books.set_entry_party (parties) instead", named.len() ))); } } None => { if named.len() >= 2 && let Some(p) = self .entry_vendor(entry) .and_then(|v| self.party_of_vendor(v)) && named.iter().any(|s| s.party == p) { return Err(LedgerError::InvalidParty(format!( "entry {entry} names party {p}, whose vendor record is its vendor tag, among {} parties; the tag follows that party: drop it from the parties with books.set_entry_party (parties) instead", named.len() ))); } } } Ok(()) } /// One entry's half of [`Self::set_entry_vendor`], checked already. fn tag_entry_vendor(&mut self, entry: EntryId, vendor: Option) { match vendor { Some(v) => { self.entry_vendors.insert(entry, v); // Several parties: the list stays (SCENARIO 4). if self.entry_parties(entry).len() < 2 && let Some(p) = self.party_of_vendor(v) { self.entry_parties .insert(entry, vec![crate::PartyShare::whole(p)]); } if self.entry_parties.contains_key(&entry) { self.entry_no_party.remove(&entry); } } None => { // The party tag goes too only when it was the vendor's. if let Some(v) = self.entry_vendors.remove(&entry) && self.party_of_vendor(v).is_some() && self.entry_party(entry) == self.party_of_vendor(v) { self.entry_parties.remove(&entry); } } } } /// An entry's reversal chain, in order: the original it reverses (and /// so on up), itself, the entry reversing it (and so on down). An /// entry reversed by none and reversing none is its own chain. Each /// entry is reversed at most once ([`Self::check_reversal`]). pub fn reversal_chain(&self, entry: EntryId) -> Vec { let mut top = entry; while let Some(o) = self.entries.get(&top).and_then(JournalEntry::reversal_of) { if !self.entries.contains_key(&o) || o == entry { break; } top = o; } let mut chain = vec![top]; let mut cur = top; while let Some(&r) = self.reversed_by.get(&cur) { if chain.contains(&r) { break; } chain.push(r); cur = r; } chain } /// Tag an entry with a vendor and that vendor's party (bills, bill /// payments): one counterparty, both tags. fn tag_vendor(&mut self, entry: EntryId, vendor: VendorId) { self.entry_vendors.insert(entry, vendor); if let Some(p) = self.party_of_vendor(vendor) { self.entry_parties .insert(entry, vec![crate::PartyShare::whole(p)]); } } /// Tag an entry with a customer's party (invoices, invoice payments). fn tag_customer(&mut self, entry: EntryId, customer: CustomerId) { if let Some(p) = self.party_of_customer(customer) { self.entry_parties .insert(entry, vec![crate::PartyShare::whole(p)]); } } /// Link a new vendor or customer record to its party: the party not /// archived with that name (ignoring case and surrounding space) that /// has no such record yet, else a new party (named "Name (2)", "(3)", /// ... when a live party already has the name). Deterministic, so replay /// rebuilds the same directory from the same records. fn link_record_party(&mut self, name: &str, vendor: bool, record: u64) { let id = match self.record_party_match(name, vendor) { Some(id) => id, None => { let id = self.next_party_id; self.next_party_id += 1; // Names are unique among live parties (parties INVARIANT 3): // a second record of the same name gets "Name (2)", and so on. let base = name.trim(); let taken = |n: &str| { let k = crate::parties::name_key(n); self.parties .values() .any(|p| !p.archived && crate::parties::name_key(&p.name) == k) }; let mut party_name = base.to_string(); let mut n = 2; while taken(&party_name) { party_name = format!("{base} ({n})"); n += 1; } self.parties.insert( id, crate::Party { id, name: party_name, display_name: None, roles: std::collections::BTreeSet::new(), website: None, email: None, default_terms_as_customer: None, default_terms_as_vendor: None, address: None, notes: None, archived: false, w9: None, w4: None, tin: None, vendor: None, customer: None, creditor_of: std::collections::BTreeSet::new(), }, ); id } }; let p = self.parties.get_mut(&id).expect("found or made above"); if vendor { p.vendor = Some(record); } else { p.customer = Some(record); } } /// The existing party a new vendor (or customer) record named `name` /// links to ([`Self::link_record_party`]): the party not archived with /// that name and no such record yet. fn record_party_match(&self, name: &str, vendor: bool) -> Option { let key = crate::parties::name_key(name); let slot = |p: &crate::Party| if vendor { p.vendor } else { p.customer }; self.parties .values() .find(|p| !p.archived && crate::parties::name_key(&p.name) == key && slot(p).is_none()) .map(|p| p.id) } /// The party a vendor record belongs to. pub fn party_of_vendor(&self, vendor: VendorId) -> Option { self.parties .values() .find(|p| p.vendor == Some(vendor)) .map(|p| p.id) } /// The party a customer record belongs to. pub fn party_of_customer(&self, customer: CustomerId) -> Option { self.parties .values() .find(|p| p.customer == Some(customer)) .map(|p| p.id) } /// One party. pub fn get_party(&self, id: crate::PartyId) -> Option<&crate::Party> { self.parties.get(&id) } /// Every party, in id order. pub fn parties(&self) -> impl Iterator + '_ { self.parties.values() } /// The party (not archived) named `name`, ignoring case and surrounding /// space. pub fn party_by_name(&self, name: &str) -> Option<&crate::Party> { let key = crate::parties::name_key(name); self.parties .values() .find(|p| !p.archived && crate::parties::name_key(&p.name) == key) } /// The sole party an entry is with: Some only when it names exactly /// one ([`Self::entry_parties`] lists them all). pub fn entry_party(&self, entry: EntryId) -> Option { match self.entry_parties.get(&entry).map(Vec::as_slice) { Some([one]) => Some(one.party), _ => None, } } /// Every party an entry is with, each with their share, in the order /// named (empty when it names none). Resolve the shares to amounts with /// [`crate::shares::resolve`]. pub fn entry_parties(&self, entry: EntryId) -> &[crate::PartyShare] { self.entry_parties .get(&entry) .map(Vec::as_slice) .unwrap_or_default() } /// Whether an entry names `party`, alone or among others. pub fn entry_names_party(&self, entry: EntryId, party: crate::PartyId) -> bool { self.entry_parties(entry).iter().any(|s| s.party == party) } /// The next party id the directory will assign. pub fn next_party_id(&self) -> crate::PartyId { self.next_party_id } /// Add a party. /// /// ```text /// GOAL: a record for anyone the company deals with. /// SOURCE: crate::parties (INVARIANTS 1, 3); DECISION (owner, /// 2026-10-15). /// SCENARIOS: /// 1. IF the name is blank, holds a full TIN, or names a party not /// archived: SHOULD refuse, changing nothing. /// 2. IF a role is vendor or customer: SHOULD refuse (those follow /// their records; add_vendor / add_customer make them). /// ``` pub fn add_party( &mut self, name: &str, roles: &[crate::Role], ) -> Result { self.check_add_party(name, roles)?; let id = self.next_party_id; self.next_party_id += 1; self.parties.insert( id, crate::Party { id, name: name.trim().to_string(), display_name: None, roles: roles.iter().copied().collect(), website: None, email: None, default_terms_as_customer: None, default_terms_as_vendor: None, address: None, notes: None, archived: false, w9: None, w4: None, tin: None, vendor: None, customer: None, creditor_of: std::collections::BTreeSet::new(), }, ); Ok(id) } /// The validation behind [`Self::add_party`]. pub fn check_add_party(&self, name: &str, roles: &[crate::Role]) -> Result<(), LedgerError> { let bad = |why: String| Err(LedgerError::InvalidParty(why)); if name.trim().is_empty() { return bad("a party needs a name".into()); } crate::parties::check_text("name", name).map_err(LedgerError::InvalidParty)?; if self.party_by_name(name).is_some() { return bad(format!("a party named {:?} already exists", name.trim())); } if roles .iter() .any(|r| matches!(r, crate::Role::Vendor | crate::Role::Customer)) { return bad( "vendor and customer roles come with their records: add_vendor or add_customer" .into(), ); } Ok(()) } /// Change one of a party's facts. /// /// ```text /// SOURCE: crate::parties (INVARIANTS 1, 3). /// SCENARIOS: /// 1. IF the party is unknown, a text holds a full TIN or is too long, /// or a new name is blank or taken: SHOULD refuse, changing /// nothing. /// ``` pub fn update_party( &mut self, id: crate::PartyId, field: crate::PartyField, ) -> Result<(), LedgerError> { self.check_update_party(id, &field)?; let p = self.parties.get_mut(&id).expect("checked"); use crate::PartyField::*; match field { Name(n) => { // Its records carry the same name: aging, bills, and the // 1099 worksheet show what the party is called now. p.name = n.trim().to_string(); let (name, vendor, customer) = (p.name.clone(), p.vendor, p.customer); if let Some(v) = vendor.and_then(|v| self.vendors.get_mut(&v)) { v.name = name.clone(); } if let Some(c) = customer.and_then(|c| self.customers.get_mut(&c)) { c.name = name; } } DisplayName(v) => p.display_name = v, Website(v) => p.website = v, Address(v) => p.address = v, Notes(v) => p.notes = v, Archived(b) => p.archived = b, Email(v) => p.email = v, DefaultTermsAsCustomer(v) => p.default_terms_as_customer = v, DefaultTermsAsVendor(v) => p.default_terms_as_vendor = v, } Ok(()) } /// The validation behind [`Self::update_party`]. pub fn check_update_party( &self, id: crate::PartyId, field: &crate::PartyField, ) -> Result<(), LedgerError> { use crate::PartyField::*; let p = self.parties.get(&id).ok_or(LedgerError::UnknownParty(id))?; let text = |f: &str, v: &Option| match v { Some(t) => crate::parties::check_text(f, t).map_err(LedgerError::InvalidParty), None => Ok(()), }; match field { Name(n) => { if n.trim().is_empty() { return Err(LedgerError::InvalidParty("a party needs a name".into())); } crate::parties::check_text("name", n).map_err(LedgerError::InvalidParty)?; if let Some(other) = self.party_by_name(n) && other.id != id { return Err(LedgerError::InvalidParty(format!( "a party named {:?} already exists", n.trim() ))); } } DisplayName(v) => text("display_name", v)?, Website(v) => text("website", v)?, Address(v) => text("address", v)?, Notes(v) => text("notes", v)?, Archived(false) => { if let Some(other) = self.party_by_name(&p.name) && other.id != id { return Err(LedgerError::InvalidParty(format!( "another party is named {:?}; rename one first", p.name ))); } } Archived(true) => {} Email(v) => { if let Some(e) = v { crate::parties::check_email("email", e).map_err(LedgerError::InvalidParty)?; } } // Terms are typed and already canonical (`crate::Terms`), so // there is nothing here a string could get wrong. DefaultTermsAsCustomer(_) | DefaultTermsAsVendor(_) => {} } Ok(()) } /// Give a party a role, or take one away. /// /// ```text /// SOURCE: crate::parties (INVARIANT 2). /// SCENARIOS: /// 1. IF the role is vendor or customer: SHOULD refuse (they follow /// the records). /// 2. IF taking away a 1099 role leaves W-9 facts with no 1099 role, /// or taking away employee leaves W-4 facts: SHOULD refuse (clear /// the facts first). /// ``` pub fn set_party_role( &mut self, id: crate::PartyId, role: crate::Role, on: bool, ) -> Result<(), LedgerError> { self.check_set_party_role(id, role, on)?; let p = self.parties.get_mut(&id).expect("checked"); if on { p.roles.insert(role); } else { p.roles.remove(&role); } Ok(()) } /// The validation behind [`Self::set_party_role`]. pub fn check_set_party_role( &self, id: crate::PartyId, role: crate::Role, on: bool, ) -> Result<(), LedgerError> { let p = self.parties.get(&id).ok_or(LedgerError::UnknownParty(id))?; if matches!(role, crate::Role::Vendor | crate::Role::Customer) { return Err(LedgerError::InvalidParty( "vendor and customer roles follow their records".into(), )); } if !on { let mut left = p.roles.clone(); left.remove(&role); if p.w9.is_some() && !left.iter().any(|r| r.gets_1099()) { return Err(LedgerError::InvalidParty( "clear the W-9 facts before removing the last 1099 role".into(), )); } if p.w4.is_some() && role == crate::Role::Employee { return Err(LedgerError::InvalidParty( "clear the W-4 facts before removing the employee role".into(), )); } } Ok(()) } /// Record (or clear) a party's W-9 facts. /// /// ```text /// SOURCE: crate::parties (INVARIANTS 1, 2). /// SCENARIOS: /// 1. IF the party has no 1099 role (contractor, landlord), or the /// facts' form is wrong (W9Facts::check): SHOULD refuse. /// ``` pub fn set_party_w9( &mut self, id: crate::PartyId, facts: Option, ) -> Result<(), LedgerError> { self.check_set_party_w9(id, facts.as_ref())?; self.parties.get_mut(&id).expect("checked").w9 = facts; Ok(()) } /// The validation behind [`Self::set_party_w9`]. pub fn check_set_party_w9( &self, id: crate::PartyId, facts: Option<&crate::W9Facts>, ) -> Result<(), LedgerError> { let p = self.parties.get(&id).ok_or(LedgerError::UnknownParty(id))?; if let Some(f) = facts { if !p.roles.iter().any(|r| r.gets_1099()) { return Err(LedgerError::InvalidParty( "W-9 facts need a 1099 role (contractor or landlord)".into(), )); } f.check().map_err(LedgerError::InvalidParty)?; if let Some(t) = &p.tin && t.last4 != f.tin_last4 { return Err(LedgerError::InvalidParty(format!( "the party's TIN on file ends in {}, the W-9 facts say {}: they must agree (record the W-9's TIN with it)", t.last4, f.tin_last4 ))); } } Ok(()) } /// Record (or clear) a party's taxpayer-ID facts: the TIN's type, last /// four, and vault record, never the number. /// /// ```text /// SOURCE: crate::parties (INVARIANTS 1, 2, 2a); DECISION (owner, /// 2026-10-15): every member's and payee's TIN in the vault. /// SCENARIOS: /// 1. Any role may have them (members need theirs for K-1s). /// 2. IF the facts' form is wrong (TinFacts::check): SHOULD refuse. /// 3. IF the party's W-9 facts name another last four: SHOULD refuse /// (the two never disagree). /// ``` pub fn set_party_tin( &mut self, id: crate::PartyId, facts: Option, ) -> Result<(), LedgerError> { self.check_set_party_tin(id, facts.as_ref())?; self.parties.get_mut(&id).expect("checked").tin = facts; Ok(()) } /// The validation behind [`Self::set_party_tin`]. pub fn check_set_party_tin( &self, id: crate::PartyId, facts: Option<&crate::TinFacts>, ) -> Result<(), LedgerError> { let p = self.parties.get(&id).ok_or(LedgerError::UnknownParty(id))?; if let Some(f) = facts { f.check().map_err(LedgerError::InvalidParty)?; if let Some(w) = &p.w9 && w.tin_last4 != f.last4 { return Err(LedgerError::InvalidParty(format!( "the party's W-9 facts say the TIN ends in {}, not {}: a new TIN for a W-9 payee comes with its W-9 (books.set_w9)", w.tin_last4, f.last4 ))); } } Ok(()) } /// Name a party the creditor of a liability account the company owes /// it itself on (`on`), or stop (`!on`): the card it issued, the loan /// it made, what the members advanced (each member). /// /// ```text /// GOAL: the books know whose each card, loan and advance is, so a /// credit to it never counts as paying that party (a fee on its /// own card, interest on its own loan, a distribution still owed /// to a member: a promise, not a payment). /// SOURCE: crate::parties INVARIANT 5; rules.md B-35 (DECISION, owner, /// 2026-10-20: "a liability credit never counts as paying that /// liability's own creditor"). /// SCENARIOS: /// 1. IF the party or the account is unknown: SHOULD refuse. /// 2. IF the account isn't a liability, is a placeholder, or is /// payables or a payroll withholding (owed to many, through their /// own records): SHOULD refuse, changing nothing. /// 3. Naming it twice, or stopping one never named, changes nothing. /// 4. Several parties may be one account's creditors (both members, /// for what the members advanced). /// ``` pub fn set_party_creditor( &mut self, id: crate::PartyId, account: AccountId, on: bool, ) -> Result<(), LedgerError> { self.check_set_party_creditor(id, account, on)?; let p = self.parties.get_mut(&id).expect("checked"); if on { p.creditor_of.insert(account); } else { p.creditor_of.remove(&account); } Ok(()) } /// The validation behind [`Self::set_party_creditor`]. pub fn check_set_party_creditor( &self, id: crate::PartyId, account: AccountId, on: bool, ) -> Result<(), LedgerError> { if !self.parties.contains_key(&id) { return Err(LedgerError::UnknownParty(id)); } let a = self .chart .get(account) .ok_or(LedgerError::UnknownAccount(account))?; if !on { return Ok(()); } if a.account_type != AccountType::Liability { return Err(LedgerError::InvalidParty(format!( "account {} isn't a liability: a party is the creditor only of what the company owes it (a card, a loan, what the members advanced)", a.code ))); } if a.placeholder { return Err(LedgerError::InvalidParty(format!( "account {} is a placeholder: name the creditor of the account under it that is posted to", a.code ))); } let many = PAYROLL[2..] .iter() .chain([&PAYABLES]) .any(|c| self.role_account(c) == Some(account)); if many { return Err(LedgerError::InvalidParty(format!( "account {} is owed to many parties through their own records (bills, payroll): it has no one creditor", a.code ))); } Ok(()) } /// The parties named the creditor of `account`, by id /// ([`Self::set_party_creditor`]). pub fn creditors_of(&self, account: AccountId) -> Vec { self.parties .values() .filter(|p| p.creditor_of.contains(&account)) .map(|p| p.id) .collect() } /// Record (or clear) a party's W-4 facts. pub fn set_party_w4( &mut self, id: crate::PartyId, facts: Option, ) -> Result<(), LedgerError> { self.check_set_party_w4(id, facts.as_ref())?; self.parties.get_mut(&id).expect("checked").w4 = facts; Ok(()) } /// The validation behind [`Self::set_party_w4`]. pub fn check_set_party_w4( &self, id: crate::PartyId, facts: Option<&crate::W4Facts>, ) -> Result<(), LedgerError> { let p = self.parties.get(&id).ok_or(LedgerError::UnknownParty(id))?; if let Some(f) = facts { if !p.roles.contains(&crate::Role::Employee) { return Err(LedgerError::InvalidParty( "W-4 facts need the employee role".into(), )); } f.check().map_err(LedgerError::InvalidParty)?; } Ok(()) } /// Tag an entry with the party it was with, or clear the tag: one /// party with all of it ([`Self::set_entry_parties`] with one derived /// share). A party with a vendor record also tags the entry's vendor /// (for 1099s), as [`Self::set_entry_vendor`] does the other way round. /// Metadata: no amount changes, so posted entries may be tagged. pub fn set_entry_party( &mut self, entry: EntryId, party: Option, ) -> Result<(), LedgerError> { let shares: Vec = party.into_iter().map(crate::PartyShare::whole).collect(); self.set_entry_parties(entry, &shares) } /// Name the parties an entry was with, each with their share, or /// clear them (an empty list). Metadata: no amount changes, so posted /// entries may be tagged. /// /// ```text /// GOAL: one entry with several counterparties (both members, two /// contractors), apportioned exactly. /// SOURCE: crate::shares (INVARIANTS 1–5); DECISION (owner, /// 2026-10-15). /// INVARIANTS: /// 1. The shares are checked as crate::shares::check_shares says, and /// at most one party has a vendor record; refused otherwise, /// changing nothing. Whether they resolve against the entry's lines /// depends on settings (cash accounts, members) core doesn't hold: /// callers check crate::shares::resolve before journaling. /// 2. The entry's vendor tag follows the one party with a vendor /// record; with none, it is cleared. /// 3. Naming any party clears a no-party mark. /// 4. entry_party(entry) is Some only for exactly one party. /// 5. An entry and its reversal (and theirs: Self::reversal_chain) /// name the same parties, shares, and vendor tag: tagging any one /// of them tags the whole chain, so each party's part of an entry /// and its reversal still nets to zero (crate::shares INVARIANT 6; /// form1099 SCENARIO 6), however it is re-apportioned later. /// SCENARIOS: /// 1. IF the entry or a party is unknown, a party is named twice, or /// two parties have vendor records: SHOULD refuse. /// 2. Both members, no shares: two derived shares. /// 3. A reversed $1,000.00 payment named 600/400, then re-apportioned /// 700/300: the reversal is 700/300 too, and the pair nets to 0.00 /// for each party. /// ``` pub fn set_entry_parties( &mut self, entry: EntryId, shares: &[crate::PartyShare], ) -> Result<(), LedgerError> { let vendor = self.check_set_entry_parties(entry, shares)?; self.apply_entry_parties(entry, shares, vendor); Ok(()) } /// [`Self::set_entry_parties`] with the checks every journaled /// `entry.parties_set` passed when it was written /// (crate::shares::check_shares_journaled: no upper bound on an /// amount), for replay only (rules.md B-28). Never for new work. pub fn replay_set_entry_parties( &mut self, entry: EntryId, shares: &[crate::PartyShare], ) -> Result<(), LedgerError> { if !self.entries.contains_key(&entry) { return Err(LedgerError::UnknownEntry(entry)); } let vendor = self.party_shares_vendor(shares, false)?; self.apply_entry_parties(entry, shares, vendor); Ok(()) } /// Record checked shares on the entry's reversal chain /// ([`Self::set_entry_parties`] INVARIANTS 2-5). fn apply_entry_parties( &mut self, entry: EntryId, shares: &[crate::PartyShare], vendor: Option, ) { for e in self.reversal_chain(entry) { if shares.is_empty() { self.entry_parties.remove(&e); self.entry_vendors.remove(&e); continue; } self.entry_parties.insert(e, shares.to_vec()); self.entry_no_party.remove(&e); match vendor { Some(v) => { self.entry_vendors.insert(e, v); } None => { self.entry_vendors.remove(&e); } } } } /// The validation behind [`Self::set_entry_parties`]: returns the /// vendor record the entry's vendor tag follows, if any. pub fn check_set_entry_parties( &self, entry: EntryId, shares: &[crate::PartyShare], ) -> Result, LedgerError> { if !self.entries.contains_key(&entry) { return Err(LedgerError::UnknownEntry(entry)); } self.check_party_shares(shares) } /// The checks [`Self::check_set_entry_parties`] makes of the shares /// themselves, for an entry not yet created: returns the vendor record /// the vendor tag would follow, if any. pub fn check_party_shares( &self, shares: &[crate::PartyShare], ) -> Result, LedgerError> { self.party_shares_vendor(shares, true) } /// [`Self::check_party_shares`], with crate::shares::check_shares when /// `strict` (new work) or check_shares_journaled (replay). fn party_shares_vendor( &self, shares: &[crate::PartyShare], strict: bool, ) -> Result, LedgerError> { if let Some(s) = shares.iter().find(|s| !self.parties.contains_key(&s.party)) { return Err(LedgerError::UnknownParty(s.party)); } let known = |p| self.parties.contains_key(&p); if strict { crate::shares::check_shares(shares, known) } else { crate::shares::check_shares_journaled(shares, known) } .map_err(LedgerError::InvalidParty)?; let vendors: Vec<(crate::PartyId, VendorId)> = shares .iter() .filter_map(|s| self.parties[&s.party].vendor.map(|v| (s.party, v))) .collect(); if let [_, _, ..] = vendors.as_slice() { return Err(LedgerError::InvalidParty(format!( "parties {} have vendor records; a 1099 counts a payment toward one vendor, so record each vendor's part as its own entry", vendors .iter() .map(|(p, _)| p.to_string()) .collect::>() .join(" and ") ))); } Ok(vendors.first().map(|(_, v)| *v)) } /// Mark an entry as having no counterparty, with why (a transfer /// between the company's own accounts, an accrual, a correction), so a /// books policy that requires parties can tell "none" from "not yet". /// /// ```text /// SOURCE: docs/parties-and-attachments.md "Counterparties"; DECISION /// (owner, 2026-10-15). /// INVARIANTS: /// 1. An entry and its reversal (and theirs: Self::reversal_chain) /// carry the same mark, as they name the same parties /// (set_entry_parties INVARIANT 5): marking any one marks the /// chain, and reverse_entry copies the mark to the reversal. /// SCENARIOS: /// 1. IF the entry is unknown, already names a party, or the reason /// is blank or fails the party text rules: SHOULD refuse. /// 2. Tagging the entry with a party later clears the mark /// (set_entry_parties, set_entry_party, set_entry_vendor), on the /// whole chain. /// 3. A transfer between the company's own accounts marked, then /// reversed: the reversal is marked with the same reason. /// ``` pub fn set_entry_no_party(&mut self, entry: EntryId, reason: &str) -> Result<(), LedgerError> { self.check_set_entry_no_party(entry, reason)?; for e in self.reversal_chain(entry) { self.entry_no_party.insert(e, reason.trim().to_string()); } Ok(()) } /// The validation behind [`Self::set_entry_no_party`]. pub fn check_set_entry_no_party( &self, entry: EntryId, reason: &str, ) -> Result<(), LedgerError> { if !self.entries.contains_key(&entry) { return Err(LedgerError::UnknownEntry(entry)); } if let Some(e) = self .reversal_chain(entry) .into_iter() .find(|e| self.entry_parties.contains_key(e)) { return Err(LedgerError::InvalidParty(if e == entry { format!("entry {entry} names a party; clear it first") } else { format!("entry {entry}'s reversal chain entry {e} names a party; clear it first") })); } if reason.trim().is_empty() { return Err(LedgerError::InvalidParty( "say why the entry has no counterparty".into(), )); } crate::parties::check_text("reason", reason).map_err(LedgerError::InvalidParty) } /// Why an entry has no counterparty, when it was marked so. pub fn entry_no_party(&self, entry: EntryId) -> Option<&str> { self.entry_no_party.get(&entry).map(String::as_str) } /// 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 (owner, /// 2026-10-13): refuse with a reason, never force the chart's /// numbering. /// INVARIANTS: /// 1. MUST NOT mutate anything. /// 2. NEVER accepts an existing account of another type, or a /// placeholder, for a control role. /// SCENARIOS: /// 1. IF the code is free: SHOULD accept (the command creates it). /// 2. IF the code holds a postable account of the right type: SHOULD /// accept. /// 3. IF it holds another type or a placeholder: /// - SHOULD refuse with ControlAccountMismatch, naming the code, /// the account, its type, the role, and the type needed /// - NEVER post to it, and NEVER rename or renumber it /// BECAUSE imported charts use their own numbering; the human /// decides. /// ``` fn check_controls(&self, controls: &[ControlAccount]) -> Result<(), LedgerError> { for c in controls { // 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), } } /// Ensure a control role's account exists at its code, creating it /// (with the role's name and type: [`Self::add_control_account`]) if /// missing. Returns the account id. fn ensure_account(&mut self, c: &ControlAccount) -> AccountId { if let Some(a) = self.chart.find_by_code(c.code) { return a.id; } self.add_control_account(c).expect("fresh code cannot fail") } /// A document's revenue, expense or asset side on its draft `entry`: /// one line on each part's account, or on `default_account` when the /// part names none ([`arap::document_parts`]), each tagged with its /// project (a project the books don't have: UnknownClass, and the /// caller's `atomically` leaves the books unchanged). Every account is /// checked for the document's kind first /// ([`Self::check_document_line_account`]). fn add_document_parts( &mut self, entry: EntryId, default_account: AccountId, side: Side, kind: arap::DocumentKind, parts: &[(arap::DocumentPartKey, Decimal)], ) -> Result<(), LedgerError> { for &((account, class), amount) in parts { let account = account.unwrap_or(default_account); self.check_document_line_account(kind, account)?; self.add_line(entry, account, side, Money::new(amount, self.base_currency))?; if class.is_some() { let last = self .get_entry(entry) .map_or(0, |e| e.lines().len()) .saturating_sub(1); self.set_line_class(entry, last, class)?; } } Ok(()) } /// A document line's account must be one its kind can post to (plan /// P4b S4.1, A-10): an invoice line takes a revenue account, or a /// liability holding no role (2400 Deferred Revenue, a contract /// liability); a bill line takes an expense account, or an asset that /// is not cash and holds no role (1500 Equipment, 1300 Prepaid). /// Placeholders and the receivables and payables control accounts are /// refused. Checked when a line is added and when the entry posts. /// /// ```text /// GOAL: a document's lines go where its kind can put them, never to a /// control, a cash account or a header. /// SOURCE: docs/spec/rules.md B-39; plan P4b S4.1 ("Line accounts"), /// A-10 (every bill line names its account); /// ASC 606-10-45-2 (deferred revenue is a liability). /// INVARIANTS: /// 1. An invoice line: a revenue account, or a liability that holds /// no role. /// 2. A bill line: an expense account, or an asset that is not cash /// (`Ledger::account_is_cash`) and holds no role. /// 3. A placeholder, or an account that is a document control /// (receivables or payables), is refused for both. /// 4. A refusal names the account, the kind and the rule. /// ``` pub fn check_document_line_account( &self, kind: arap::DocumentKind, account: AccountId, ) -> Result<(), LedgerError> { self.check_line_account(account)?; let a = self.chart.get(account).expect("checked above"); let kind_name = match kind { arap::DocumentKind::Invoice => "an invoice", arap::DocumentKind::Bill => "a bill", }; let refuse = |why: &str| { Err(LedgerError::DocumentLineAccount { code: a.code.clone(), name: a.name.clone(), kind: kind_name, why: why.to_string(), }) }; if self.control_role_of(account).is_some() { return refuse( "it is a receivables or payables control account; its balance is its documents'", ); } match kind { arap::DocumentKind::Invoice => match a.account_type { AccountType::Revenue => Ok(()), AccountType::Liability if !self.holds_any_role(account) => Ok(()), _ => refuse( "an invoice line takes a revenue account, or a liability holding no role (deferred revenue)", ), }, arap::DocumentKind::Bill => { if a.account_type == AccountType::Expense { return Ok(()); } if a.account_type == AccountType::Asset && !self.account_is_cash(account) && !self.holds_any_role(account) { return Ok(()); } refuse( "a bill line takes an expense account, or an asset that is not cash and holds no role (equipment, prepaid)", ) } } } /// Whether `account` is one of the books' cash accounts: the codes in /// `books.cash_accounts` when set, else (as `ledger-store`'s /// reconciliation reads them) the asset accounts whose names say they /// hold cash. fn account_is_cash(&self, account: AccountId) -> bool { let Some(a) = self.chart.get(account) else { return false; }; let configured = self.settings.cash_accounts(); if !configured.is_empty() { return configured.iter().any(|c| c == &a.code); } const CASH_WORDS: [&str; 5] = ["cash", "bank", "checking", "savings", "money market"]; let name = a.name.to_lowercase(); a.account_type == AccountType::Asset && !a.placeholder && CASH_WORDS.iter().any(|w| name.contains(w)) } /// Whether `account` fills any of the books' control roles (assigned, /// or the account at the role's code and type). fn holds_any_role(&self, account: AccountId) -> bool { control_accounts().any(|c| self.holds_role(c, account)) } /// Create a sales invoice. Auto-posts: Dr `1200` Accounts Receivable, /// Cr each line's revenue account (the revenue role, `4000`, when a /// line names none). 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; rules.md B-38, B-39. /// INVARIANTS: /// 1. MUST post one balanced entry: Dr 1200 receivables, Cr the lines' /// accounts, for the sum of the lines, dated the invoice date: one /// line per (account, project) part the lines name, the parts /// naming neither as one (arap::document_parts), each revenue line /// tagged with its project; the receivable line never (rules.md /// B-37 item 8). /// 2. The receivable line MUST carry the invoice (Origin), in the /// step that posts it: the invoice starts Open, owing its total. /// 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, a line names a project the /// books don't have, 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.create_invoice_with( customer_id, date, due_date, lines, arap::InvoiceFields::default(), ) } /// [`Self::create_invoice`] with the document's new fields (number, /// terms, snapshots, references: plan P4b S4.1). The fields are stored /// on the invoice at issue and never change. All `None` is exactly /// [`Self::create_invoice`]. pub fn create_invoice_with( &mut self, customer_id: CustomerId, date: NaiveDate, due_date: NaiveDate, lines: Vec, fields: arap::InvoiceFields, ) -> Result { self.atomically(|l| l.create_invoice_steps(customer_id, date, due_date, lines, fields)) } /// 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, fields: arap::InvoiceFields, ) -> 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(number) = &fields.number && self .invoices .values() .any(|i| i.number.as_deref() == Some(number.as_str())) { return Err(LedgerError::DuplicateInvoiceNumber(number.clone())); } if let Some(pid) = self.closed_period_containing(date) { return Err(LedgerError::ClosedPeriod(pid, date.to_string())); } let total = arap::document_total(&lines, "the invoice")?; let parts = arap::document_parts(&lines, "the invoice")?; self.check_controls(&[RECEIVABLES, REVENUE])?; let ar = self.ensure_control(&RECEIVABLES); let revenue = self.ensure_control(&REVENUE); let id = self.next_invoice_id; let entry = self.create_entry(date, &format!("Invoice {id}")); self.add_tagged_line( entry, ar, Side::Debit, Money::new(total, self.base_currency), arap::DocTag { doc: arap::DocRef::Invoice(id), movement: arap::Movement::Origin, }, )?; self.add_document_parts( entry, revenue, Side::Credit, arap::DocumentKind::Invoice, &parts, )?; self.post_entry(entry)?; self.next_invoice_id += 1; self.invoices.insert( id, Invoice { id, customer_id, date, due_date, lines, amount_paid: Decimal::ZERO, owed: Decimal::ZERO, status: DocumentStatus::Open, entry_id: entry, number: fields.number, terms: fields.terms, bill_to: fields.bill_to, seller: fields.seller, customer_ref: fields.customer_ref, memo: fields.memo, sends: Vec::new(), }, ); self.tag_customer(entry, customer_id); self.refresh_doc(arap::DocRef::Invoice(id)); Ok(id) } /// Create a vendor bill. Auto-posts: Dr each line's expense or asset /// account (general expenses, `6999`, when a line names none), Cr /// `2000` Accounts Payable. The bill date must not fall in a closed /// period. /// /// ```text /// GOAL: record a vendor's bill as an expense or asset owed (accrual /// basis). /// SOURCE: IRS Pub. 538, accrual method ("deduct ... expenses in the year /// incurred"); control accounts: Ledger::check_controls; /// rules.md B-38, B-39 (plan P4b S4.1, A-10). /// INVARIANTS: /// 1. MUST post one balanced entry: Dr the lines' accounts, Cr 2000 /// payables, for the sum of the lines, dated the bill date, tagged /// with the vendor (1099s): one line per (account, project) part /// the lines name, the parts naming neither as one /// (arap::document_parts), each tagged with its project; the /// payable line never (rules.md B-37 item 8). /// 2. The payable line MUST carry the bill (Origin), in the step that /// posts it. /// 3. 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, a line names a project the books don't /// have, 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.create_bill_with( vendor_id, date, due_date, lines, arap::BillFields::default(), ) } /// [`Self::create_bill`] with the bill's new fields (the vendor's /// invoice number, terms, receipt date, memo: plan P4b S4.1). The /// fields are stored on the bill at issue and never change. All `None` /// is exactly [`Self::create_bill`]. pub fn create_bill_with( &mut self, vendor_id: VendorId, date: NaiveDate, due_date: NaiveDate, lines: Vec, fields: arap::BillFields, ) -> Result { self.atomically(|l| l.create_bill_steps(vendor_id, date, due_date, lines, fields)) } /// 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, fields: arap::BillFields, ) -> 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(received_on) = fields.received_on { crate::calendar::check(received_on)?; } if let Some(vendor_ref) = fields.vendor_ref.as_deref() { self.check_vendor_ref(vendor_id, vendor_ref, fields.duplicate_reason.as_deref())?; } if let Some(pid) = self.closed_period_containing(date) { return Err(LedgerError::ClosedPeriod(pid, date.to_string())); } let total = arap::document_total(&lines, "the bill")?; let parts = arap::document_parts(&lines, "the bill")?; self.check_controls(&[GENERAL_EXPENSES, PAYABLES])?; let expense = self.ensure_control(&GENERAL_EXPENSES); let ap = self.ensure_control(&PAYABLES); let id = self.next_bill_id; let entry = self.create_entry(date, &format!("Bill {id}")); self.add_document_parts( entry, expense, Side::Debit, arap::DocumentKind::Bill, &parts, )?; self.add_tagged_line( entry, ap, Side::Credit, Money::new(total, self.base_currency), arap::DocTag { doc: arap::DocRef::Bill(id), movement: arap::Movement::Origin, }, )?; self.post_entry(entry)?; self.next_bill_id += 1; self.bills.insert( id, Bill { id, vendor_id, date, due_date, lines, amount_paid: Decimal::ZERO, owed: Decimal::ZERO, status: DocumentStatus::Open, entry_id: entry, vendor_ref: fields.vendor_ref, received_on: fields.received_on, terms: fields.terms, memo: fields.memo, }, ); self.tag_vendor(entry, vendor_id); self.refresh_doc(arap::DocRef::Bill(id)); Ok(id) } /// The number the next invoice is issued under (A-2, B-40): `prefix`, /// then `first_number` plus the count of numbered invoices issued so /// far, zero-padded to four digits and wider past 9,999 (never /// truncated). Stored at issue, never recomputed; a void keeps its /// number, so the series never reuses one; a legacy invoice /// (`number: None`) is not counted. pub fn next_invoice_number(&self, prefix: &str, first_number: u64) -> String { let issued = self .invoices .values() .filter(|i| i.number.is_some()) .count() as u64; format!("{prefix}{:04}", first_number.saturating_add(issued)) } /// Refuse a vendor invoice number that already names a standing bill /// of the same vendor (B-40), unless `duplicate_reason` says why it is /// entered again. Case, spaces and hyphens are ignored: "ACME-123", /// "acme 123" and "Acme123" are the same number. pub fn check_vendor_ref( &self, vendor: VendorId, vendor_ref: &str, duplicate_reason: Option<&str>, ) -> Result<(), LedgerError> { if duplicate_reason.is_some_and(|r| !r.trim().is_empty()) { return Ok(()); } let key = normalize_vendor_ref(vendor_ref); if key.is_empty() { return Ok(()); } for bill in self.bills.values() { if bill.vendor_id != vendor || !self.in_effect(bill.entry_id) { continue; } if let Some(existing) = &bill.vendor_ref && normalize_vendor_ref(existing) == key { return Err(LedgerError::DuplicateVendorRef { vendor, vendor_ref: vendor_ref.to_string(), }); } } Ok(()) } /// 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) } /// Every invoice, by id. pub fn invoices(&self) -> Vec<&Invoice> { let mut v: Vec<&Invoice> = self.invoices.values().collect(); v.sort_by_key(|i| i.id); v } /// Every bill, by id. pub fn bills(&self) -> Vec<&Bill> { let mut v: Vec<&Bill> = self.bills.values().collect(); v.sort_by_key(|b| b.id); v } /// Every document: the invoices by id, then the bills by id. pub fn documents(&self) -> Vec { self.invoices() .into_iter() .map(|i| arap::DocRef::Invoice(i.id)) .chain(self.bills().into_iter().map(|b| arap::DocRef::Bill(b.id))) .collect() } /// The document a ledger line belongs to, if it carries one (rules.md /// B-38): `(entry, line index)`. pub fn line_doc(&self, entry: EntryId, line: usize) -> Option { self.line_docs.get(&(entry, line)).copied() } /// A document's date, due date, own entry and total; refused for an /// unknown document (UnknownInvoice, UnknownBill) or a total past /// exact arithmetic. fn doc_header( &self, doc: arap::DocRef, ) -> Result<(NaiveDate, NaiveDate, EntryId, Decimal), LedgerError> { match doc { arap::DocRef::Invoice(id) => { let i = self .invoices .get(&id) .ok_or(LedgerError::UnknownInvoice(id))?; Ok((i.date, i.due_date, i.entry_id, i.total()?)) } arap::DocRef::Bill(id) => { let b = self.bills.get(&id).ok_or(LedgerError::UnknownBill(id))?; Ok((b.date, b.due_date, b.entry_id, b.total()?)) } } } /// Every posted ledger line tagged with `doc`, in date order (posting /// order within a day): its origin, payments, and the mirrors of any /// of them reversed. pub fn doc_lines(&self, doc: arap::DocRef) -> Vec { let Some(tagged) = self.doc_index.get(&doc) else { return Vec::new(); }; let mut out: Vec = tagged .iter() .filter_map(|&(entry, line)| { let e = self.entries.get(&entry)?; if e.status() != EntryStatus::Posted { return None; } let l = e.lines().get(line)?; Some(arap::DocLine { entry, line, date: e.date(), account: l.account, movement: self.line_docs.get(&(entry, line))?.movement, amount: doc.counted(l.side, l.amount.amount), reversal: e.reversal_of().is_some(), }) }) .collect(); out.sort_by_key(|l| (l.date, self.order_pos.get(&l.entry).copied(), l.line)); out } /// What a document is as of a day, read from the ledger lines tagged /// with it (rules.md B-38; plan P4b.3, "Derived figures"). `None` for /// a document dated after `as_of` (it wasn't issued yet). /// /// ```text /// GOAL: every figure about a document (paid, outstanding, status, /// overdue) is read from its tagged lines, so a reversal, a void /// or a bounced payment is right by construction. /// SOURCE: docs/spec/rules.md B-38 (INVARIANTS 1-4), B-9, B-10, B-18; /// ASC 310-10 (receivables), ASC 405-20-40-1 (payables). /// INVARIANTS: /// 1. outstanding = the signed sum of every posted line tagged with /// it dated on or before `as_of` (a debit to receivables counts /// plus for an invoice; a credit to payables for a bill): the /// very lines the control account sums. /// 2. paid (discounted, written off) = minus the same sum over its /// Payment (Discount, WriteOff) lines: a reversed payment counts /// from its reversal's date, never after. /// 3. status = arap::doc_status(its own entry stands on `as_of`, its /// own entry's control line carries it, ...). /// 4. overdue_days > 0 only while something is owed past the due date. /// 5. A pure function of (the books, as_of); NEVER panics: /// TooLargeToCompute past exact arithmetic. /// SCENARIOS: /// 1. IF an invoice of 100 was paid 100 and the payment reversed: /// SHOULD be Open, owing 100 (scenario 1 of B-38). /// 2. IF its own entry was reversed with nothing paid: SHOULD be Void, /// owing nothing. /// 3. IF asked between a payment and its reversal: SHOULD be Paid. /// ``` pub fn doc_state( &self, doc: arap::DocRef, as_of: NaiveDate, ) -> Result, LedgerError> { let (date, due_date, entry, total) = self.doc_header(doc)?; if date > as_of { return Ok(None); } let too_large = || LedgerError::TooLargeToCompute(format!("{doc}'s figures")); let add = |a: Decimal, b: Decimal| a.checked_add(b).ok_or_else(too_large); let (mut outstanding, mut paid, mut discounted, mut written_off) = (Decimal::ZERO, Decimal::ZERO, Decimal::ZERO, Decimal::ZERO); let lines = self.doc_lines(doc); let origin_tagged = lines .iter() .any(|l| l.entry == entry && l.movement == arap::Movement::Origin); for l in lines.into_iter().filter(|l| l.date <= as_of) { outstanding = add(outstanding, l.amount)?; match l.movement { arap::Movement::Payment => paid = add(paid, -l.amount)?, arap::Movement::Discount => discounted = add(discounted, -l.amount)?, arap::Movement::WriteOff => written_off = add(written_off, -l.amount)?, arap::Movement::Origin | arap::Movement::Adopted | arap::Movement::RoleMove => {} } } let status = arap::doc_status( self.stands_on(entry, as_of), origin_tagged, total, outstanding, written_off, ); let overdue_days = if outstanding > Decimal::ZERO && as_of > due_date { (as_of - due_date).num_days() } else { 0 }; Ok(Some(arap::DocState { total, paid, discounted, written_off, outstanding, status, overdue_days, })) } /// [`Self::doc_state`] of an invoice. pub fn invoice_state( &self, id: InvoiceId, as_of: NaiveDate, ) -> Result, LedgerError> { self.doc_state(arap::DocRef::Invoice(id), as_of) } /// [`Self::doc_state`] of a bill. pub fn bill_state( &self, id: BillId, as_of: NaiveDate, ) -> Result, LedgerError> { self.doc_state(arap::DocRef::Bill(id), as_of) } /// The payments a document's events recorded, each once, standing or /// not, tagged or not: every `invoice.payment_received` or `bill.paid` /// naming it (live or replayed). What a version before document /// standing (plan P4b S1) counts as paid, so the store knows when a /// payment needs that feature. Refused (TooLargeToCompute), never a /// panic, past exact arithmetic: replayed amounts are any size (B-28). pub fn payments_recorded(&self, doc: arap::DocRef) -> Result { self.payment_events .get(&doc) .map_or(&[][..], Vec::as_slice) .iter() .try_fold(Decimal::ZERO, |sum, &a| sum.checked_add(a)) .ok_or_else(|| LedgerError::TooLargeToCompute(format!("{doc}'s payments"))) } /// The payment entries that stand against a document now: each /// payment (an entry reversing nothing) that isn't reversed, once, in /// entry order. What a void waits on, and what a bounds refusal names /// to reverse first; never a payment's reversal, which stands too but /// re-instating the payment is not the remedy. pub fn standing_payments(&self, doc: arap::DocRef) -> Vec { let mut out: Vec = self .doc_lines(doc) .iter() .filter(|l| { l.movement == arap::Movement::Payment && !l.reversal && self.in_effect(l.entry) }) .map(|l| l.entry) .collect(); out.sort_unstable(); out.dedup(); out } /// A document's movements by date, `extra` ones added: `(date, the /// day's movements summed)`, in date order, over the dates in `grid` /// (each date once; a date with nothing moving is zero). fn doc_deltas( &self, doc: arap::DocRef, extra: &[(NaiveDate, Decimal)], grid: &[NaiveDate], ) -> Result, LedgerError> { let mut by_date: std::collections::BTreeMap = grid.iter().map(|&d| (d, Decimal::ZERO)).collect(); let too_large = || LedgerError::TooLargeToCompute(format!("{doc}'s figures")); let moves = self .doc_lines(doc) .into_iter() .map(|l| (l.date, l.amount)) .chain(extra.iter().copied()); for (date, amount) in moves { let day = by_date.entry(date).or_default(); *day = day.checked_add(amount).ok_or_else(too_large)?; } Ok(by_date.into_values().collect()) } /// Refuse a step that would move `doc` by `extra` (dated movements) /// if, at some date, it would then owe less than nothing or more than /// its total where it didn't before ([`kernel::bounds_kept`]; rules.md /// B-38 INVARIANT 2). Books an earlier version left out of bounds /// (a void under a payment that stands) take any step that makes no /// date worse, so the remedy (reversing the payment) is accepted. fn check_item_bounds( &self, doc: arap::DocRef, extra: &[(NaiveDate, Decimal)], ) -> Result<(), LedgerError> { let (_, _, _, total) = self.doc_header(doc)?; let mut grid: Vec = self .doc_lines(doc) .iter() .map(|l| l.date) .chain(extra.iter().map(|&(d, _)| d)) .collect(); grid.sort(); grid.dedup(); let before = self.doc_deltas(doc, &[], &grid)?; let after = self.doc_deltas(doc, extra, &grid)?; let (i, below) = match kernel::bounds_kept(total, &before, &after) { kernel::Bounds::Within => return Ok(()), kernel::Bounds::Below(i) => (i, true), kernel::Bounds::Above(i) => (i, false), kernel::Bounds::Overflow => { return Err(LedgerError::TooLargeToCompute(format!("{doc}'s figures"))); } }; let owed: Decimal = after[..=i].iter().sum(); let on = grid[i]; let payments: Vec = self .standing_payments(doc) .iter() .map(|e| format!("entry {e}")) .collect(); let standing = if payments.is_empty() { String::new() } else { format!(" (payments standing: {})", payments.join(", ")) }; Err(LedgerError::DocumentBounds(if below { format!( "{doc} would owe {owed} on {on}, less than nothing: more would be paid against it than it is for{standing}. Reverse the payment first (books.reverse_entry)" ) } else { format!( "{doc} would owe {owed} on {on}, more than its total {total}{standing}: reverse the other payment first (books.reverse_entry), then this one's reversal" ) })) } /// What can still be paid on `doc` by a payment dated `date`: the /// least it owes on any day from then on (from its own date, for a /// payment dated before it), so no later day is overpaid. fn payment_room(&self, doc: arap::DocRef, date: NaiveDate) -> Result { let (doc_date, ..) = self.doc_header(doc)?; let from = date.max(doc_date); let lines = self.doc_lines(doc); let too_large = || LedgerError::TooLargeToCompute(format!("{doc}'s figures")); let mut running = Decimal::ZERO; for l in lines.iter().filter(|l| l.date <= from) { running = running.checked_add(l.amount).ok_or_else(too_large)?; } let mut room = running; for l in lines.iter().filter(|l| l.date > from) { running = running.checked_add(l.amount).ok_or_else(too_large)?; room = room.min(running); } Ok(room) } /// A payment's deposit or payer account: a postable asset or liability /// that holds no role (a bank, a card, a member's payable), never a /// control, revenue or expense account (plan P4b.3, "Payment /// accounts"). Receivables and payables are held as the control guard /// reads them ([`Self::control_role_of`]); the other roles by /// [`Self::holds_role`], so a chart's own card at 2000, or a bank at a /// payroll role's code, pays. fn check_payment_account(&self, account: AccountId) -> Result<(), LedgerError> { self.check_line_account(account)?; let a = self.chart.get(account).expect("checked above"); let refuse = |why: String| { Err(LedgerError::PaymentAccount { code: a.code.clone(), name: a.name.clone(), why, }) }; if !matches!(a.account_type, AccountType::Asset | AccountType::Liability) { return refuse(format!( "it is {} account, not cash, a card or a payable", match a.account_type { AccountType::Equity => "an equity", AccountType::Revenue => "a revenue", _ => "an expense", } )); } if let Some(c) = self.control_role_of(account).or_else(|| { control_accounts().find(|c| { c.key != RECEIVABLES.key && c.key != PAYABLES.key && self.holds_role(c, account) }) }) { return refuse(format!("it holds the {} role", c.role)); } Ok(()) } /// Whether `account` fills role `c`: assigned to it, or (unassigned) /// the account at the role's code, of the role's type and postable, /// so the commands posting the role post there. An account of /// another type at the code fills nothing (the commands refuse it: /// [`Self::check_controls`]). fn holds_role(&self, c: &ControlAccount, account: AccountId) -> bool { match self.roles.get(c.key) { Some(&a) => a == account, None => self.chart.find_by_code(c.code).is_some_and(|a| { a.id == account && a.account_type == c.account_type && !a.placeholder }), } } /// Record a customer payment against an invoice. Posts: Dr Cash, /// Cr A/R ([`Self::receive_payment_with`], with no payment method or /// reference). pub fn receive_payment( &mut self, invoice_id: InvoiceId, date: NaiveDate, amount: Decimal, cash_account: AccountId, ) -> Result { self.receive_payment_with( invoice_id, date, amount, cash_account, &arap::PaymentDetails::default(), ) } /// Record a customer payment against an invoice, with its payment /// method and reference. Posts: Dr the deposit account, Cr A/R. /// /// ```text /// GOAL: record a customer's payment against an invoice. /// SOURCE: IRS Pub. 538 (accrual); CONVENTION (A/R workflow); rules.md /// B-38; plan/served.md P4b.3 ("Payment accounts"), P4b.6. /// INVARIANTS: /// 1. MUST post Dr the deposit account, Cr 1200 receivables, for the /// amount; the receivables line carries the invoice (Payment), so /// what it owes falls by the amount from the payment's date. /// 2. The payment method, when given, tags the entry in the same /// step (D-40); the reference is in its description. /// 3. A refusal MUST leave the books unchanged. /// SCENARIOS: /// 1. IF the amount isn't positive: SHOULD refuse. /// 2. IF it exceeds what's still owed on any day from its date on: /// - SHOULD refuse (Overpayment), saying what can be paid /// - NEVER let a standing payment exceed the total /// - a reversed payment no longer counts, so the right one is /// accepted after the wrong one is reversed (B-38 scenario 5) /// 3. IF the deposit account is a control, revenue, expense or /// equity account, or holds any role: SHOULD refuse /// (PaymentAccount; B-38 scenario 8). /// 4. IF dated before the invoice: SHOULD refuse (PaymentBeforeDocument). /// ``` pub fn receive_payment_with( &mut self, invoice_id: InvoiceId, date: NaiveDate, amount: Decimal, cash_account: AccountId, details: &arap::PaymentDetails, ) -> Result { self.atomically(|l| { l.payment_steps( arap::DocRef::Invoice(invoice_id), date, amount, cash_account, details, ) }) } /// Pay a vendor bill. Posts: Dr A/P, Cr Cash ([`Self::pay_bill_with`], /// with no payment method or reference). pub fn pay_bill( &mut self, bill_id: BillId, date: NaiveDate, amount: Decimal, cash_account: AccountId, ) -> Result { self.pay_bill_with( bill_id, date, amount, cash_account, &arap::PaymentDetails::default(), ) } /// Pay a vendor bill, with its payment method and reference. Posts: /// Dr A/P, Cr the payer account. /// /// ```text /// GOAL: record paying a vendor's bill. /// SOURCE: IRS Pub. 538 (accrual); CONVENTION (A/P workflow); rules.md /// B-38; plan/served.md P4b.3, P4b.6. /// INVARIANTS: /// 1. MUST post Dr 2000 payables, Cr the payer account, for the /// amount, tagged with the vendor; the payables line carries the /// bill (Payment). /// 2. The payment method, when given, tags the entry in the same /// step (a card-paid bill is left out of 1099-NEC, D-40). /// 3. A refusal MUST leave the books unchanged. /// SCENARIOS: /// 1. IF the amount isn't positive, or exceeds what's still owed on /// any day from its date on: SHOULD refuse. /// 2. IF dated before the bill: SHOULD refuse (PaymentBeforeDocument); /// the bill's own date is fine (DECISION, owner 2026-10-13). /// 3. IF the payer account isn't a bank, card or payable that holds /// no role: SHOULD refuse (PaymentAccount). /// ``` pub fn pay_bill_with( &mut self, bill_id: BillId, date: NaiveDate, amount: Decimal, cash_account: AccountId, details: &arap::PaymentDetails, ) -> Result { self.atomically(|l| { l.payment_steps( arap::DocRef::Bill(bill_id), date, amount, cash_account, details, ) }) } /// The steps of a payment ([`Self::receive_payment_with`], /// [`Self::pay_bill_with`]), run inside [`Self::atomically`]. fn payment_steps( &mut self, doc: arap::DocRef, date: NaiveDate, amount: Decimal, account: AccountId, details: &arap::PaymentDetails, ) -> Result { if amount <= Decimal::ZERO { return Err(LedgerError::InvalidPaymentAmount(amount)); } let (doc_date, ..) = self.doc_header(doc)?; let room = self.payment_room(doc, date)?; if amount > room { return Err(LedgerError::Overpayment(amount, room)); } if let Some(pid) = self.closed_period_containing(date) { return Err(LedgerError::ClosedPeriod(pid, date.to_string())); } if date < doc_date { return Err(LedgerError::PaymentBeforeDocument { date, document_date: doc_date, }); } details.check()?; if let Some(i) = details.instrument && !self.instruments.contains_key(&i) { return Err(LedgerError::UnknownInstrument(i)); } let role = match doc { arap::DocRef::Invoice(_) => RECEIVABLES, arap::DocRef::Bill(_) => PAYABLES, }; self.check_controls(&[role])?; // Every check before the entry exists: a refusal leaves no draft. self.check_payment_account(account)?; let control = self.ensure_control(&role); let money = Money::new(amount, self.base_currency); let tag = arap::DocTag { doc, movement: arap::Movement::Payment, }; let entry = match doc { arap::DocRef::Invoice(id) => { let entry = self.create_entry( date, &details.description(&format!("Payment on invoice {id}")), ); self.add_line(entry, account, Side::Debit, money)?; self.add_tagged_line(entry, control, Side::Credit, money, tag)?; entry } arap::DocRef::Bill(id) => { let entry = self.create_entry(date, &details.description(&format!("Payment on bill {id}"))); self.add_tagged_line(entry, control, Side::Debit, money, tag)?; self.add_line(entry, account, Side::Credit, money)?; entry } }; self.post_entry(entry)?; match doc { arap::DocRef::Invoice(id) => { let customer = self.invoices[&id].customer_id; self.tag_customer(entry, customer); } arap::DocRef::Bill(id) => { let vendor = self.bills[&id].vendor_id; self.tag_vendor(entry, vendor); } } if details.instrument.is_some() { self.set_entry_instrument(entry, details.instrument)?; } self.payment_events.entry(doc).or_default().push(amount); self.refresh_doc(doc); Ok(entry) } /// Void a document: reverse its own entry (B-9), described "Void /// invoice 7: reason". It keeps its id (and, later, its number); its /// status as of the void's date on is Void. Plain /// [`Self::reverse_entry`] of the same entry gives the same result. /// /// ```text /// GOAL: a document that should never have existed (a duplicate, the /// wrong customer) is undone by an opposite entry, never erased. /// SOURCE: docs/spec/rules.md B-9, B-10, B-12, B-38 (scenarios 2, 3); /// plan/served.md P4b.5, P4b.6; ASC 250 (an immaterial error is /// corrected in the period found). /// INVARIANTS: /// 1. MUST post exactly the reversal reverse_entry would, of the /// entry whose reversal voids it (its own entry, or the last /// reversal in its chain when it was voided and re-instated). /// 2. A refusal MUST leave the books unchanged. /// SCENARIOS: /// 1. IF the reason is blank, more than one line, or longer than 200 /// characters: SHOULD refuse (InvalidVoid). /// 2. IF it is void already: SHOULD refuse (InvalidVoid), naming the /// reversal (reverse that to re-instate it). /// 3. IF a payment stands against it: SHOULD refuse (DocumentBounds), /// naming the payment to reverse first. /// 4. IF dated before the document, or in a closed period: SHOULD /// refuse (check_reversal). /// ``` pub fn void_document( &mut self, doc: arap::DocRef, date: NaiveDate, reason: &str, ) -> Result { let (target, description) = self.check_void_document(doc, date, reason)?; self.reverse_entry(target, date, &description) } /// Every refusal of [`Self::void_document`], without touching the /// books: the entry to reverse and the reversal's description. pub fn check_void_document( &self, doc: arap::DocRef, date: NaiveDate, reason: &str, ) -> Result<(EntryId, String), LedgerError> { let reason = reason.trim(); if reason.is_empty() || reason.contains(['\n', '\r']) || reason.chars().count() > arap::MAX_REFERENCE_CHARS { return Err(LedgerError::InvalidVoid(format!( "a void needs its reason: one line of at most {} characters", arap::MAX_REFERENCE_CHARS ))); } let (_, _, origin, _) = self.doc_header(doc)?; let chain = self.reversal_chain(origin); let target = *chain.last().unwrap_or(&origin); if !self.in_effect(origin) { return Err(LedgerError::InvalidVoid(format!( "{doc} is void already: entry {target} reversed it (reverse that entry to re-instate it)" ))); } self.check_reversal(target, date)?; Ok((target, format!("Void {doc}: {reason}"))) } /// The control accounts' tie-out as of a day (plan P4b.10, /// `books.control_check`): for each control account /// ([`Self::control_role_of`]: assigned receivables or payables, or /// holding a document's lines), its balance, what its documents owe /// on it, its lines that carry no document, and (on the role's first /// account) the role's documents needing attention. /// /// ```text /// GOAL: show that receivables and payables equal their documents, and /// list every line that is no document's. /// SOURCE: docs/spec/rules.md B-23, B-38 INVARIANT 1: /// GL(A, D) = Σ_docs outstanding(d, D) + unattached(A, D). /// INVARIANTS: /// 1. balance sums every posted line on the account dated on or /// before `as_of`; documents_total sums the tagged ones through /// their documents (Self::doc_lines); unattached lists the rest, /// each reversal chain once, at what its lines net to as of the /// day (a reversed hand line nets to nothing and is not listed). /// 2. difference = balance - documents_total - Σ unattached is zero /// in every books (an identity: both sides sum the same lines). /// 3. attention lists each document of the role issued by `as_of` /// whose state then is out of bounds (a credit balance, which /// aging leaves out, or more owed than its total) or Unattached: /// what books an earlier version wrote can hold (plan P4b.14 /// item 3), never what these commands write. /// 4. NEVER panics: TooLargeToCompute past exact arithmetic. /// ``` pub fn control_check(&self, as_of: NaiveDate) -> Result, LedgerError> { let too_large = || LedgerError::TooLargeToCompute("a control account's balance".into()); // Each control account (Self::control_role_of), and the role // whose documents it holds: a debit counts plus on receivables, a // credit on payables. let mut accounts: Vec<(AccountId, &'static ControlAccount)> = [&RECEIVABLES, &PAYABLES] .into_iter() .flat_map(|c| { self.document_role_accounts(c) .into_iter() .map(move |a| (a, c)) }) .collect(); let mut doc_totals: HashMap = HashMap::new(); let mut attention: HashMap<&'static str, Vec> = HashMap::new(); for doc in self.documents() { let role = match doc { arap::DocRef::Invoice(_) => &RECEIVABLES, arap::DocRef::Bill(_) => &PAYABLES, }; for l in self.doc_lines(doc).into_iter().filter(|l| l.date <= as_of) { let t = doc_totals.entry(l.account).or_default(); *t = t.checked_add(l.amount).ok_or_else(too_large)?; } if let Some(st) = self.doc_state(doc, as_of)? && (st.out_of_bounds() || st.status == arap::DocStatus::Unattached) { attention .entry(role.key) .or_default() .push(arap::DocAttention { doc, status: st.status, total: st.total, outstanding: st.outstanding, }); } } // A role whose documents carry no line anywhere (each unattached) // still gets its row, on the account the role posts to now. for c in [&RECEIVABLES, &PAYABLES] { if attention.contains_key(c.key) && !accounts.iter().any(|&(_, r)| r.key == c.key) && let Some(a) = self.role_account(c) { accounts.push((a, c)); } } let counted = |role: &ControlAccount, side: Side, amount: Decimal| { let plus = if role.key == RECEIVABLES.key { Side::Debit } else { Side::Credit }; if side == plus { amount } else { -amount } }; let mut out = Vec::new(); for (account, role) in accounts { let mut balance = Decimal::ZERO; let mut tops: Vec<(EntryId, usize)> = Vec::new(); for e in self.posted_entries().filter(|e| e.date() <= as_of) { for (i, l) in e.lines().iter().enumerate() { if l.account != account { continue; } balance = balance .checked_add(counted(role, l.side, l.amount.amount)) .ok_or_else(too_large)?; if !self.line_docs.contains_key(&(e.id(), i)) { let top = self.reversal_chain(e.id())[0]; if !tops.contains(&(top, i)) { tops.push((top, i)); } } } } let mut unattached = Vec::new(); let mut unattached_sum = Decimal::ZERO; for (top, i) in tops { // The chain's lines dated by `as_of`, each the mirror of // the one before: they net to k times the top's line. let mut net = Decimal::ZERO; for (n, e) in self.reversal_chain(top).into_iter().enumerate() { let Some(e) = self.entries.get(&e).filter(|e| e.date() <= as_of) else { continue; }; let Some(l) = e.lines().get(i) else { continue; }; debug_assert!(n == 0 || e.reversal_of().is_some()); net = net .checked_add(counted(role, l.side, l.amount.amount)) .ok_or_else(too_large)?; } if !net.is_zero() { unattached_sum = unattached_sum.checked_add(net).ok_or_else(too_large)?; unattached.push(arap::UnattachedLine { entry: top, line: i, date: self.entries[&top].date(), amount: net, }); } } let documents_total = doc_totals.get(&account).copied().unwrap_or_default(); let difference = balance .checked_sub(documents_total) .and_then(|d| d.checked_sub(unattached_sum)) .ok_or_else(too_large)?; out.push(arap::ControlCheck { role: role.key.to_string(), account, balance, documents_total, unattached, difference, attention: attention.remove(role.key).unwrap_or_default(), }); } Ok(out) } /// Refresh a document's record figures ([`Invoice::amount_paid`], /// [`Invoice::owed`], [`Invoice::status`]) from its tagged lines as of /// the end of the books: what was settled (paid, discounted, written /// off, as they stand), what is owed, and the three-word status of /// what was settled. A void settles nothing, so it never reads as /// paid. Never refuses: a document whose figures can't be computed /// keeps the last ones (its state refuses them, `doc_state`). fn refresh_doc(&mut self, doc: arap::DocRef) { let Ok(Some(st)) = self.doc_state(doc, NaiveDate::MAX) else { return; }; let Some(settled) = st .paid .checked_add(st.discounted) .and_then(|s| s.checked_add(st.written_off)) else { return; }; // Nothing settled is zero as the record always started it (a // sum's scale would show as "0.00"); what is owed keeps the // lines' scale, as the total less the settled did ("0.00"). let settled = if settled.is_zero() { Decimal::ZERO } else { settled }; let owed = st.outstanding; let status = arap::derive_status(settled, st.total); match doc { arap::DocRef::Invoice(id) => { if let Some(i) = self.invoices.get_mut(&id) { (i.amount_paid, i.owed, i.status) = (settled, owed, status); } } arap::DocRef::Bill(id) => { if let Some(b) = self.bills.get_mut(&id) { (b.amount_paid, b.owed, b.status) = (settled, owed, status); } } } } /// [`Self::refresh_doc`] for every document `entry`'s lines carry. fn refresh_docs_of(&mut self, entry: EntryId) { let docs: Vec = self .line_docs .range((entry, 0)..(entry + 1, 0)) .map(|(_, t)| t.doc) .collect(); for d in docs { self.refresh_doc(d); } } /// On replay, tag a document's control line in `entry` (plan P4b.3, /// "How tags get set"): the first line, in line order, on the role's /// account as the role stands at this point of the journal, on the /// side the movement posts (the owed side for the document's own /// line, the other for a payment), that carries no document yet and /// is for `amount` (any amount when `None`: a document's own entry /// has one such line). An entry with /// no such line stays untagged, and its line is listed by /// [`Self::control_check`]: replay never refuses (B-28). fn tag_replayed( &mut self, entry: EntryId, role: &ControlAccount, amount: Option, tag: arap::DocTag, ) { let (Some(account), Some(e)) = (self.role_account(role), self.entries.get(&entry)) else { return; }; // The side the movement posts: a document's own line raises what // it owes, a payment lowers it. An older payment deposited into // the control account itself (Dr 1200 / Cr 1200, plan P4b.2 gap // 5) has a line on each side; only the right one is the payment. let side = match tag.movement { arap::Movement::Origin => tag.doc.owed_side(), _ => kernel::flip(tag.doc.owed_side()), }; let at = e.lines().iter().enumerate().position(|(i, l)| { l.account == account && l.side == side && !self.line_docs.contains_key(&(entry, i)) && amount.is_none_or(|a| a == l.amount.amount) }); if let Some(i) = at { // A document's own entry has one control line: two would be no // entry an earlier version wrote, so neither is tagged. let lines_on = e.lines().iter().filter(|l| l.account == account).count(); if amount.is_some() || lines_on == 1 { self.tag_line(entry, i, tag); } } } /// 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 /// restores the invoice record and tags its receivable line (the one /// line on the receivables role's account as it stands at this point /// of the journal). 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)); } // Restored exactly as journaled: replay checks no line or date // (none did when it was written; rules.md B-28). Figures derived // from them (the total, aging) refuse what they can't compute. self.next_invoice_id += 1; self.invoices.insert( id, Invoice { id, customer_id, date, due_date, lines, amount_paid: Decimal::ZERO, owed: Decimal::ZERO, status: DocumentStatus::Open, entry_id, number: None, terms: None, bill_to: None, seller: None, customer_ref: None, memo: None, sends: Vec::new(), }, ); self.tag_customer(entry_id, customer_id); let doc = arap::DocRef::Invoice(id); self.tag_replayed( entry_id, &RECEIVABLES, None, arap::DocTag { doc, movement: arap::Movement::Origin, }, ); self.refresh_doc(doc); 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)); } // Restored exactly as journaled: replay checks no line or date // (none did when it was written; rules.md B-28). Figures derived // from them (the total, aging) refuse what they can't compute. self.next_bill_id += 1; self.bills.insert( id, Bill { id, vendor_id, date, due_date, lines, amount_paid: Decimal::ZERO, owed: Decimal::ZERO, status: DocumentStatus::Open, entry_id, vendor_ref: None, received_on: None, terms: None, memo: None, }, ); self.tag_vendor(entry_id, vendor_id); let doc = arap::DocRef::Bill(id); self.tag_replayed( entry_id, &PAYABLES, None, arap::DocTag { doc, movement: arap::Movement::Origin, }, ); self.refresh_doc(doc); Ok(()) } /// Apply an invoice payment without posting: replay support for /// journaling stores. The payment entry was already rebuilt from its /// own entry events; this tags its receivables line with the invoice /// (the first untagged one for `amount`, so several payment events /// naming one entry tag its lines in order: the ordinal rule, plan /// P4b.3). Nothing is bounded on replay (rules.md B-28). #[doc(hidden)] pub fn replay_invoice_payment( &mut self, invoice_id: InvoiceId, amount: Decimal, entry_id: EntryId, ) -> Result<(), LedgerError> { let customer = self .invoices .get(&invoice_id) .ok_or(LedgerError::UnknownInvoice(invoice_id))? .customer_id; let doc = arap::DocRef::Invoice(invoice_id); self.tag_replayed( entry_id, &RECEIVABLES, Some(amount), arap::DocTag { doc, movement: arap::Movement::Payment, }, ); self.tag_customer(entry_id, customer); self.payment_events.entry(doc).or_default().push(amount); self.refresh_doc(doc); 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 vendor = self .bills .get(&bill_id) .ok_or(LedgerError::UnknownBill(bill_id))? .vendor_id; let doc = arap::DocRef::Bill(bill_id); self.tag_replayed( entry_id, &PAYABLES, Some(amount), arap::DocTag { doc, movement: arap::Movement::Payment, }, ); self.tag_vendor(entry_id, vendor); self.payment_events.entry(doc).or_default().push(amount); self.refresh_doc(doc); Ok(()) } /// A/R aging as of a date: one row per customer with an outstanding /// balance, bucketed by days past due. Refused (TooLargeToCompute), /// never a panic, when a sum is past exact arithmetic: documents are /// restored as journaled (rules.md B-28). /// /// ```text /// GOAL: what each customer owes as of a day, by how overdue. /// SOURCE: docs/spec/rules.md B-18, B-38 INVARIANT 5. /// INVARIANTS: /// 1. Each invoice issued by `as_of` that owes something then /// (Self::doc_state's outstanding) is in exactly one bucket, at /// that amount: the buckets partition what the invoices owe. /// 2. A void invoice, a paid one, and one dated after `as_of` are /// left out; a payment reversed after `as_of` still counts as /// paid, one reversed by then doesn't. /// 3. One owing less than nothing (a credit balance, in books an /// earlier version left out of bounds) is left out too: it is /// owed back, not receivable (ASC 310-10), and /// Self::control_check lists it. So the buckets sum to what the /// invoices owing something owe: the documents' total less those /// credit balances. /// ``` pub fn ar_aging(&self, as_of: NaiveDate) -> Result, LedgerError> { self.aging(as_of, true) } /// A/P aging as of a date: one row per vendor with an outstanding /// balance, bucketed by days past due; as [`Self::ar_aging`] is. pub fn ap_aging(&self, as_of: NaiveDate) -> Result, LedgerError> { self.aging(as_of, false) } /// [`Self::ar_aging`] (`invoices`) or [`Self::ap_aging`]. fn aging(&self, as_of: NaiveDate, invoices: bool) -> Result, LedgerError> { let mut by_party: HashMap = HashMap::new(); for doc in self.documents() { let (party, name) = match doc { arap::DocRef::Invoice(id) if invoices => { let c = self.invoices[&id].customer_id; (c, self.customers.get(&c).map(|c| c.name.clone())) } arap::DocRef::Bill(id) if !invoices => { let v = self.bills[&id].vendor_id; (v, self.vendors.get(&v).map(|v| v.name.clone())) } _ => continue, }; let Some(st) = self.doc_state(doc, as_of)? else { continue; }; // Nothing owed, or a credit balance (Overpaid, or a void under // a payment that stands): no receivable or payable to age; the // control check lists the credit balances. if st.outstanding <= Decimal::ZERO { continue; } let (_, due_date, ..) = self.doc_header(doc)?; let row = arap::aging_bucket(due_date, as_of, st.outstanding); by_party .entry(party) .or_insert_with(|| AgingRow { name: name.unwrap_or_default(), current: Decimal::ZERO, days_30: Decimal::ZERO, days_60: Decimal::ZERO, days_90: Decimal::ZERO, days_90_plus: Decimal::ZERO, }) .add(&row)?; } let mut rows: Vec = by_party.into_values().collect(); rows.sort_by(|a, b| a.name.cmp(&b.name)); Ok(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))?; // A statement says what an account holds: no more than a line may // move, so the difference is exact (P0 review round 3, S1). if !kernel::Amount::in_line_range(statement_balance) { return Err(LedgerError::ValueOutOfRange(format!( "statement balance {statement_balance}" ))); } 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.checked_sub(statement_balance).ok_or_else(|| { LedgerError::TooLargeToCompute("the reconciliation's difference".into()) })?, 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)?; // A line that names its employee names a party with that role. for line in lines { if let Some(id) = line.employee { let p = self.parties.get(&id).ok_or(LedgerError::UnknownParty(id))?; if !p.roles.contains(&crate::Role::Employee) { return Err(LedgerError::InvalidParty(format!( "{} isn't an employee (give the party the employee role first)", p.label() ))); } } } 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. /// 3. When every line with net pay names its employee party, the entry /// names those parties (Self::entry_parties), each with its net pay /// (summed over its lines): the whole of it for one employee, else /// an exact amount each, so each employee's books.party counts what /// they were paid. Lines named by employee_name only (or shares /// Self::set_entry_parties would refuse) leave the entry naming no /// party (the agent tags it). /// SCENARIOS: /// 1. IF any amount is negative, gross is zero, or withholdings exceed /// gross: SHOULD refuse. /// 2. Pat (net 1,500.00) and Sam (net 900.00), both parties: the entry /// names Pat 1,500.00 and Sam 900.00. /// ``` 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)?; self.name_payroll_parties(entry, &lines)?; 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) } /// [`Self::run_payroll`] INVARIANT 3: name the employees a payroll /// entry paid as its parties, each with their net pay. Live and on /// replay ([`Self::replay_payroll_run`]) alike. fn name_payroll_parties( &mut self, entry: EntryId, lines: &[PayrollLine], ) -> Result<(), LedgerError> { let mut paid: Vec<(crate::PartyId, Decimal)> = Vec::new(); let mut unnamed = false; for l in lines { // A run is restored as journaled (rules.md B-28): its figures // are checked, and a net past exact arithmetic refused. let net = l.net_pay()?; if net.is_zero() { continue; } match l.employee { Some(p) => match paid.iter_mut().find(|(x, _)| *x == p) { Some((_, v)) => { *v = v.checked_add(net).ok_or_else(|| { LedgerError::TooLargeToCompute(format!("party {p}'s net pay")) })? } None => paid.push((p, net)), }, None => unnamed = true, } } if !unnamed && !paid.is_empty() { let shares: Vec = match paid.as_slice() { [(p, _)] => vec![crate::PartyShare::whole(*p)], _ => paid .iter() .map(|&(party, v)| crate::PartyShare { party, share: crate::Share::Amount(v), }) .collect(), }; // Shares set_entry_parties would refuse (two employees with // vendor records) leave the entry for the agent to tag. if self.check_set_entry_parties(entry, &shares).is_ok() { self.set_entry_parties(entry, &shares)?; } } Ok(()) } /// 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)); } // Restored exactly as journaled: the run's figures are checked by // the command, not by replay (rules.md B-28). self.next_payroll_run_id += 1; if self.entries.contains_key(&entry_id) { self.name_payroll_parties(entry_id, &lines)?; } self.payroll_runs.insert( id, PayrollRun { id, date, lines, entry_id, }, ); Ok(()) } // ------------------------------------------------------------------ // Settings, conventions, decisions, open items, approvals // ------------------------------------------------------------------ /// The settings the journal holds ([`crate::settings`]). pub fn settings(&self) -> &crate::settings::Settings { &self.settings } /// Check a settings change before it is journaled: every value /// canonical, changes that change nothing left out, the result whole /// (crate::settings INVARIANTS 1, 2, 4), and the fiscal year never /// moved under a closed period. Changes nothing. pub fn check_settings( &self, changes: &[crate::settings::Change], source: &crate::settings::Source, ) -> Result, LedgerError> { self.check_settings_adopting(changes, source, false) } /// [`Self::check_settings`] for books moving a settings file in /// (`adopting`): their first `settings.set` takes the fiscal year they /// were kept under (the import proves it is the file's), so it moves /// nothing, closed periods or not. pub fn check_settings_adopting( &self, changes: &[crate::settings::Change], source: &crate::settings::Source, adopting: bool, ) -> Result, LedgerError> { let made = self .settings .check_changes(changes, source) .map_err(LedgerError::InvalidSetting)?; // The fiscal year can't move under closed periods: they were // closed by it, and the year's earnings would restate (a tax year // changes only with the IRS's consent, Form 1128). Books moving a // settings file in take the year they were kept under. if !(adopting && !self.settings.is_journaled()) && made.iter().any(|c| c.key == "company.fiscal_year_start") { let mut next = self.settings.clone(); next.apply(&made, source, 0) .map_err(LedgerError::InvalidSetting)?; let (before, after) = ( self.settings.company("").fiscal_year_start, next.company("").fiscal_year_start, ); if before != after && self .periods .values() .any(|p| p.status == crate::PeriodStatus::Closed) { return Err(LedgerError::InvalidSetting(format!( "company.fiscal_year_start can't change from {:02}-{:02} to {:02}-{:02}: periods are closed under it (a changed tax year needs the IRS's consent, Form 1128; ask the CPA)", before.0, before.1, after.0, after.1 ))); } } Ok(made) } /// Apply a journaled settings change (`settings.set` at `seq`): as /// checked when made, or as replayed (shape only, rules.md B-28). pub fn apply_settings( &mut self, changes: &[crate::settings::Change], source: &crate::settings::Source, seq: u64, ) -> Result<(), LedgerError> { self.settings .apply(changes, source, seq) .map_err(LedgerError::InvalidSetting) } /// Conventions, decisions, open items and approvals. pub fn conventions(&self) -> &crate::conventions::Conventions { &self.conventions } /// Check a convention before it is journaled: its id, text and /// structure (crate::conventions::check_new), and that every account a /// posting template or approval rule names is in the chart (a /// template's, one that takes postings). Returns it as it will be /// journaled; changes nothing. pub fn check_convention( &self, c: &crate::conventions::NewConvention, ) -> Result { let bad = LedgerError::InvalidConvention; let c = crate::conventions::check_new(c).map_err(bad)?; let old = self.conventions.convention(&c.id); if old.is_some_and(|o| o.retired.is_some()) { return Err(bad(format!( "convention {} is retired; ids are never reused: choose a new one", c.id ))); } if let Some(o) = old.filter(|o| o.kind != c.kind) { return Err(bad(format!( "convention {} is a {}; its kind can't change (retire it and set a new id)", c.id, o.kind.key() ))); } let probe = crate::conventions::Convention { id: c.id.clone(), kind: c.kind, title: c.title.clone(), body_md: c.body_md.clone(), structured: c.structured.clone(), source: c.source.clone(), seq: 0, revision: 1, retired: None, }; if let Some(t) = probe.template() { for l in t .lines .iter() .filter(|l| crate::conventions::slot(&l.account).is_none()) { let a = self.chart.find_by_code(&l.account).ok_or_else(|| { bad(format!( "template account {} is not in the chart", l.account )) })?; if a.placeholder { return Err(bad(format!( "template account {} is a placeholder; templates post to accounts that take postings", l.account ))); } } } if let Some(r) = probe.approval_rule() { for code in &r.accounts { match self.chart.find_by_code(code) { None => { return Err(bad(format!( "approval rule account {code} is not in the chart" ))); } Some(a) if a.placeholder => { return Err(bad(format!( "approval rule account {code} is a placeholder (a heading), which no line posts to: name the accounts under it" ))); } Some(_) => {} } } } if old.is_some_and(|o| { o.title == c.title && o.body_md == c.body_md && o.structured == c.structured }) { return Err(bad(format!( "convention {} already says exactly that", c.id ))); } Ok(c) } /// Apply a journaled `convention.set` at `seq` (checked when made; /// replay takes it as journaled). pub fn apply_convention( &mut self, c: crate::conventions::NewConvention, seq: u64, ) -> Result<(), LedgerError> { self.conventions .apply_convention(c, seq) .map_err(LedgerError::InvalidConvention) } /// Check a retirement before it is journaled: the convention is live, /// the reason and source are sound. pub fn check_retire_convention( &self, id: &str, reason: &str, source: &crate::settings::Source, ) -> Result { let bad = LedgerError::InvalidConvention; source.check().map_err(bad)?; let reason = crate::settings::long_text("reason", reason, crate::settings::MAX_LONG_TEXT) .map_err(bad)?; match self.conventions.convention(id) { None => Err(bad(format!( "unknown convention {id:?}: books.conventions lists them" ))), Some(c) if c.retired.is_some() => { Err(bad(format!("convention {id} is already retired"))) } Some(_) => Ok(reason), } } /// Apply a journaled `convention.retired` at `seq`. pub fn apply_retire_convention( &mut self, id: &str, reason: String, source: crate::settings::Source, seq: u64, ) -> Result<(), LedgerError> { self.conventions .apply_retire(id, reason, source, seq) .map_err(LedgerError::InvalidConvention) } /// Check a decision before it is journaled; returns it as it will be /// journaled, under the id it will get. Changes nothing. (Its /// evidence attachment is the store's to check.) pub fn check_decision( &self, d: &crate::conventions::NewDecision, ) -> Result<(u64, crate::conventions::NewDecision), LedgerError> { let bad = LedgerError::InvalidConvention; let d = crate::conventions::check_decision(d).map_err(bad)?; for (i, p) in d.projects.iter().enumerate() { if !self.classes.contains_key(p) { return Err(LedgerError::UnknownClass(*p)); } if d.projects[..i].contains(p) { return Err(bad(format!("project {p} is named twice"))); } } if let Some(old) = d.supersedes { match self.conventions.decision(old) { None => { return Err(bad(format!( "decision {old} doesn't exist: books.decisions lists them" ))); } Some(o) if o.superseded_by.is_some() => { return Err(bad(format!( "decision {old} was already superseded by {}", o.superseded_by.unwrap_or_default() ))); } Some(_) => {} } } Ok((self.conventions.next_decision_id(), d)) } /// Apply a journaled `decision.recorded` under `id` at `seq`. pub fn apply_decision( &mut self, id: u64, d: crate::conventions::NewDecision, seq: u64, ) -> Result<(), LedgerError> { self.conventions .apply_decision(id, d, seq) .map_err(LedgerError::InvalidConvention) } /// Check an open item before it is journaled; returns it, under the /// id it will get. pub fn check_open_item( &self, i: &crate::conventions::NewOpenItem, ) -> Result<(u64, crate::conventions::NewOpenItem), LedgerError> { let i = crate::conventions::check_open_item(i).map_err(LedgerError::InvalidConvention)?; Ok((self.conventions.next_open_item_id(), i)) } /// Apply a journaled `open_item.raised` under `id` at `seq`. pub fn apply_open_item( &mut self, id: u64, i: crate::conventions::NewOpenItem, seq: u64, ) -> Result<(), LedgerError> { self.conventions .apply_raise(id, i, seq) .map_err(LedgerError::InvalidConvention) } /// Check a resolution before it is journaled: the item is open, the /// text and source are sound. pub fn check_resolve_item( &self, id: u64, resolution: &str, source: &crate::settings::Source, ) -> Result { let bad = LedgerError::InvalidConvention; source.check().map_err(bad)?; let text = crate::settings::long_text("resolution", resolution, crate::settings::MAX_LONG_TEXT) .map_err(bad)?; match self.conventions.open_item(id) { None => Err(bad(format!( "unknown open item {id}: books.open_items lists them" ))), Some(i) if i.resolved.is_some() => { Err(bad(format!("open item {id} is already resolved"))) } Some(_) => Ok(text), } } /// Apply a journaled `open_item.resolved` at `seq`. pub fn apply_resolve_item( &mut self, id: u64, resolution: String, source: crate::settings::Source, seq: u64, ) -> Result<(), LedgerError> { self.conventions .apply_resolve(id, resolution, source, seq) .map_err(LedgerError::InvalidConvention) } /// Check an approval before it is journaled: `entry` is a draft, the /// approver is named, the note and source are sound. Returns the /// approval as it will be journaled (its fingerprint is the draft's /// now: crate::conventions INVARIANT 3). pub fn check_approval( &self, entry: EntryId, approver: &str, note: Option<&str>, source: &crate::settings::Source, ) -> Result { let bad = LedgerError::InvalidConvention; source.check().map_err(bad)?; let e = self .entries .get(&entry) .ok_or(LedgerError::UnknownEntry(entry))?; if e.status() == EntryStatus::Posted { return Err(LedgerError::EntryAlreadyPosted(entry)); } Ok(crate::conventions::Approval { approver: crate::settings::line_text("approver", approver, crate::settings::MAX_TEXT) .map_err(bad)?, fingerprint: self.approval_fingerprint(e), note: note .map(|n| crate::settings::long_text("note", n, crate::settings::MAX_LONG_TEXT)) .transpose() .map_err(bad)?, source: source.clone(), seq: 0, }) } /// Apply a journaled `entry.approved` at `seq` (the entry must exist). pub fn apply_approval( &mut self, entry: EntryId, approval: crate::conventions::Approval, ) -> Result<(), LedgerError> { if !self.entries.contains_key(&entry) { return Err(LedgerError::UnknownEntry(entry)); } self.conventions.apply_approval(entry, approval); Ok(()) } /// An entry's fingerprint for approvals, with who it is with /// (crate::conventions::fingerprint). fn approval_fingerprint(&self, e: &JournalEntry) -> String { crate::conventions::fingerprint( e, self.entry_parties .get(&e.id()) .map_or(&[][..], Vec::as_slice), self.entry_no_party.get(&e.id()).map(String::as_str), ) } /// The approvals `entry` still needs before it posts: each live /// approval rule covering it whose approver hasn't approved it as it /// stands, as `(rule id, approver)`. Empty under /// `policy.approval_rules = off` (crate::conventions INVARIANT 3). pub fn approvals_missing(&self, entry: EntryId) -> Result, LedgerError> { if self.settings.approval_rules() == crate::settings::ApprovalMode::Off { return Ok(Vec::new()); } let e = self .entries .get(&entry) .ok_or(LedgerError::UnknownEntry(entry))?; let codes: Vec<&str> = e .lines() .iter() .filter_map(|l| self.chart.get(l.account).map(|a| a.code.as_str())) .collect(); let debits = e .lines() .iter() .filter(|l| l.side == Side::Debit) .try_fold(Decimal::ZERO, |t, l| t.checked_add(l.amount.amount)) .ok_or_else(|| LedgerError::TooLargeToCompute(format!("entry {entry}'s debits")))?; let approvals = self.conventions.approvals(entry); let key = crate::parties::name_key; let mut missing = Vec::new(); for (c, r) in self.conventions.approval_rules() { // A rule this version can't read covers everything (fail // closed): no approver it names can be matched. let Some(r) = r else { missing.push((c.id.clone(), "an approver this version can't read".into())); continue; }; let by_account = r.accounts.is_empty() || r.accounts.iter().any(|a| codes.contains(&a.as_str())); let by_amount = r .amount_over .as_deref() .and_then(crate::settings::decimal) .is_none_or(|over| debits > over); let covered = by_account && by_amount; let now = self.approval_fingerprint(e); let approved = approvals .iter() .any(|a| key(&a.approver) == key(&r.approver) && a.fingerprint == now); if covered && !approved { missing.push((c.id.clone(), r.approver.clone())); } } Ok(missing) } /// The lines a posting template drafts for `amount` (crate:: /// conventions INVARIANT 2): `(account, side, amount)`, each side /// summing to `amount` exactly, and the template's description. /// `slots` names the account (by code) for each of the template's /// slots (`{expense}` → `"expense"`): every slot, and nothing else. pub fn template_lines( &self, template_id: &str, amount: Decimal, slots: &std::collections::BTreeMap, ) -> Result { let bad = LedgerError::InvalidConvention; let c = self.conventions.convention(template_id).ok_or_else(|| { bad(format!( "unknown convention {template_id:?}: books.conventions lists the templates" )) })?; if c.retired.is_some() { return Err(bad(format!("template {template_id} is retired"))); } let t = c.template().ok_or_else(|| { bad(format!( "convention {template_id} is a {}, not a posting template", c.kind.key() )) })?; let wanted: std::collections::BTreeSet<&str> = t .lines .iter() .filter_map(|l| crate::conventions::slot(&l.account)) .collect(); let missing: Vec<&str> = wanted .iter() .copied() .filter(|w| !slots.contains_key(*w)) .collect(); if !missing.is_empty() { return Err(bad(format!( "template {template_id} needs an account for {}: pass accounts {{{}}}", missing.join(", "), missing .iter() .map(|m| format!("\"{m}\": \"\"")) .collect::>() .join(", ") ))); } if let Some(extra) = slots.keys().find(|k| !wanted.contains(k.as_str())) { return Err(bad(format!( "template {template_id} has no slot {extra:?}{}", if wanted.is_empty() { String::new() } else { format!( "; its slots: {}", wanted.iter().copied().collect::>().join(", ") ) } ))); } if amount <= Decimal::ZERO { return Err(bad(format!("amount {amount} must be above zero"))); } if amount > crate::kernel::MAX_LINE_AMOUNT { return Err(LedgerError::AmountOutOfRange); } check_minor_units(Money::new(amount, self.base_currency))?; let places = self.base_currency.minor_units(); let mut out = Vec::new(); for side in [Side::Debit, Side::Credit] { let lines: Vec<_> = t.lines.iter().filter(|l| l.side == side).collect(); let shares: Vec = lines .iter() .map(|l| crate::settings::decimal(&l.share).unwrap_or_default()) .collect(); let parts = crate::conventions::split(amount, &shares, places).ok_or_else(|| { LedgerError::TooLargeToCompute(format!("template {template_id} at {amount}")) })?; for (l, part) in lines.iter().zip(parts) { if part <= Decimal::ZERO { return Err(bad(format!( "{amount} is too small to split by template {template_id}: account {}'s part would be {part}", l.account ))); } let code = match crate::conventions::slot(&l.account) { Some(name) => slots[name].as_str(), None => l.account.as_str(), }; let a = self .chart .find_by_code(code) .ok_or_else(|| bad(format!("template account {code} is not in the chart")))?; if a.placeholder { return Err(bad(format!( "account {code} is a placeholder (a heading); a template posts to accounts that take postings" ))); } out.push((a.id, side, Money::new(part, self.base_currency))); } } Ok((t.description, out)) } // ------------------------------------------------------------------ // Classes // ------------------------------------------------------------------ /// Add a class (division) for line-level P&L tracking: a project with /// only its name (rules.md B-37). Returns its id. /// /// ```text /// GOAL: a new class under a name no other class has. /// SOURCE: docs/spec/rules.md B-37 item 1. /// SCENARIOS: /// 1. IF the name is blank: SHOULD refuse (EmptyClassName). /// 2. IF it is longer than a line, holds a control character or a /// full TIN: SHOULD refuse. /// 3. IF another class has it, ignoring capitals and surrounding /// space: SHOULD refuse (DuplicateClassName). Replay never checks /// (replay_class), so old books holding a duplicate still open. /// 4. IF it is digits only: SHOULD refuse (it would read as an id). /// ``` pub fn add_class(&mut self, name: &str) -> Result { let name = self.check_class_name(name, None)?; let id = self.next_class_id; self.next_class_id += 1; self.classes.insert(id, Class { id, name }); Ok(id) } /// A class name, trimmed, if a class (other than `except`) may take it. /// /// ```text /// GOAL: one rule for every name a command gives a class. /// SOURCE: docs/spec/rules.md B-37 item 1; B-26 (no full TIN). /// SCENARIOS: /// 1. IF `except` is a class and the name is its own, exactly: SHOULD /// keep it as stored, unchecked, so a fact-only change never /// renames (an earlier version stored names untrimmed) and never /// refuses a name the books already hold. /// 2. IF it is the class's own name up to capitals and surrounding /// space: SHOULD check its text but not its uniqueness, so old /// books holding a duplicate (replay never checks) can still /// change either class. /// 3. IF it is digits only: SHOULD refuse, since a project is named /// by id or name and such a name would read as an id. /// 4. IF another class has it, ignoring capitals and surrounding /// space: SHOULD refuse (DuplicateClassName). /// ``` fn check_class_name(&self, name: &str, except: Option) -> Result { let current = except.and_then(|id| self.classes.get(&id)); if let Some(c) = current && c.name == name { return Ok(c.name.clone()); } if name.trim().is_empty() { return Err(LedgerError::EmptyClassName); } let name = crate::settings::line_text("project name", name, crate::settings::MAX_TEXT) .map_err(LedgerError::InvalidProject)?; let folded = fold_class_name(&name); if current.is_some_and(|c| fold_class_name(&c.name) == folded) { return Ok(name); } if name.chars().all(|c| c.is_ascii_digit()) { return Err(LedgerError::InvalidProject(format!( "project name {name:?} is digits only, so it would read as a project id: add a word (\"Job {name}\")" ))); } if self .classes .values() .any(|c| Some(c.id) != except && fold_class_name(&c.name) == folded) { return Err(LedgerError::DuplicateClassName(name)); } Ok(name) } /// 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, ignoring capitals (in any script) and /// surrounding space: the fold the duplicate check uses. Among old /// books' duplicates (replay never checks), the lowest id. pub fn find_class_by_name(&self, name: &str) -> Option<&Class> { let folded = fold_class_name(name); self.classes .values() .filter(|c| fold_class_name(&c.name) == folded) .min_by_key(|c| c.id) } /// 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(()) } /// A class's project facts: the ones set, or the defaults for a class /// that has none (every class an earlier version wrote). `None` for no /// such class. pub fn project_facts(&self, id: ClassId) -> Option { self.classes.get(&id)?; Some(self.projects.get(&id).cloned().unwrap_or_default()) } /// The projects for or with a party, by name (ignoring capitals). pub fn projects_of_party(&self, party: crate::PartyId) -> Vec { let mut ids: Vec = self .projects .iter() .filter(|(_, f)| f.party == Some(party)) .map(|(id, _)| *id) .filter(|id| self.classes.contains_key(id)) .collect(); ids.sort_by_key(|id| { let c = &self.classes[id]; (c.name.to_lowercase(), c.id) }); ids } /// Check a whole project record before it is journaled: returns the /// trimmed name and the normalized facts. Changes nothing. /// /// ```text /// GOAL: refuse any project record the books can't take, before a /// line is written. /// SOURCE: docs/spec/rules.md B-37 item 1 (and B-26: no full TIN). /// SCENARIOS: /// 1. IF the class doesn't exist: SHOULD refuse (UnknownClass). /// 2. IF the name is blank, too long, or another class has it /// (ignoring capitals; the class itself excepted): SHOULD refuse. /// 3. IF a fact breaks its rule (ProjectFacts::check): SHOULD refuse. /// 4. IF the party is not in the directory: SHOULD refuse. /// ``` pub fn check_set_project( &self, id: ClassId, name: &str, facts: &crate::classes::ProjectFacts, ) -> Result<(String, crate::classes::ProjectFacts), LedgerError> { if !self.classes.contains_key(&id) { return Err(LedgerError::UnknownClass(id)); } let name = self.check_class_name(name, Some(id))?; let facts = facts.check().map_err(LedgerError::InvalidProject)?; if let Some(p) = facts.party && self.get_party(p).is_none() { return Err(LedgerError::InvalidProject(format!( "party {p} is not in the directory: books.parties lists them" ))); } Ok((name, facts)) } /// Apply a project record after [`Ledger::check_set_project`]: rename /// the class, keep the facts. pub fn set_project( &mut self, id: ClassId, name: &str, facts: crate::classes::ProjectFacts, ) -> Result<(), LedgerError> { let (name, facts) = self.check_set_project(id, name, &facts)?; self.put_project(id, name, facts); Ok(()) } /// Replay a journaled project record: only what replay can't do /// without (the class and the party exist), never the text rules, so a /// rule tightened later never breaks old books (as /// `AssetBasis::check_journaled`, rules.md B-25). #[doc(hidden)] pub fn replay_set_project( &mut self, id: ClassId, name: String, facts: crate::classes::ProjectFacts, ) -> Result<(), LedgerError> { if !self.classes.contains_key(&id) { return Err(LedgerError::UnknownClass(id)); } if let Some(p) = facts.party && self.get_party(p).is_none() { return Err(LedgerError::InvalidProject(format!( "party {p} is not in the directory" ))); } self.put_project(id, name, facts); Ok(()) } fn put_project(&mut self, id: ClassId, name: String, facts: crate::classes::ProjectFacts) { if let Some(c) = self.classes.get_mut(&id) { c.name = name; } if facts.is_default() { self.projects.remove(&id); } else { self.projects.insert(id, facts); } } } /// A class name as names compare (rules.md B-37 item 1): trimmed, in /// lower case (Unicode's, so `Éclair` and `éCLAIR` are one name). fn fold_class_name(name: &str) -> String { name.trim().to_lowercase() } /// 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(()), } } /// Refuse a new line larger than any posting could carry /// (kernel::MAX_LINE_AMOUNT; P0 review round 3, S1). Only the size: the /// minor unit is check_minor_units', and the post checks the full range. fn check_line_size(amount: Decimal) -> Result<(), LedgerError> { if amount.abs() > kernel::MAX_LINE_AMOUNT { return Err(LedgerError::AmountOutOfRange); } Ok(()) } /// A members close's (capital account, share) list, in order: what a run /// of run-rule closes must share in that order, and a count of cumulative /// closes as a set ([`same_member_set`]). fn member_key(members: &[periods::MemberClose]) -> Vec<(AccountId, Decimal)> { members.iter().map(|m| (m.capital, m.share)).collect() } /// Two members closes' (capital account, share) lists name the same /// members with the same shares, in any order (rules.md B-13: a reorder of /// the members is not a change of members). Shares compare by value, so /// 0.5 is 0.50. fn same_member_set(a: &[(AccountId, Decimal)], b: &[(AccountId, Decimal)]) -> bool { let sorted = |v: &[(AccountId, Decimal)]| { let mut v = v.to_vec(); v.sort_unstable(); v }; a.len() == b.len() && sorted(a) == sorted(b) } /// A vendor invoice number as the duplicate check compares it: lower case, /// with spaces and hyphens removed ("ACME-123" and "acme 123" are the /// same number; rules.md B-40). fn normalize_vendor_ref(vendor_ref: &str) -> String { vendor_ref .chars() .filter(|c| !c.is_whitespace() && *c != '-') .flat_map(char::to_lowercase) .collect() } /// A members regime ([`Ledger::members_regime`]): the members closes with /// one set of members and shares since the last close with another. #[derive(Clone, Debug, PartialEq)] pub(crate) struct Regime { /// The close the count starts at (its [`Period::close_seq`]); `None` /// when no members close with this set is counted yet. pub since_seq: Option, /// The net those closes closed, together (negative for a loss). pub closed: Decimal, /// What those closes credited each member, in the order asked for, by /// capital account. pub given: Vec, /// The members as the count's first close listed them: the order its /// tied odd units go in. As of a day before any close of a count, the /// count the next members close begins, when it is these members' /// ([`Ledger::members_regime`]). Empty when there is none. pub order: Vec<(AccountId, Decimal)>, } impl Regime { /// [`periods::allocate_cumulative`] of `total` among `members` against /// `given` (both in `members`' order), the tied odd units to the member /// the count's first close listed first ([`Regime::order`]; `members`' /// own order when no close has begun the count), the parts back in `members`' /// order. So a count's ties go to the same member whatever order later /// closes, statements or the settings list the members in, and a /// reorder of the members moves no cent (rules.md B-13, D-38). pub(crate) fn allocate( &self, members: &[(AccountId, Decimal)], total: Decimal, given: &[Decimal], places: u32, ) -> Result, LedgerError> { let order = if self.order.is_empty() { members } else { &self.order }; let index: Vec = order .iter() .map(|(capital, _)| { members .iter() .position(|(c, _)| c == capital) .expect("a count's members are the members asked for") }) .collect(); let shares: Vec = index.iter().map(|&i| members[i].1).collect(); let given: Vec = index.iter().map(|&i| given[i]).collect(); let split = periods::allocate_cumulative(total, &shares, &given, places)?; let mut parts = vec![Decimal::ZERO; members.len()]; for (&i, part) in index.iter().zip(split) { parts[i] = part; } Ok(parts) } }