diff --git a/crates/ledger-core/src/ledger.rs b/crates/ledger-core/src/ledger.rs index 3b59a2e..59bf9d6 100644 --- a/crates/ledger-core/src/ledger.rs +++ b/crates/ledger-core/src/ledger.rs @@ -7,13 +7,50 @@ //! audit trail and what will make the future oracle harness trustworthy. use crate::{ - AccountId, AccountType, Asset, AssetId, AssetStatus, ChartOfAccounts, Currency, EntryId, - EntryStatus, JournalEntry, JournalLine, LedgerError, Money, NewAsset, Side, assets, + AccountId, AccountType, AgingRow, Asset, AssetId, AssetStatus, Bill, BillId, ChartOfAccounts, + Currency, Customer, CustomerId, DocumentLine, DocumentStatus, EntryId, EntryStatus, Invoice, + InvoiceId, JournalEntry, JournalLine, LedgerError, Money, NewAsset, Period, PeriodId, + PeriodStatus, Side, Vendor, VendorId, arap, assets, periods, }; use chrono::{Months, NaiveDate}; use rust_decimal::Decimal; +use serde::{Deserialize, Serialize}; use std::collections::HashMap; +/// A posted line that has not cleared, for the reconciliation report. +#[derive(Clone, Debug, Serialize, Deserialize)] +pub struct UnclearedLine { + /// Entry id. + pub entry_id: EntryId, + /// Index within the entry. + pub line_index: usize, + /// Entry date. + pub date: NaiveDate, + /// Entry description. + pub description: String, + /// Line side. + pub side: Side, + /// Line amount (base currency). + pub amount: Decimal, +} + +/// The bank reconciliation report. +#[derive(Clone, Debug, Serialize, Deserialize)] +pub struct ReconciliationReport { + /// Account id reconciled. + pub account: AccountId, + /// Sum of all posted lines on the account (book balance). + pub book_balance: Decimal, + /// Sum of cleared lines dated on or before the statement date. + pub cleared_balance: Decimal, + /// The bank statement's balance. + pub statement_balance: Decimal, + /// cleared_balance - statement_balance. Zero means reconciled. + pub difference: Decimal, + /// Posted, uncleared lines dated on or before the statement date. + pub uncleared_lines: Vec, +} + /// The books. Single-currency (see R-5); owns id allocation. #[derive(Debug)] pub struct Ledger { @@ -24,9 +61,29 @@ pub struct Ledger { 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>, 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, } impl Ledger { @@ -38,9 +95,21 @@ impl Ledger { 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(), 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, } } @@ -66,6 +135,41 @@ impl Ledger { 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 currency every account and line in this ledger uses. pub fn base_currency(&self) -> Currency { self.base_currency @@ -100,6 +204,10 @@ impl Ledger { } /// 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; @@ -108,6 +216,31 @@ impl Ledger { 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. + pub fn try_create_entry( + &mut self, + date: NaiveDate, + description: &str, + ) -> Result { + if let Some(pid) = self.closed_period_containing(date) { + return Err(LedgerError::ClosedPeriod(pid, date.to_string())); + } + Ok(self.create_entry(date, description)) + } + + /// The id of a closed period containing `date`, if any. + /// + /// Pure query. Journaling stores use it to pre-validate dates before + /// journaling, so a journal write is never followed by a failed mutation. + pub fn closed_period_containing(&self, date: NaiveDate) -> Option { + self.periods + .values() + .find(|p| p.status == PeriodStatus::Closed && p.contains(date)) + .map(|p| p.id) + } + fn draft_mut(&mut self, entry: EntryId) -> Result<&mut JournalEntry, LedgerError> { let e = self .entries @@ -139,26 +272,71 @@ impl Ledger { account, side, amount, + foreign_currency: None, + foreign_amount: None, + }); + Ok(()) + } + + /// Add a foreign-currency line to a draft entry. `foreign_amount` is the + /// amount in `foreign_currency`; `fx_rate` converts foreign -> base + /// (base = foreign * rate). The stored `amount` is the base-currency + /// equivalent; the foreign original is kept for reference. Rates are + /// exact Decimals, never floats. + pub fn add_line_fx( + &mut self, + entry: EntryId, + account: AccountId, + side: Side, + foreign_currency: Currency, + foreign_amount: Decimal, + fx_rate: Decimal, + ) -> Result<(), LedgerError> { + 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 base_amount = foreign_amount * fx_rate; + if base_amount <= Decimal::ZERO { + return Err(LedgerError::InvalidFxRate(fx_rate)); + } + let base = self.base_currency; + let e = self.draft_mut(entry)?; + e.push_line(JournalLine { + account, + side, + amount: Money::new(base_amount, base), + foreign_currency: Some(foreign_currency), + foreign_amount: Some(foreign_amount), }); Ok(()) } /// Validate and post a draft entry. One-way: a posted entry is immutable. + /// + /// Rejects entries dated in a closed period. 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(); + 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) @@ -320,6 +498,753 @@ impl Ledger { Ok(()) } + // ------------------------------------------------------------------ + // Accounting periods + // ------------------------------------------------------------------ + + /// Create an accounting period. The date range must be valid + /// (end >= start). Returns the period id. + pub fn create_period( + &mut self, + name: &str, + start: NaiveDate, + end: NaiveDate, + ) -> Result { + periods::validate_period_range(start, end)?; + let id = self.next_period_id; + self.next_period_id += 1; + self.periods.insert( + id, + Period { + id, + name: name.to_string(), + start, + end, + status: PeriodStatus::Open, + closing_entry: None, + }, + ); + Ok(id) + } + + /// Look up a period by id. + pub fn get_period(&self, id: PeriodId) -> Option<&Period> { + self.periods.get(&id) + } + + /// 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. This: + /// 1. Rejects if the period is already closed. + /// 2. Rejects if any draft entries have dates inside the period. + /// 3. Posts a closing entry dated at period end: revenue accounts are + /// debited to zero, expense accounts are credited to zero, and the + /// net goes to Retained Earnings (account `3999`, created if missing). + /// 4. Marks the period closed. + /// + /// Returns the closing entry's id, or `None` if there was nothing to + /// close (no revenue/expense movement). + pub fn close_period(&mut self, id: PeriodId) -> Result, LedgerError> { + let period = self + .periods + .get(&id) + .ok_or(LedgerError::UnknownPeriod(id))? + .clone(); + if period.status == PeriodStatus::Closed { + return Err(LedgerError::PeriodAlreadyClosed(id)); + } + // No drafts may have dates inside the period. + let has_drafts = self + .entries + .values() + .any(|e| e.status() == EntryStatus::Draft && period.contains(e.date())); + if has_drafts { + return Err(LedgerError::DraftsInPeriod(id)); + } + + // Collect revenue and expense accounts with non-zero movement. + let mut closings: Vec<(AccountId, Side, Decimal)> = Vec::new(); + let mut accounts: Vec<_> = self.chart.all().collect(); + accounts.sort_by(|a, b| a.code.cmp(&b.code)); + for account in accounts { + match account.account_type { + AccountType::Revenue | AccountType::Expense => { + let movement = self.account_movement(account.id); + if movement.is_zero() { + continue; + } + // To zero the account: post the opposite of its net movement. + let (side, amount) = if movement > Decimal::ZERO { + (Side::Credit, movement) + } else { + (Side::Debit, -movement) + }; + closings.push((account.id, side, amount)); + } + _ => {} + } + } + + let closing_entry = if closings.is_empty() { + None + } else { + // Ensure Retained Earnings exists. + let re_id = match self.chart.find_by_code("3999") { + Some(a) => a.id, + None => self.add_account( + "3999", + "Retained Earnings", + AccountType::Equity, + None, + false, + )?, + }; + // Sum the closing lines to find the net for RE. + let mut debits = Decimal::ZERO; + let mut credits = Decimal::ZERO; + for (_, side, amount) in &closings { + match side { + Side::Debit => debits += *amount, + Side::Credit => credits += *amount, + } + } + let entry = self.create_entry(period.end, &format!("Close period {}", period.name)); + for (account, side, amount) in closings { + self.add_line(entry, account, side, Money::new(amount, self.base_currency))?; + } + // Balance with Retained Earnings. + if debits > credits { + // Profit: credit RE. + self.add_line( + entry, + re_id, + Side::Credit, + Money::new(debits - credits, self.base_currency), + )?; + } else if credits > debits { + // Loss: debit RE. + self.add_line( + entry, + re_id, + Side::Debit, + Money::new(credits - debits, self.base_currency), + )?; + } + self.post_entry(entry)?; + Some(entry) + }; + + self.periods.get_mut(&id).expect("period exists").status = PeriodStatus::Closed; + self.periods + .get_mut(&id) + .expect("period exists") + .closing_entry = closing_entry; + Ok(closing_entry) + } + + /// Pure pre-close check for journaling stores: verifies that + /// [`Self::close_period`] would succeed — the period exists and is open, + /// no draft entries are dated inside it, and the closing entry's date + /// (period end) is not inside another closed period — and returns the id + /// the closing entry will receive, or `None` when the close posts no + /// entry (no revenue/expense movement). [`Self::close_period`] repeats + /// these checks before mutating; this exists so stores can journal the + /// outcome before mutating, keeping a failed write from leaving a + /// journal line behind. + pub fn preview_close_period(&self, id: PeriodId) -> 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())); + } + // The close posts an entry exactly when some revenue or expense + // account has non-zero movement — the same predicate close_period + // uses to decide. The entry id is the next one the allocator hands + // out; account creation (e.g. Retained Earnings) does not consume + // entry ids. + let any_movement = self.chart.all().any(|a| { + matches!(a.account_type, AccountType::Revenue | AccountType::Expense) + && !self.account_movement(a.id).is_zero() + }); + Ok(any_movement.then_some(self.next_entry_id)) + } + + // ------------------------------------------------------------------ + // Accounts receivable / payable + // ------------------------------------------------------------------ + + /// Add a customer. Returns its id. + pub fn add_customer(&mut self, name: &str) -> Result { + if name.trim().is_empty() { + return Err(LedgerError::EmptyAssetDescription); + } + let id = self.next_customer_id; + self.next_customer_id += 1; + self.customers.insert( + id, + Customer { + id, + name: name.to_string(), + }, + ); + Ok(id) + } + + /// Add a vendor. Returns its id. + pub fn add_vendor(&mut self, name: &str) -> Result { + if name.trim().is_empty() { + return Err(LedgerError::EmptyAssetDescription); + } + let id = self.next_vendor_id; + self.next_vendor_id += 1; + self.vendors.insert( + id, + Vendor { + id, + name: name.to_string(), + }, + ); + 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) + } + + /// 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() + } + + /// Ensure an account exists by code, creating it with the given type + /// and name if missing. Returns the account id. + fn ensure_account(&mut self, code: &str, name: &str, account_type: AccountType) -> AccountId { + if let Some(a) = self.chart.find_by_code(code) { + return a.id; + } + self.add_account(code, name, account_type, None, false) + .expect("fresh code cannot fail") + } + + /// Create a sales invoice. Auto-posts: Dr `1200` Accounts Receivable, + /// Cr `4000` Revenue. The invoice date must not fall in a closed period. + pub fn create_invoice( + &mut self, + customer_id: CustomerId, + date: NaiveDate, + due_date: NaiveDate, + lines: Vec, + ) -> Result { + if !self.customers.contains_key(&customer_id) { + return Err(LedgerError::UnknownCustomer(customer_id)); + } + arap::validate_lines(&lines)?; + arap::validate_due_date(date, due_date)?; + if let Some(pid) = self.closed_period_containing(date) { + return Err(LedgerError::ClosedPeriod(pid, date.to_string())); + } + let total: Decimal = lines.iter().map(|l| l.amount).sum(); + let ar = self.ensure_account("1200", "Accounts Receivable", AccountType::Asset); + let revenue = self.ensure_account("4000", "Revenue", AccountType::Revenue); + let entry = self.create_entry(date, &format!("Invoice {}", self.next_invoice_id)); + let money = Money::new(total, self.base_currency); + self.add_line(entry, ar, Side::Debit, money)?; + self.add_line(entry, revenue, Side::Credit, money)?; + self.post_entry(entry)?; + let id = self.next_invoice_id; + self.next_invoice_id += 1; + self.invoices.insert( + id, + Invoice { + id, + customer_id, + date, + due_date, + lines, + amount_paid: Decimal::ZERO, + status: DocumentStatus::Open, + entry_id: entry, + }, + ); + Ok(id) + } + + /// Create a vendor bill. Auto-posts: Dr `6999` General Expenses, + /// Cr `2000` Accounts Payable. The bill date must not fall in a closed + /// period. + pub fn create_bill( + &mut self, + vendor_id: VendorId, + date: NaiveDate, + due_date: NaiveDate, + lines: Vec, + ) -> Result { + if !self.vendors.contains_key(&vendor_id) { + return Err(LedgerError::UnknownVendor(vendor_id)); + } + arap::validate_lines(&lines)?; + arap::validate_due_date(date, due_date)?; + if let Some(pid) = self.closed_period_containing(date) { + return Err(LedgerError::ClosedPeriod(pid, date.to_string())); + } + let total: Decimal = lines.iter().map(|l| l.amount).sum(); + let expense = self.ensure_account("6999", "General Expenses", AccountType::Expense); + let ap = self.ensure_account("2000", "Accounts Payable", AccountType::Liability); + let entry = self.create_entry(date, &format!("Bill {}", self.next_bill_id)); + let money = Money::new(total, self.base_currency); + self.add_line(entry, expense, Side::Debit, money)?; + self.add_line(entry, ap, Side::Credit, money)?; + self.post_entry(entry)?; + let id = self.next_bill_id; + self.next_bill_id += 1; + self.bills.insert( + id, + Bill { + id, + vendor_id, + date, + due_date, + lines, + amount_paid: Decimal::ZERO, + status: DocumentStatus::Open, + entry_id: entry, + }, + ); + Ok(id) + } + + /// Look up an invoice. + pub fn get_invoice(&self, id: InvoiceId) -> Option<&Invoice> { + self.invoices.get(&id) + } + + /// Look up a bill. + pub fn get_bill(&self, id: BillId) -> Option<&Bill> { + self.bills.get(&id) + } + + /// Record a customer payment against an invoice. Posts: Dr Cash, + /// Cr A/R. Updates the invoice's paid amount and status. + pub fn receive_payment( + &mut self, + invoice_id: InvoiceId, + date: NaiveDate, + amount: Decimal, + cash_account: AccountId, + ) -> Result { + if amount <= Decimal::ZERO { + return Err(LedgerError::InvalidPaymentAmount(amount)); + } + let invoice = self + .invoices + .get(&invoice_id) + .ok_or(LedgerError::UnknownInvoice(invoice_id))? + .clone(); + let balance = invoice.balance(); + if amount > balance { + return Err(LedgerError::Overpayment(amount, balance)); + } + if let Some(pid) = self.closed_period_containing(date) { + return Err(LedgerError::ClosedPeriod(pid, date.to_string())); + } + let ar = self.ensure_account("1200", "Accounts Receivable", AccountType::Asset); + let entry = self.create_entry(date, &format!("Payment on invoice {}", invoice_id)); + let money = Money::new(amount, self.base_currency); + self.add_line(entry, cash_account, Side::Debit, money)?; + self.add_line(entry, ar, Side::Credit, money)?; + self.post_entry(entry)?; + let inv = self.invoices.get_mut(&invoice_id).expect("invoice exists"); + inv.amount_paid += amount; + inv.status = arap::derive_status(inv.amount_paid, inv.total()); + Ok(entry) + } + + /// Pay a vendor bill. Posts: Dr A/P, Cr Cash. Updates the bill's paid + /// amount and status. + pub fn pay_bill( + &mut self, + bill_id: BillId, + date: NaiveDate, + amount: Decimal, + cash_account: AccountId, + ) -> Result { + if amount <= Decimal::ZERO { + return Err(LedgerError::InvalidPaymentAmount(amount)); + } + let bill = self + .bills + .get(&bill_id) + .ok_or(LedgerError::UnknownBill(bill_id))? + .clone(); + let balance = bill.balance(); + if amount > balance { + return Err(LedgerError::Overpayment(amount, balance)); + } + if let Some(pid) = self.closed_period_containing(date) { + return Err(LedgerError::ClosedPeriod(pid, date.to_string())); + } + let ap = self.ensure_account("2000", "Accounts Payable", AccountType::Liability); + let entry = self.create_entry(date, &format!("Payment on bill {}", bill_id)); + let money = Money::new(amount, self.base_currency); + self.add_line(entry, ap, Side::Debit, money)?; + self.add_line(entry, cash_account, Side::Credit, money)?; + self.post_entry(entry)?; + let b = self.bills.get_mut(&bill_id).expect("bill exists"); + b.amount_paid += amount; + b.status = arap::derive_status(b.amount_paid, b.total()); + Ok(entry) + } + + /// Record an invoice without posting: replay support for journaling + /// stores. The invoice's entry was already rebuilt from its own + /// `entry.created` / `entry.line_added` / `entry.posted` events; this + /// only restores the invoice record. The id must be the next invoice + /// id — anything else means the journal and the allocator disagree. + #[doc(hidden)] + pub fn replay_invoice( + &mut self, + id: InvoiceId, + customer_id: CustomerId, + date: NaiveDate, + due_date: NaiveDate, + lines: Vec, + entry_id: EntryId, + ) -> Result<(), LedgerError> { + if id != self.next_invoice_id { + return Err(LedgerError::ReplayIdMismatch(id, self.next_invoice_id)); + } + self.next_invoice_id += 1; + self.invoices.insert( + id, + Invoice { + id, + customer_id, + date, + due_date, + lines, + amount_paid: Decimal::ZERO, + status: DocumentStatus::Open, + entry_id, + }, + ); + Ok(()) + } + + /// Record a vendor bill without posting: replay support for journaling + /// stores. See [`Self::replay_invoice`]. + #[doc(hidden)] + pub fn replay_bill( + &mut self, + id: BillId, + vendor_id: VendorId, + date: NaiveDate, + due_date: NaiveDate, + lines: Vec, + entry_id: EntryId, + ) -> Result<(), LedgerError> { + if id != self.next_bill_id { + return Err(LedgerError::ReplayIdMismatch(id, self.next_bill_id)); + } + self.next_bill_id += 1; + self.bills.insert( + id, + Bill { + id, + vendor_id, + date, + due_date, + lines, + amount_paid: Decimal::ZERO, + status: DocumentStatus::Open, + entry_id, + }, + ); + Ok(()) + } + + /// Apply an invoice payment without posting: replay support for + /// journaling stores. The payment entry was already rebuilt from its + /// own entry events; this only moves the invoice's paid amount and + /// status. `entry_id` is the journaled payment entry, carried for + /// audit; the invoice does not link payment entries. + #[doc(hidden)] + pub fn replay_invoice_payment( + &mut self, + invoice_id: InvoiceId, + amount: Decimal, + entry_id: EntryId, + ) -> Result<(), LedgerError> { + let _ = entry_id; + let inv = self + .invoices + .get_mut(&invoice_id) + .ok_or(LedgerError::UnknownInvoice(invoice_id))?; + inv.amount_paid += amount; + inv.status = arap::derive_status(inv.amount_paid, inv.total()); + Ok(()) + } + + /// Apply a bill payment without posting: replay support for journaling + /// stores. See [`Self::replay_invoice_payment`]. + #[doc(hidden)] + pub fn replay_bill_payment( + &mut self, + bill_id: BillId, + amount: Decimal, + entry_id: EntryId, + ) -> Result<(), LedgerError> { + let _ = entry_id; + let bill = self + .bills + .get_mut(&bill_id) + .ok_or(LedgerError::UnknownBill(bill_id))?; + bill.amount_paid += amount; + bill.status = arap::derive_status(bill.amount_paid, bill.total()); + Ok(()) + } + + /// A/R aging as of a date: one row per customer with an outstanding + /// balance, bucketed by days past due. + pub fn ar_aging(&self, as_of: NaiveDate) -> Vec { + let mut by_customer: HashMap = HashMap::new(); + for inv in self.invoices.values() { + let balance = inv.balance(); + if balance.is_zero() { + continue; + } + let row = arap::aging_bucket(inv.due_date, as_of, balance); + let entry = by_customer + .entry(inv.customer_id) + .or_insert_with(|| AgingRow { + name: self + .customers + .get(&inv.customer_id) + .map(|c| c.name.clone()) + .unwrap_or_default(), + current: Decimal::ZERO, + days_30: Decimal::ZERO, + days_60: Decimal::ZERO, + days_90: Decimal::ZERO, + days_90_plus: Decimal::ZERO, + }); + entry.current += row.current; + entry.days_30 += row.days_30; + entry.days_60 += row.days_60; + entry.days_90 += row.days_90; + entry.days_90_plus += row.days_90_plus; + } + let mut rows: Vec = by_customer.into_values().collect(); + rows.sort_by(|a, b| a.name.cmp(&b.name)); + rows + } + + /// A/P aging as of a date: one row per vendor with an outstanding + /// balance, bucketed by days past due. + pub fn ap_aging(&self, as_of: NaiveDate) -> Vec { + let mut by_vendor: HashMap = HashMap::new(); + for bill in self.bills.values() { + let balance = bill.balance(); + if balance.is_zero() { + continue; + } + let row = arap::aging_bucket(bill.due_date, as_of, balance); + let entry = by_vendor.entry(bill.vendor_id).or_insert_with(|| AgingRow { + name: self + .vendors + .get(&bill.vendor_id) + .map(|v| v.name.clone()) + .unwrap_or_default(), + current: Decimal::ZERO, + days_30: Decimal::ZERO, + days_60: Decimal::ZERO, + days_90: Decimal::ZERO, + days_90_plus: Decimal::ZERO, + }); + entry.current += row.current; + entry.days_30 += row.days_30; + entry.days_60 += row.days_60; + entry.days_90 += row.days_90; + entry.days_90_plus += row.days_90_plus; + } + let mut rows: Vec = by_vendor.into_values().collect(); + rows.sort_by(|a, b| a.name.cmp(&b.name)); + rows + } + + // ------------------------------------------------------------------ + // Bank reconciliation + // ------------------------------------------------------------------ + + /// Mark a posted entry's line as cleared (or uncleared). The cleared + /// flag is a workflow marker, not accounting data: posted entries stay + /// immutable, and the flag lives in a side table. + 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. + pub fn reconciliation_report( + &self, + account: AccountId, + statement_date: NaiveDate, + statement_balance: Decimal, + ) -> Result { + let acct = self + .chart + .get(account) + .ok_or(LedgerError::UnknownAccount(account))?; + let normal = acct.account_type.normal_side(); + let mut book = Decimal::ZERO; + let mut cleared = Decimal::ZERO; + let mut uncleared_lines = Vec::new(); + for entry in self.posted_entries() { + for (idx, line) in entry.lines().iter().enumerate() { + if line.account != account { + continue; + } + // Signed in the account's normal side: positive = normal balance. + let signed = match (line.side, normal) { + (Side::Debit, Side::Debit) => line.amount.amount, + (Side::Credit, Side::Credit) => line.amount.amount, + (Side::Debit, Side::Credit) => -line.amount.amount, + (Side::Credit, Side::Debit) => -line.amount.amount, + }; + book += signed; + if entry.date() > statement_date { + continue; + } + if self.is_cleared(entry.id(), idx) { + cleared += signed; + } else { + uncleared_lines.push(UnclearedLine { + entry_id: entry.id(), + line_index: idx, + date: entry.date(), + description: entry.description().to_string(), + side: line.side, + amount: line.amount.amount, + }); + } + } + } + // Sort uncleared by date for a readable report. + uncleared_lines.sort_by(|a, b| a.date.cmp(&b.date).then(a.entry_id.cmp(&b.entry_id))); + Ok(ReconciliationReport { + account, + book_balance: if book.is_zero() { Decimal::ZERO } else { book }, + cleared_balance: if cleared.is_zero() { + Decimal::ZERO + } else { + cleared + }, + statement_balance, + difference: cleared - statement_balance, + uncleared_lines, + }) + } + + // ------------------------------------------------------------------ + // Foreign exchange + // ------------------------------------------------------------------ + + /// Record a historical FX rate for reference: how much base currency + /// one unit of `currency` buys on `date`. Lines store their own rate at + /// entry time; this table is for lookup and audit, not conversion. + pub fn set_fx_rate( + &mut self, + currency: Currency, + date: NaiveDate, + rate: Decimal, + ) -> Result<(), LedgerError> { + if rate <= Decimal::ZERO { + return Err(LedgerError::InvalidFxRate(rate)); + } + if currency == self.base_currency { + return Err(LedgerError::CurrencyMismatch { + expected: "foreign currency".to_string(), + found: currency.code().to_string(), + }); + } + self.fx_rates.insert((currency, date), rate); + Ok(()) + } + + /// Look up a recorded FX rate. + pub fn get_fx_rate(&self, currency: Currency, date: NaiveDate) -> Option { + self.fx_rates.get(&(currency, date)).copied() + } + /// Signed net movement of an account across all posted entries: /// debits minus credits. Positive means net debit movement. pub fn account_movement(&self, account: AccountId) -> Decimal {