wanderbooks is a general MCP-first accounting application: every business needs its accounts mapped to where they land on its income tax return, and its CPA needs book-to-tax differences surfaced, not hidden. - ledger_core::tax: a catalog of stable categories (gross_receipts, commissions_fees, meals, guaranteed_payments, officer_compensation, nondeductible, ...) with kind (income, cost of goods, deduction), the returns each appears on (Schedule C, 1065, 1120-S, 1120), a default deductible percent (meals 50, nondeductible 0), and guidance. Keys, not line numbers: form lines move from year to year. - Accounts carry tax_category and an optional deductible_percent override, validated against the account type (and the return, at the MCP layer), journaled as account.tax_set, replayed, projected (schema v6). - tax_summary(start, end, form): the P&L grouped by category, book and deductible amounts (half away from zero to the cent), contra income (refunds) reducing receipts, and every account still uncategorized. - MCP: books.tax_categories (filtered to the configured return), books.set_tax_category, a tax_summary report in every format and in the CPA package, chart output with categories, and a fifth tax_category column in chart CSV imports, validated before anything is created. [company] tax_form, or derived from tax_classification. Scope stays book basis: no tax depreciation, basis, or filing. Tests: a hand-computed quarter (50% meals 333.33 -> 166.67, penalties at 0%, refunds as negative receipts, guaranteed payments, one uncategorized account; book net 5,276.67 vs tax 5,483.33); overrides; category/account /return validation; form parsing and derivation; CSV parsing; stdio: filtered catalog, mapping, refusal, summary, all-or-nothing import. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01MtYEaR6M6Ybdhm6X98BWNu
1703 lines
62 KiB
Rust
1703 lines
62 KiB
Rust
//! 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::{
|
||
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,
|
||
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;
|
||
|
||
/// 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<UnclearedLine>,
|
||
}
|
||
|
||
/// The books. Single-currency (see R-5); owns id allocation.
|
||
#[derive(Clone, Debug)]
|
||
pub struct Ledger {
|
||
base_currency: Currency,
|
||
chart: ChartOfAccounts,
|
||
entries: HashMap<EntryId, JournalEntry>,
|
||
/// Entry ids in posting order: the append-only log.
|
||
order: Vec<EntryId>,
|
||
/// Fixed-asset register: master data, journaled like accounts.
|
||
assets: HashMap<AssetId, Asset>,
|
||
/// Accounting periods.
|
||
periods: HashMap<PeriodId, Period>,
|
||
/// Customers for A/R.
|
||
customers: HashMap<CustomerId, Customer>,
|
||
/// Vendors for A/P.
|
||
vendors: HashMap<VendorId, Vendor>,
|
||
/// Sales invoices.
|
||
invoices: HashMap<InvoiceId, Invoice>,
|
||
/// Vendor bills.
|
||
bills: HashMap<BillId, Bill>,
|
||
/// 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<PayrollRunId, PayrollRun>,
|
||
/// Classes for divisional P&L tracking.
|
||
classes: HashMap<ClassId, Class>,
|
||
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,
|
||
}
|
||
|
||
impl Ledger {
|
||
/// Open a new, empty ledger denominated in `base_currency`.
|
||
pub fn new(base_currency: Currency) -> Self {
|
||
Self {
|
||
base_currency,
|
||
chart: ChartOfAccounts::default(),
|
||
entries: HashMap::new(),
|
||
order: Vec::new(),
|
||
assets: HashMap::new(),
|
||
periods: HashMap::new(),
|
||
customers: HashMap::new(),
|
||
vendors: HashMap::new(),
|
||
invoices: HashMap::new(),
|
||
bills: HashMap::new(),
|
||
cleared: HashMap::new(),
|
||
fx_rates: HashMap::new(),
|
||
payroll_runs: HashMap::new(),
|
||
classes: HashMap::new(),
|
||
next_account_id: 1,
|
||
next_entry_id: 1,
|
||
next_asset_id: 1,
|
||
next_period_id: 1,
|
||
next_customer_id: 1,
|
||
next_vendor_id: 1,
|
||
next_invoice_id: 1,
|
||
next_bill_id: 1,
|
||
next_payroll_run_id: 1,
|
||
next_class_id: 1,
|
||
}
|
||
}
|
||
|
||
/// 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.
|
||
pub fn add_account(
|
||
&mut self,
|
||
code: &str,
|
||
name: &str,
|
||
account_type: AccountType,
|
||
parent: Option<AccountId>,
|
||
placeholder: bool,
|
||
) -> Result<AccountId, LedgerError> {
|
||
let id = self.next_account_id;
|
||
self.next_account_id += 1;
|
||
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,
|
||
})?;
|
||
Ok(id)
|
||
}
|
||
|
||
/// 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<Decimal>,
|
||
) -> 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<Decimal>,
|
||
) -> 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.
|
||
pub fn try_create_entry(
|
||
&mut self,
|
||
date: NaiveDate,
|
||
description: &str,
|
||
) -> Result<EntryId, LedgerError> {
|
||
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<PeriodId> {
|
||
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> {
|
||
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
|
||
/// (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),
|
||
class_id: None,
|
||
});
|
||
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 snapshot = self
|
||
.entries
|
||
.get(&entry)
|
||
.ok_or(LedgerError::UnknownEntry(entry))?
|
||
.clone();
|
||
if snapshot.status() == EntryStatus::Posted {
|
||
return Err(LedgerError::EntryAlreadyPosted(entry));
|
||
}
|
||
// Closed periods reject new postings.
|
||
if let Some(pid) = self.closed_period_containing(snapshot.date()) {
|
||
return Err(LedgerError::ClosedPeriod(pid, snapshot.date().to_string()));
|
||
}
|
||
// Clone so validation borrows end before the mutable mark.
|
||
snapshot.validate(&self.chart)?;
|
||
self.entries
|
||
.get_mut(&entry)
|
||
.ok_or(LedgerError::UnknownEntry(entry))?
|
||
.mark_posted();
|
||
self.order.push(entry);
|
||
Ok(())
|
||
}
|
||
|
||
/// Look up an entry by id (draft or posted).
|
||
pub fn get_entry(&self, entry: EntryId) -> Option<&JournalEntry> {
|
||
self.entries.get(&entry)
|
||
}
|
||
|
||
/// 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<EntryId, LedgerError> {
|
||
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 (idx, line) in original.lines().iter().enumerate() {
|
||
let flipped = match line.side {
|
||
Side::Debit => Side::Credit,
|
||
Side::Credit => Side::Debit,
|
||
};
|
||
self.add_line(id, line.account, flipped, line.amount)?;
|
||
// The reversal offsets the original line for line: carry the
|
||
// class so divisional P&L nets correctly.
|
||
if line.class_id.is_some() {
|
||
self.set_line_class(id, idx, line.class_id)?;
|
||
}
|
||
}
|
||
self.entries
|
||
.get_mut(&id)
|
||
.ok_or(LedgerError::UnknownEntry(id))?
|
||
.mark_reversal_of(entry);
|
||
self.post_entry(id)?;
|
||
Ok(id)
|
||
}
|
||
|
||
/// Posted entries in posting order: the append-only log.
|
||
pub fn posted_entries(&self) -> impl Iterator<Item = &JournalEntry> + '_ {
|
||
self.order.iter().filter_map(|id| self.entries.get(id))
|
||
}
|
||
|
||
// ------------------------------------------------------------------
|
||
// Fixed assets
|
||
// ------------------------------------------------------------------
|
||
|
||
/// Pure pre-validation for [`Self::register_asset`]: every check that
|
||
/// does not mutate, so journaling stores can validate before journaling.
|
||
pub fn validate_new_asset(&self, new: &NewAsset) -> Result<(), LedgerError> {
|
||
if new.description.trim().is_empty() {
|
||
return Err(LedgerError::EmptyAssetDescription);
|
||
}
|
||
if new.cost.amount <= Decimal::ZERO {
|
||
return Err(LedgerError::InvalidAssetCost(new.cost.amount));
|
||
}
|
||
if new.cost.currency != self.base_currency {
|
||
return Err(LedgerError::CurrencyMismatch {
|
||
expected: self.base_currency.code().to_string(),
|
||
found: new.cost.currency.code().to_string(),
|
||
});
|
||
}
|
||
if new.salvage_value.amount < Decimal::ZERO || new.salvage_value.amount >= new.cost.amount {
|
||
return Err(LedgerError::InvalidSalvageValue(new.salvage_value.amount));
|
||
}
|
||
if new.salvage_value.currency != self.base_currency {
|
||
return Err(LedgerError::CurrencyMismatch {
|
||
expected: self.base_currency.code().to_string(),
|
||
found: new.salvage_value.currency.code().to_string(),
|
||
});
|
||
}
|
||
if new.useful_life_months < 1 {
|
||
return Err(LedgerError::InvalidUsefulLife(new.useful_life_months));
|
||
}
|
||
// The schedule walks every month of the asset's life; a purchase
|
||
// date near the end of the calendar would overflow date arithmetic
|
||
// (and hang or panic the schedule). Reject it up front.
|
||
if new
|
||
.purchase_date
|
||
.checked_add_months(Months::new(new.useful_life_months))
|
||
.is_none()
|
||
{
|
||
return Err(LedgerError::AssetDateOverflow);
|
||
}
|
||
assets::validate_asset_accounts(
|
||
&self.chart,
|
||
new.cost_account,
|
||
new.accum_depr_account,
|
||
new.expense_account,
|
||
)
|
||
}
|
||
|
||
/// Register a fixed asset. Validates the economics (positive cost,
|
||
/// salvage below cost, at least one month of life, base currency) and
|
||
/// the account links (asset/asset/expense types, three distinct
|
||
/// accounts).
|
||
pub fn register_asset(&mut self, new: NewAsset) -> Result<AssetId, LedgerError> {
|
||
self.validate_new_asset(&new)?;
|
||
let id = self.next_asset_id;
|
||
self.next_asset_id += 1;
|
||
self.assets.insert(
|
||
id,
|
||
Asset {
|
||
id,
|
||
description: new.description,
|
||
purchase_date: new.purchase_date,
|
||
cost: new.cost,
|
||
salvage_value: new.salvage_value,
|
||
useful_life_months: new.useful_life_months,
|
||
method: crate::DepreciationMethod::StraightLine,
|
||
cost_account: new.cost_account,
|
||
accum_depr_account: new.accum_depr_account,
|
||
expense_account: new.expense_account,
|
||
status: AssetStatus::Active,
|
||
},
|
||
);
|
||
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<Item = &Asset> + '_ {
|
||
let mut ids: Vec<AssetId> = self.assets.keys().copied().collect();
|
||
ids.sort_unstable();
|
||
ids.into_iter().filter_map(|id| self.assets.get(&id))
|
||
}
|
||
|
||
/// Mark an asset disposed. The disposal *entry* is posted through the
|
||
/// normal machinery by the caller; this records the lifecycle change.
|
||
pub fn dispose_asset(
|
||
&mut self,
|
||
id: AssetId,
|
||
date: NaiveDate,
|
||
proceeds: Money,
|
||
) -> Result<(), LedgerError> {
|
||
if proceeds.currency != self.base_currency {
|
||
return Err(LedgerError::CurrencyMismatch {
|
||
expected: self.base_currency.code().to_string(),
|
||
found: proceeds.currency.code().to_string(),
|
||
});
|
||
}
|
||
let asset = self.assets.get(&id).ok_or(LedgerError::UnknownAsset(id))?;
|
||
// Pure math validates activeness, proceeds sign, and date order.
|
||
crate::disposal_amounts(asset, date, proceeds)?;
|
||
self.assets.get_mut(&id).expect("asset exists").status =
|
||
AssetStatus::Disposed { date, proceeds };
|
||
Ok(())
|
||
}
|
||
|
||
// ------------------------------------------------------------------
|
||
// Accounting periods
|
||
// ------------------------------------------------------------------
|
||
|
||
/// Create an accounting period. The date range must be valid
|
||
/// (end >= start). Returns the period id.
|
||
pub fn create_period(
|
||
&mut self,
|
||
name: &str,
|
||
start: NaiveDate,
|
||
end: NaiveDate,
|
||
) -> Result<PeriodId, LedgerError> {
|
||
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)
|
||
}
|
||
|
||
/// 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<EntryId> {
|
||
self.periods
|
||
.values()
|
||
.filter_map(|p| p.closing_entry)
|
||
.collect()
|
||
}
|
||
|
||
/// All periods, in creation order.
|
||
pub fn list_periods(&self) -> Vec<&Period> {
|
||
let mut ids: Vec<PeriodId> = 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<Option<EntryId>, LedgerError> {
|
||
self.close_period_to(id, &periods::CloseTo::RetainedEarnings)
|
||
}
|
||
|
||
/// Close a period. This:
|
||
/// 1. Rejects if the period is already closed, if any draft is dated
|
||
/// inside it, or if a later period is already closed (closing out of
|
||
/// order would sweep the same income twice).
|
||
/// 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): revenue debited,
|
||
/// expense credited, and the net to `to` — Retained Earnings (account
|
||
/// `3999`, created if missing), or members' capital accounts by
|
||
/// share for a partnership, to the cent with the last member
|
||
/// absorbing rounding.
|
||
/// 3. Marks the period closed.
|
||
///
|
||
/// Returns the closing entry's id, or `None` if there was nothing to
|
||
/// close (no revenue/expense balance at period end).
|
||
pub fn close_period_to(
|
||
&mut self,
|
||
id: PeriodId,
|
||
to: &periods::CloseTo,
|
||
) -> Result<Option<EntryId>, LedgerError> {
|
||
self.preview_close_period(id)?;
|
||
if let periods::CloseTo::Members(members) = to {
|
||
self.validate_close_members(members)?;
|
||
}
|
||
let period = self.periods.get(&id).expect("previewed").clone();
|
||
|
||
// Revenue and expense balances as of the period end.
|
||
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 {
|
||
if !matches!(
|
||
account.account_type,
|
||
AccountType::Revenue | AccountType::Expense
|
||
) {
|
||
continue;
|
||
}
|
||
let movement = self.movement_through(account.id, period.end);
|
||
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 {
|
||
// Net income: the closing debits (revenue) minus credits (expense).
|
||
let net: Decimal = closings
|
||
.iter()
|
||
.map(|(_, side, amount)| match side {
|
||
Side::Debit => *amount,
|
||
Side::Credit => -*amount,
|
||
})
|
||
.sum();
|
||
let targets: Vec<(AccountId, Decimal)> = match to {
|
||
periods::CloseTo::RetainedEarnings => {
|
||
let re_id = match self.chart.find_by_code(periods::RETAINED_EARNINGS_CODE) {
|
||
Some(a) => a.id,
|
||
None => self.add_account(
|
||
periods::RETAINED_EARNINGS_CODE,
|
||
"Retained Earnings",
|
||
AccountType::Equity,
|
||
None,
|
||
false,
|
||
)?,
|
||
};
|
||
vec![(re_id, net)]
|
||
}
|
||
periods::CloseTo::Members(members) => {
|
||
let shares: Vec<Decimal> = members.iter().map(|(_, s)| *s).collect();
|
||
members
|
||
.iter()
|
||
.map(|(a, _)| *a)
|
||
.zip(periods::allocate(net, &shares))
|
||
.collect()
|
||
}
|
||
};
|
||
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))?;
|
||
}
|
||
// Profit credits equity; a loss debits it.
|
||
for (account, share) in targets {
|
||
if share.is_zero() {
|
||
continue;
|
||
}
|
||
let (side, amount) = if share > Decimal::ZERO {
|
||
(Side::Credit, share)
|
||
} else {
|
||
(Side::Debit, -share)
|
||
};
|
||
self.add_line(entry, account, side, Money::new(amount, self.base_currency))?;
|
||
}
|
||
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;
|
||
Ok(closing_entry)
|
||
}
|
||
|
||
/// Members for a close: shares positive and summing to 1, capital
|
||
/// accounts distinct equity accounts.
|
||
fn validate_close_members(&self, members: &[(AccountId, Decimal)]) -> Result<(), LedgerError> {
|
||
let total: Decimal = members.iter().map(|(_, s)| *s).sum();
|
||
if members.is_empty()
|
||
|| total != Decimal::ONE
|
||
|| members.iter().any(|(_, s)| *s <= Decimal::ZERO)
|
||
{
|
||
return Err(LedgerError::InvalidMembers(format!(
|
||
"member shares must be positive and sum to 1, got {total}"
|
||
)));
|
||
}
|
||
let mut seen = std::collections::BTreeSet::new();
|
||
for (a, _) in members {
|
||
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!(
|
||
"capital 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 {
|
||
let mut total = Decimal::ZERO;
|
||
for entry in self.posted_entries().filter(|e| e.date() <= end) {
|
||
for line in entry.lines().iter().filter(|l| l.account == account) {
|
||
match line.side {
|
||
Side::Debit => total += line.amount.amount,
|
||
Side::Credit => total -= line.amount.amount,
|
||
}
|
||
}
|
||
}
|
||
total
|
||
}
|
||
|
||
/// 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<Option<EntryId>, 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));
|
||
}
|
||
// The close posts an entry exactly when some revenue or expense
|
||
// account has a balance at period end — the same predicate
|
||
// close_period_to 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.movement_through(a.id, period.end).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<CustomerId, LedgerError> {
|
||
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<VendorId, LedgerError> {
|
||
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<CustomerId> = 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<VendorId> = 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<DocumentLine>,
|
||
) -> Result<InvoiceId, LedgerError> {
|
||
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<DocumentLine>,
|
||
) -> Result<BillId, LedgerError> {
|
||
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<EntryId, LedgerError> {
|
||
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<EntryId, LedgerError> {
|
||
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<DocumentLine>,
|
||
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<DocumentLine>,
|
||
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<AgingRow> {
|
||
let mut by_customer: HashMap<CustomerId, AgingRow> = 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<AgingRow> = 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<AgingRow> {
|
||
let mut by_vendor: HashMap<VendorId, AgingRow> = 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<AgingRow> = 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<ReconciliationReport, LedgerError> {
|
||
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<Decimal> {
|
||
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 {
|
||
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<Decimal, LedgerError> {
|
||
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
|
||
})
|
||
}
|
||
|
||
// ------------------------------------------------------------------
|
||
// Payroll
|
||
// ------------------------------------------------------------------
|
||
|
||
/// Pure pre-validation for [`Self::run_payroll`]: every check that does
|
||
/// not mutate, so journaling stores can validate before journaling.
|
||
pub fn validate_payroll(
|
||
&self,
|
||
date: NaiveDate,
|
||
lines: &[PayrollLine],
|
||
cash_account: AccountId,
|
||
) -> Result<(), LedgerError> {
|
||
validate_payroll_lines(lines)?;
|
||
self.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 {
|
||
PayrollAccounts {
|
||
salary_expense: self.ensure_account(
|
||
"6100",
|
||
"Salary & Wage Expense",
|
||
AccountType::Expense,
|
||
),
|
||
payroll_tax_expense: self.ensure_account(
|
||
"6110",
|
||
"Payroll Tax Expense",
|
||
AccountType::Expense,
|
||
),
|
||
federal_payable: self.ensure_account(
|
||
"2110",
|
||
"Federal Withholding Payable",
|
||
AccountType::Liability,
|
||
),
|
||
state_payable: self.ensure_account(
|
||
"2120",
|
||
"State Withholding Payable",
|
||
AccountType::Liability,
|
||
),
|
||
ss_payable: self.ensure_account("2130", "SS Tax Payable", AccountType::Liability),
|
||
medicare_payable: self.ensure_account(
|
||
"2140",
|
||
"Medicare Tax Payable",
|
||
AccountType::Liability,
|
||
),
|
||
other_payable: self.ensure_account(
|
||
"2150",
|
||
"Other Deductions Payable",
|
||
AccountType::Liability,
|
||
),
|
||
}
|
||
}
|
||
|
||
/// 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.
|
||
pub fn run_payroll(
|
||
&mut self,
|
||
date: NaiveDate,
|
||
lines: Vec<PayrollLine>,
|
||
cash_account: AccountId,
|
||
) -> Result<PayrollRunId, LedgerError> {
|
||
self.validate_payroll(date, &lines, cash_account)?;
|
||
let accounts = self.payroll_accounts();
|
||
let entry = self.try_create_entry(date, &format!("Payroll {date}"))?;
|
||
for (account, side, amount) in
|
||
payroll_entry_plan(&lines, accounts, cash_account, self.base_currency)
|
||
{
|
||
self.add_line(entry, account, side, amount)?;
|
||
}
|
||
self.post_entry(entry)?;
|
||
let id = self.next_payroll_run_id;
|
||
self.next_payroll_run_id += 1;
|
||
self.payroll_runs.insert(
|
||
id,
|
||
PayrollRun {
|
||
id,
|
||
date,
|
||
lines,
|
||
entry_id: entry,
|
||
},
|
||
);
|
||
Ok(id)
|
||
}
|
||
|
||
/// Current balances of the payroll tax payable accounts (`2110`–`2150`),
|
||
/// in code order. Accounts that don't exist yet count as zero and are
|
||
/// skipped, as are zero balances. Balances are normal-side: positive
|
||
/// means owed.
|
||
pub fn payroll_tax_payables(&self) -> Vec<(AccountId, Decimal)> {
|
||
let mut out = Vec::new();
|
||
for code in ["2110", "2120", "2130", "2140", "2150"] {
|
||
let Some(account) = self.chart.find_by_code(code) else {
|
||
continue;
|
||
};
|
||
// The code resolved, so the balance lookup cannot fail.
|
||
let balance = self.account_balance(account.id).unwrap_or(Decimal::ZERO);
|
||
if !balance.is_zero() {
|
||
out.push((account.id, 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.
|
||
pub fn remit_payroll_taxes(
|
||
&mut self,
|
||
date: NaiveDate,
|
||
cash_account: AccountId,
|
||
) -> Result<EntryId, LedgerError> {
|
||
self.chart
|
||
.get(cash_account)
|
||
.ok_or(LedgerError::UnknownAccount(cash_account))?;
|
||
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<PayrollRunId> = 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<PayrollLine>,
|
||
entry_id: EntryId,
|
||
) -> Result<(), LedgerError> {
|
||
if id != self.next_payroll_run_id {
|
||
return Err(LedgerError::ReplayIdMismatch(id, self.next_payroll_run_id));
|
||
}
|
||
self.next_payroll_run_id += 1;
|
||
self.payroll_runs.insert(
|
||
id,
|
||
PayrollRun {
|
||
id,
|
||
date,
|
||
lines,
|
||
entry_id,
|
||
},
|
||
);
|
||
Ok(())
|
||
}
|
||
|
||
// ------------------------------------------------------------------
|
||
// Classes
|
||
// ------------------------------------------------------------------
|
||
|
||
/// Add a class (division) for line-level P&L tracking. Returns its id.
|
||
pub fn add_class(&mut self, name: &str) -> Result<ClassId, LedgerError> {
|
||
if name.trim().is_empty() {
|
||
return Err(LedgerError::EmptyClassName);
|
||
}
|
||
let id = self.next_class_id;
|
||
self.next_class_id += 1;
|
||
self.classes.insert(
|
||
id,
|
||
Class {
|
||
id,
|
||
name: name.to_string(),
|
||
},
|
||
);
|
||
Ok(id)
|
||
}
|
||
|
||
/// Look up a class.
|
||
pub fn get_class(&self, id: ClassId) -> Option<&Class> {
|
||
self.classes.get(&id)
|
||
}
|
||
|
||
/// All classes, in id order.
|
||
pub fn list_classes(&self) -> Vec<&Class> {
|
||
let mut ids: Vec<ClassId> = self.classes.keys().copied().collect();
|
||
ids.sort_unstable();
|
||
ids.into_iter()
|
||
.filter_map(|id| self.classes.get(&id))
|
||
.collect()
|
||
}
|
||
|
||
/// Find a class by name, case-insensitive.
|
||
pub fn find_class_by_name(&self, name: &str) -> Option<&Class> {
|
||
self.classes
|
||
.values()
|
||
.find(|c| c.name.eq_ignore_ascii_case(name))
|
||
}
|
||
|
||
/// Tag a draft entry's line with a class, or clear it with `None`.
|
||
/// Draft-only: posted entries are immutable, so their classes are
|
||
/// frozen at posting time.
|
||
pub fn set_line_class(
|
||
&mut self,
|
||
entry: EntryId,
|
||
line_index: usize,
|
||
class_id: Option<ClassId>,
|
||
) -> 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(())
|
||
}
|
||
}
|