Initial commit: wanderbooks accounting semantics engine
This commit is contained in:
1 parent
0d05da02dc
commit
bd6ea3e67e
1 file changed
+200
@@ -0,0 +1,200 @@
|
||||
//! Reports: pure functions of a [`Ledger`](crate::Ledger).
|
||||
//!
|
||||
//! Reports never mutate and never guess. They are what the semantic oracle
|
||||
//! (`docs/oracle.md`) diffs: the same fixture books through two engines must
|
||||
//! produce byte-identical [`snapshot`] output.
|
||||
|
||||
use crate::{AccountType, Ledger, LedgerError};
|
||||
use rust_decimal::Decimal;
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
/// One line of a trial balance.
|
||||
#[derive(Clone, Debug, Serialize, Deserialize)]
|
||||
pub struct TrialBalanceLine {
|
||||
/// Account code, e.g. `"1000"`.
|
||||
pub code: String,
|
||||
/// Account name, e.g. `"Cash"`.
|
||||
pub name: String,
|
||||
/// Debit balance, or zero.
|
||||
pub debit: Decimal,
|
||||
/// Credit balance, or zero.
|
||||
pub credit: Decimal,
|
||||
}
|
||||
|
||||
/// The trial balance: every account with a non-zero balance, debit and credit
|
||||
/// columns, and column totals. A correct book always has equal totals.
|
||||
#[derive(Clone, Debug, Serialize, Deserialize)]
|
||||
pub struct TrialBalance {
|
||||
/// Lines sorted by account code. Deterministic order matters for goldens.
|
||||
pub lines: Vec<TrialBalanceLine>,
|
||||
/// Sum of the debit column.
|
||||
pub total_debits: Decimal,
|
||||
/// Sum of the credit column.
|
||||
pub total_credits: Decimal,
|
||||
}
|
||||
|
||||
impl TrialBalance {
|
||||
/// The fundamental check: debits equal credits, exactly.
|
||||
pub fn balanced(&self) -> bool {
|
||||
self.total_debits == self.total_credits
|
||||
}
|
||||
}
|
||||
|
||||
/// Build the trial balance from all posted entries.
|
||||
pub fn trial_balance(ledger: &Ledger) -> TrialBalance {
|
||||
let mut accounts: Vec<_> = ledger.chart().all().collect();
|
||||
accounts.sort_by(|a, b| a.code.cmp(&b.code));
|
||||
|
||||
let mut lines = Vec::new();
|
||||
let mut total_debits = Decimal::ZERO;
|
||||
let mut total_credits = Decimal::ZERO;
|
||||
for account in accounts {
|
||||
if account.placeholder {
|
||||
continue;
|
||||
}
|
||||
let movement = ledger.account_movement(account.id);
|
||||
if movement.is_zero() {
|
||||
continue;
|
||||
}
|
||||
let (debit, credit) = if movement > Decimal::ZERO {
|
||||
(movement, Decimal::ZERO)
|
||||
} else {
|
||||
(Decimal::ZERO, -movement)
|
||||
};
|
||||
total_debits += debit;
|
||||
total_credits += credit;
|
||||
lines.push(TrialBalanceLine {
|
||||
code: account.code.clone(),
|
||||
name: account.name.clone(),
|
||||
debit,
|
||||
credit,
|
||||
});
|
||||
}
|
||||
TrialBalance {
|
||||
lines,
|
||||
total_debits,
|
||||
total_credits,
|
||||
}
|
||||
}
|
||||
|
||||
/// Profit and loss: revenue and expenses for the life of the ledger.
|
||||
/// (Period filtering is R-FUTURE work; see `docs/requirements.md` R-7.)
|
||||
#[derive(Clone, Debug, Serialize, Deserialize)]
|
||||
pub struct ProfitAndLoss {
|
||||
/// Total revenue (credit-normal balances, reported positive).
|
||||
pub revenue: Decimal,
|
||||
/// Total expenses (debit-normal balances, reported positive).
|
||||
pub expenses: Decimal,
|
||||
}
|
||||
|
||||
impl ProfitAndLoss {
|
||||
/// Revenue minus expenses. Positive is profit.
|
||||
pub fn net_income(&self) -> Decimal {
|
||||
self.revenue - self.expenses
|
||||
}
|
||||
}
|
||||
|
||||
/// Build the profit and loss statement from all posted entries.
|
||||
pub fn profit_and_loss(ledger: &Ledger) -> Result<ProfitAndLoss, LedgerError> {
|
||||
let mut revenue = Decimal::ZERO;
|
||||
let mut expenses = Decimal::ZERO;
|
||||
for account in ledger.chart().all() {
|
||||
if account.placeholder {
|
||||
continue;
|
||||
}
|
||||
let balance = ledger.account_balance(account.id)?;
|
||||
match account.account_type {
|
||||
AccountType::Revenue => revenue += balance,
|
||||
AccountType::Expense => expenses += balance,
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
Ok(ProfitAndLoss { revenue, expenses })
|
||||
}
|
||||
|
||||
/// Balance sheet: assets, liabilities, and equity.
|
||||
///
|
||||
/// Because day-one ledgers have no closing entries yet (R-7), current
|
||||
/// earnings (net income to date) are folded into equity so that the
|
||||
/// accounting equation holds on a live book: assets = liabilities + equity.
|
||||
#[derive(Clone, Debug, Serialize, Deserialize)]
|
||||
pub struct BalanceSheet {
|
||||
/// Total assets (debit-normal balances, reported positive).
|
||||
pub assets: Decimal,
|
||||
/// Total liabilities (credit-normal balances, reported positive).
|
||||
pub liabilities: Decimal,
|
||||
/// Total equity *including* current earnings.
|
||||
pub equity: Decimal,
|
||||
/// Net income to date, folded into equity above.
|
||||
pub current_earnings: Decimal,
|
||||
}
|
||||
|
||||
impl BalanceSheet {
|
||||
/// The accounting equation: assets == liabilities + equity, exactly.
|
||||
pub fn balanced(&self) -> bool {
|
||||
self.assets == self.liabilities + self.equity
|
||||
}
|
||||
}
|
||||
|
||||
/// Build the balance sheet from all posted entries.
|
||||
pub fn balance_sheet(ledger: &Ledger) -> Result<BalanceSheet, LedgerError> {
|
||||
let mut assets = Decimal::ZERO;
|
||||
let mut liabilities = Decimal::ZERO;
|
||||
let mut equity = Decimal::ZERO;
|
||||
for account in ledger.chart().all() {
|
||||
if account.placeholder {
|
||||
continue;
|
||||
}
|
||||
let balance = ledger.account_balance(account.id)?;
|
||||
match account.account_type {
|
||||
AccountType::Asset => assets += balance,
|
||||
AccountType::Liability => liabilities += balance,
|
||||
AccountType::Equity => equity += balance,
|
||||
AccountType::Revenue | AccountType::Expense => {}
|
||||
}
|
||||
}
|
||||
let current_earnings = profit_and_loss(ledger)?.net_income();
|
||||
equity += current_earnings;
|
||||
Ok(BalanceSheet {
|
||||
assets,
|
||||
liabilities,
|
||||
equity,
|
||||
current_earnings,
|
||||
})
|
||||
}
|
||||
|
||||
/// Render the three core reports as canonical JSON for oracle diffing.
|
||||
///
|
||||
/// Amounts are decimal strings (never floats, never locale-formatted), lines
|
||||
/// are sorted by account code, and object keys are fixed. Two engines fed the
|
||||
/// same fixture books must produce byte-identical output — that is the whole
|
||||
/// point (see `docs/oracle.md`).
|
||||
pub fn snapshot(ledger: &Ledger) -> Result<serde_json::Value, LedgerError> {
|
||||
let tb = trial_balance(ledger);
|
||||
let pl = profit_and_loss(ledger)?;
|
||||
let bs = balance_sheet(ledger)?;
|
||||
Ok(serde_json::json!({
|
||||
"trial_balance": {
|
||||
"lines": tb.lines.iter().map(|l| serde_json::json!({
|
||||
"code": l.code,
|
||||
"name": l.name,
|
||||
"debit": l.debit.to_string(),
|
||||
"credit": l.credit.to_string(),
|
||||
})).collect::<Vec<_>>(),
|
||||
"total_debits": tb.total_debits.to_string(),
|
||||
"total_credits": tb.total_credits.to_string(),
|
||||
},
|
||||
"profit_and_loss": {
|
||||
"revenue": pl.revenue.to_string(),
|
||||
"expenses": pl.expenses.to_string(),
|
||||
"net_income": pl.net_income().to_string(),
|
||||
},
|
||||
"balance_sheet": {
|
||||
"assets": bs.assets.to_string(),
|
||||
"liabilities": bs.liabilities.to_string(),
|
||||
"equity": bs.equity.to_string(),
|
||||
"current_earnings": bs.current_earnings.to_string(),
|
||||
"balanced": bs.balanced(),
|
||||
},
|
||||
}))
|
||||
}
|
||||
Reference in new issue
Block a user