check / check (push) Canceled after 0s
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
172 lines
5.8 KiB
Rust
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()
|
|
}
|
|
}
|