Files
wanderbooks/crates/ledger-core/src/account.rs
T
swaitsandClaude Opus 5.5 5b8910a1cd
check / check (push) Canceled after 0s
books.renumber_account: give an account a new code, history kept
Ledger::renumber_account changes only the code (entries name accounts by
id): refuses an unknown account, a blank or used code, and a control code
for an account of the wrong type or a placeholder. Journaled as
account.renumbered (with the previous code), replayed, projected, shown
in the viewer's journal. The MCP tool lists wanderbooks.toml settings
that still name the old code. The server instructions now warn that the
engine can't tell a same-type account with another purpose at a control
code (a hosting expense at payroll's 6100).

Tests: core property (only the code changes; trial balance equal), core
refusals, the store's command property, MCP end to end with restart.
Mutation on the new code: 8 of 8 viable caught.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GWVo7x3UHw5kwCUaWxiejX
2026-10-14 08:00:58 -06:00

172 lines
5.8 KiB
Rust

//! Chart of accounts: the five account types and the account registry.
//!
//! Every account has exactly one of five types. The type determines the
//! account's *normal balance side*: assets and expenses are debit-normal,
//! liabilities, equity, and revenue are credit-normal. Reports are built by
//! grouping on type, so getting this right is the whole game.
use crate::{Currency, LedgerError};
use rust_decimal::Decimal;
use serde::{Deserialize, Serialize};
use std::collections::HashMap;
/// Opaque account handle. Created by [`ChartOfAccounts`]; never forged.
pub type AccountId = u64;
/// The five kinds of accounts. This list is closed: all of double-entry
/// bookkeeping is expressed with these.
#[derive(Clone, Copy, PartialEq, Eq, Debug, Serialize, Deserialize)]
pub enum AccountType {
/// What the business owns or is owed: cash, receivables, equipment.
Asset,
/// What the business owes: payables, loans.
Liability,
/// The owners' claim: capital, retained earnings.
Equity,
/// Increases to equity from operations: sales, fees.
Revenue,
/// Decreases to equity from operations: rent, wages, supplies.
Expense,
}
/// Debit or credit: the two sides of every journal line.
#[derive(Clone, Copy, PartialEq, Eq, Debug, Serialize, Deserialize)]
pub enum Side {
/// Left side. Increases assets and expenses.
Debit,
/// Right side. Increases liabilities, equity, and revenue.
Credit,
}
impl AccountType {
/// The side that increases an account of this type — its normal balance.
pub fn normal_side(&self) -> Side {
match self {
AccountType::Asset | AccountType::Expense => Side::Debit,
AccountType::Liability | AccountType::Equity | AccountType::Revenue => Side::Credit,
}
}
/// True for the three types that appear on the balance sheet.
pub fn is_balance_sheet(&self) -> bool {
matches!(
self,
AccountType::Asset | AccountType::Liability | AccountType::Equity
)
}
}
/// A single account in the chart of accounts.
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]
pub struct Account {
/// Opaque handle.
pub id: AccountId,
/// Human-meaningful code, e.g. `"1000"`. Unique within the chart.
pub code: String,
/// Display name, e.g. `"Cash"`.
pub name: String,
/// One of the five types. Determines reporting and normal side.
pub account_type: AccountType,
/// Hierarchy link, e.g. `"1010"` under `"1000"`. `None` for top-level.
pub parent: Option<AccountId>,
/// Header accounts organize the chart and take no postings.
pub placeholder: bool,
/// Day one: always the ledger's base currency (see R-5).
pub currency: Currency,
/// Tax category key (see [`crate::tax`]), for revenue and expense
/// accounts.
#[serde(default)]
pub tax_category: Option<String>,
/// Percent deductible, overriding the category's default (e.g. 50 for
/// meals, 0 for penalties).
#[serde(default)]
pub deductible_percent: Option<Decimal>,
}
/// The registry of accounts. Owns id allocation for accounts it holds.
#[derive(Clone, Debug, Default, PartialEq)]
pub struct ChartOfAccounts {
accounts: HashMap<AccountId, Account>,
by_code: HashMap<String, AccountId>,
}
impl ChartOfAccounts {
/// Register an account. Codes must be unique and non-empty; a parent, if
/// given, must already exist.
pub fn add(&mut self, account: Account) -> Result<(), LedgerError> {
if account.code.is_empty() {
return Err(LedgerError::EmptyAccountCode);
}
if self.by_code.contains_key(&account.code) {
return Err(LedgerError::DuplicateAccountCode(account.code));
}
if let Some(p) = account.parent
&& !self.accounts.contains_key(&p)
{
return Err(LedgerError::UnknownParentAccount(p));
}
self.by_code.insert(account.code.clone(), account.id);
self.accounts.insert(account.id, account);
Ok(())
}
/// Look up an account by id.
pub fn get(&self, id: AccountId) -> Option<&Account> {
self.accounts.get(&id)
}
/// Look up an account by code.
pub fn find_by_code(&self, code: &str) -> Option<&Account> {
self.by_code.get(code).and_then(|id| self.accounts.get(id))
}
/// Iterate over all accounts (order unspecified; sort by code for display).
pub fn all(&self) -> impl Iterator<Item = &Account> {
self.accounts.values()
}
/// Give an account a new code. The code must be non-empty and free.
pub fn set_code(&mut self, id: AccountId, code: &str) -> Result<(), LedgerError> {
if code.trim().is_empty() {
return Err(LedgerError::EmptyAccountCode);
}
if self.by_code.contains_key(code) {
return Err(LedgerError::DuplicateAccountCode(code.to_string()));
}
let a = self
.accounts
.get_mut(&id)
.ok_or(LedgerError::UnknownAccount(id))?;
self.by_code.remove(&a.code);
a.code = code.to_string();
self.by_code.insert(a.code.clone(), id);
Ok(())
}
/// Set (or clear) an account's tax category and deductible percent.
pub fn set_tax(
&mut self,
id: AccountId,
category: Option<String>,
deductible_percent: Option<Decimal>,
) -> Result<(), LedgerError> {
let a = self
.accounts
.get_mut(&id)
.ok_or(LedgerError::UnknownAccount(id))?;
a.tax_category = category;
a.deductible_percent = deductible_percent;
Ok(())
}
/// Number of accounts in the chart.
pub fn len(&self) -> usize {
self.accounts.len()
}
/// True when the chart holds no accounts.
pub fn is_empty(&self) -> bool {
self.accounts.is_empty()
}
}