diff --git a/crates/ledger-core/src/ledger.rs b/crates/ledger-core/src/ledger.rs new file mode 100644 index 0000000..ccb1e9a --- /dev/null +++ b/crates/ledger-core/src/ledger.rs @@ -0,0 +1,225 @@ +//! 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::{ + AccountId, AccountType, ChartOfAccounts, Currency, EntryId, EntryStatus, JournalEntry, + JournalLine, LedgerError, Money, Side, +}; +use chrono::NaiveDate; +use rust_decimal::Decimal; +use std::collections::HashMap; + +/// The books. Single-currency (see R-5); owns id allocation. +#[derive(Debug)] +pub struct Ledger { + base_currency: Currency, + chart: ChartOfAccounts, + entries: HashMap, + /// Entry ids in posting order: the append-only log. + order: Vec, + next_account_id: AccountId, + next_entry_id: EntryId, +} + +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(), + next_account_id: 1, + next_entry_id: 1, + } + } + + /// 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. + pub fn add_account( + &mut self, + code: &str, + name: &str, + account_type: AccountType, + parent: Option, + placeholder: bool, + ) -> Result { + let id = self.next_account_id; + self.next_account_id += 1; + self.chart.add( + id, + code, + name, + account_type, + parent, + placeholder, + self.base_currency, + )?; + Ok(id) + } + + /// Start a draft journal entry. Returns its id. + 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 + } + + 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> { + 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, + }); + Ok(()) + } + + /// Validate and post a draft entry. One-way: a posted entry is immutable. + pub fn post_entry(&mut self, entry: EntryId) -> Result<(), LedgerError> { + let status = self + .entries + .get(&entry) + .ok_or(LedgerError::UnknownEntry(entry))? + .status(); + if status == EntryStatus::Posted { + return Err(LedgerError::EntryAlreadyPosted(entry)); + } + // Clone so validation borrows end before the mutable mark. + let snapshot = self + .entries + .get(&entry) + .ok_or(LedgerError::UnknownEntry(entry))? + .clone(); + snapshot.validate(&self.chart)?; + self.entries + .get_mut(&entry) + .ok_or(LedgerError::UnknownEntry(entry))? + .mark_posted(); + self.order.push(entry); + Ok(()) + } + + /// Look up an entry by id (draft or posted). + pub fn get_entry(&self, entry: EntryId) -> Option<&JournalEntry> { + self.entries.get(&entry) + } + + /// Post a reversing entry: same lines with sides flipped, linked back to + /// the original via `reversal_of`. The books never forget; they correct. + pub fn reverse_entry( + &mut self, + entry: EntryId, + date: NaiveDate, + description: &str, + ) -> Result { + let original = self + .entries + .get(&entry) + .ok_or(LedgerError::UnknownEntry(entry))? + .clone(); + if original.status() != EntryStatus::Posted { + return Err(LedgerError::EntryNotPosted(entry)); + } + let id = self.create_entry(date, description); + for line in original.lines() { + let flipped = match line.side { + Side::Debit => Side::Credit, + Side::Credit => Side::Debit, + }; + self.add_line(id, line.account, flipped, line.amount)?; + } + self.entries + .get_mut(&id) + .ok_or(LedgerError::UnknownEntry(id))? + .mark_reversal_of(entry); + self.post_entry(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)) + } + + /// 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 { + let mut total = Decimal::ZERO; + for entry in self.posted_entries() { + for line in entry.lines() { + if line.account == account { + match line.side { + Side::Debit => total += line.amount.amount, + Side::Credit => total -= line.amount.amount, + } + } + } + } + total + } + + /// Balance expressed in the account's normal side: positive means a + /// normal balance, negative means a contra balance. + 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 = match account.account_type.normal_side() { + Side::Debit => movement, + Side::Credit => -movement, + }; + // 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 + }) + } +}