check / check (push) Canceled after 0s
books.rename_account; account names trimmed, bounded, refused when a sibling's or TIN-shaped; books.show refuses what its target ignores. A report asked for without dates covers this fiscal year so far, everywhere (one rule for tools, descriptions and the viewer; the IIF and CPA package keep their own dates). policy.require_tax_category warns or refuses; a Form 4797 gain is shown apart on every return. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01T3gXMVt7XJPi6ens1or9Dj
190 lines
6.6 KiB
Rust
190 lines
6.6 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
|
|
)
|
|
}
|
|
}
|
|
|
|
/// The longest name a new account (or a rename) may take, in characters:
|
|
/// one line that every report prints, as long as the company's own name
|
|
/// may be ([`crate::settings::MAX_TEXT`]). The command's bound, never
|
|
/// replay's (rules.md B-28): a journaled name of any length replays.
|
|
pub const MAX_ACCOUNT_NAME: usize = crate::settings::MAX_TEXT;
|
|
|
|
/// 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(())
|
|
}
|
|
|
|
/// Give an account a new name. Only the account must exist: what a
|
|
/// new name may be is the command's to check
|
|
/// ([`crate::Ledger::rename_account`]), never replay's (rules.md B-28).
|
|
pub fn set_name(&mut self, id: AccountId, name: &str) -> Result<(), LedgerError> {
|
|
let a = self
|
|
.accounts
|
|
.get_mut(&id)
|
|
.ok_or(LedgerError::UnknownAccount(id))?;
|
|
a.name = name.to_string();
|
|
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()
|
|
}
|
|
}
|