Every business has owners, and how owners put money in and take it out
depends on what the business is. wanderbooks now knows the four common
US shapes:
- books.chart_template {entity_type, owners}: a starter chart for a sole
proprietorship (owner's capital and draws), partnership or
multi-member LLC (capital and distributions per partner, guaranteed
payments), S corporation (stock, APIC, shareholder distributions,
officer compensation, retained earnings) or C corporation (dividends,
income tax expense), plus common income and expense accounts with
tax categories, and the wanderbooks.toml settings that go with it
(tax form, members with equal-share placeholders that sum to exactly
100, close target). A proposal for the agent to review with the human.
- Closing: CloseTo is now RetainedEarnings { sweep }, Account { into,
sweep } (a sole proprietor's capital) or Members([MemberClose { capital,
share, distributions }]). Sweeps zero owner-pay equity accounts into
their target at close: draws into owner's capital, dividends or
distributions into retained earnings, each partner's distributions
into their own capital. Validation (equity, distinct, shares sum to 1)
runs in the pre-close preview, before anything is journaled. The
journal records the target; journals with no target or the old
"retained_earnings" string still replay.
- Members' equity: a partner's distributions account is part of their
capital; its activity reports as distributions.
- Config: members' distribution_account; [books] close_into and
close_sweep for single-owner businesses (rejected alongside members).
The cash flow names members on their distribution accounts too.
Tests: a sole proprietor (net 2,000 and 500 of draws into capital: 6,500,
the cash); a corporation (dividends 300 into retained earnings: 600); a
partnership with distribution accounts (Bob 100 + 25 - 20 = 105, swept at
close, February begins there); bad sweep accounts; templates round-trip
through the chart parser, unique codes, equity per entity, every tax
category valid for the entity's return; stdio: template -> settings ->
import -> postings -> close with the exact closing lines.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MtYEaR6M6Ybdhm6X98BWNu
1261 lines
47 KiB
Rust
1261 lines
47 KiB
Rust
//! 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::periods::allocate;
|
|
use crate::{AccountType, Class, ClassId, JournalEntry, JournalLine, Ledger, LedgerError};
|
|
use chrono::NaiveDate;
|
|
use rust_decimal::Decimal;
|
|
use serde::{Deserialize, Serialize};
|
|
use std::collections::{BTreeMap, BTreeSet};
|
|
|
|
/// 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 {
|
|
trial_balance_where(ledger, &|_| true)
|
|
}
|
|
|
|
/// The trial balance as of a date: every posted entry dated on or before
|
|
/// it (closing entries included, so after a close it is the post-closing
|
|
/// trial balance).
|
|
pub fn trial_balance_as_of(ledger: &Ledger, as_of: NaiveDate) -> TrialBalance {
|
|
trial_balance_where(ledger, &|e| e.date() <= as_of)
|
|
}
|
|
|
|
fn trial_balance_where(ledger: &Ledger, include: &dyn Fn(&JournalEntry) -> bool) -> TrialBalance {
|
|
let mut movements: BTreeMap<crate::AccountId, Decimal> = BTreeMap::new();
|
|
for entry in ledger.posted_entries().filter(|e| include(e)) {
|
|
for line in entry.lines() {
|
|
let signed = match line.side {
|
|
crate::Side::Debit => line.amount.amount,
|
|
crate::Side::Credit => -line.amount.amount,
|
|
};
|
|
*movements.entry(line.account).or_default() += signed;
|
|
}
|
|
}
|
|
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 = movements.get(&account.id).copied().unwrap_or_default();
|
|
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. For a
|
|
/// date range, with a line per account, use [`profit_and_loss_for_period`].
|
|
#[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> {
|
|
profit_and_loss_filtered(ledger, None)
|
|
}
|
|
|
|
/// Build the profit and loss statement, optionally filtered to one class.
|
|
///
|
|
/// With `None` every line counts and the result is identical to
|
|
/// [`profit_and_loss`]. With `Some(class)` only lines tagged with that
|
|
/// class count. Unlike the account-at-a-time accumulation this works line
|
|
/// by line, because classes live on lines: each line contributes its signed
|
|
/// amount (positive on the account's normal side) to its account type's
|
|
/// total.
|
|
pub fn profit_and_loss_filtered(
|
|
ledger: &Ledger,
|
|
class_id: Option<ClassId>,
|
|
) -> Result<ProfitAndLoss, LedgerError> {
|
|
profit_and_loss_where(ledger, &|line| match class_id {
|
|
None => true,
|
|
Some(c) => line.class_id == Some(c),
|
|
})
|
|
}
|
|
|
|
/// P&L for one class. `class: None` is the unclassified bucket: lines with
|
|
/// no class tag.
|
|
#[derive(Clone, Debug, Serialize, Deserialize)]
|
|
pub struct ClassProfitAndLoss {
|
|
/// The class, or `None` for unclassified lines.
|
|
pub class: Option<Class>,
|
|
/// That bucket's profit and loss.
|
|
pub profit_and_loss: ProfitAndLoss,
|
|
}
|
|
|
|
/// Profit and loss broken down by class: one row per class that has any P&L
|
|
/// lines, plus an unclassified row when untagged P&L lines exist. Rows are
|
|
/// sorted by class name; the unclassified row comes last.
|
|
///
|
|
/// The rows partition the ledger: summing every row's revenue and expenses
|
|
/// reproduces [`profit_and_loss`].
|
|
pub fn profit_and_loss_by_class(ledger: &Ledger) -> Result<Vec<ClassProfitAndLoss>, LedgerError> {
|
|
// Which classes actually have P&L lines, and whether any P&L line is
|
|
// unclassified. Balance-sheet-only classes get no row.
|
|
let mut buckets: BTreeSet<Option<ClassId>> = BTreeSet::new();
|
|
for entry in ledger.posted_entries() {
|
|
for line in entry.lines() {
|
|
let account = ledger
|
|
.chart()
|
|
.get(line.account)
|
|
.ok_or(LedgerError::UnknownAccount(line.account))?;
|
|
if account.placeholder {
|
|
continue;
|
|
}
|
|
if !matches!(
|
|
account.account_type,
|
|
AccountType::Revenue | AccountType::Expense
|
|
) {
|
|
continue;
|
|
}
|
|
buckets.insert(line.class_id);
|
|
}
|
|
}
|
|
let mut classes: Vec<&Class> = buckets
|
|
.iter()
|
|
.filter_map(|c| c.and_then(|id| ledger.get_class(id)))
|
|
.collect();
|
|
classes.sort_by(|a, b| a.name.cmp(&b.name));
|
|
let mut rows = Vec::with_capacity(classes.len() + 1);
|
|
for class in classes {
|
|
let id = class.id;
|
|
rows.push(ClassProfitAndLoss {
|
|
class: Some(class.clone()),
|
|
profit_and_loss: profit_and_loss_where(ledger, &|line| line.class_id == Some(id))?,
|
|
});
|
|
}
|
|
if buckets.contains(&None) {
|
|
rows.push(ClassProfitAndLoss {
|
|
class: None,
|
|
profit_and_loss: profit_and_loss_where(ledger, &|line| line.class_id.is_none())?,
|
|
});
|
|
}
|
|
Ok(rows)
|
|
}
|
|
|
|
/// P&L over the posted lines accepted by `include`. Each line contributes
|
|
/// its signed amount — positive on the account's normal side — to its
|
|
/// account type's total.
|
|
fn profit_and_loss_where(
|
|
ledger: &Ledger,
|
|
include: &dyn Fn(&JournalLine) -> bool,
|
|
) -> Result<ProfitAndLoss, LedgerError> {
|
|
let mut revenue = Decimal::ZERO;
|
|
let mut expenses = Decimal::ZERO;
|
|
for entry in ledger.posted_entries() {
|
|
for line in entry.lines() {
|
|
if !include(line) {
|
|
continue;
|
|
}
|
|
let account = ledger
|
|
.chart()
|
|
.get(line.account)
|
|
.ok_or(LedgerError::UnknownAccount(line.account))?;
|
|
if account.placeholder {
|
|
continue;
|
|
}
|
|
let signed = if line.side == account.account_type.normal_side() {
|
|
line.amount.amount
|
|
} else {
|
|
-line.amount.amount
|
|
};
|
|
match account.account_type {
|
|
AccountType::Revenue => revenue += signed,
|
|
AccountType::Expense => expenses += signed,
|
|
_ => {}
|
|
}
|
|
}
|
|
}
|
|
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,
|
|
})
|
|
}
|
|
|
|
/// One account's line on a financial statement.
|
|
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]
|
|
pub struct StatementLine {
|
|
/// Account code, e.g. `"6000"`.
|
|
pub code: String,
|
|
/// Account name.
|
|
pub name: String,
|
|
/// Amount on the account's normal side: positive is a normal balance
|
|
/// (or movement), negative a contra one.
|
|
pub amount: Decimal,
|
|
}
|
|
|
|
/// Profit and loss for a date range, with a line per account.
|
|
#[derive(Clone, Debug, Serialize, Deserialize)]
|
|
pub struct ProfitAndLossStatement {
|
|
/// First date, inclusive.
|
|
pub start: NaiveDate,
|
|
/// Last date, inclusive.
|
|
pub end: NaiveDate,
|
|
/// Revenue accounts with movement in the range, by code.
|
|
pub revenue: Vec<StatementLine>,
|
|
/// Expense accounts with movement in the range, by code.
|
|
pub expenses: Vec<StatementLine>,
|
|
/// Sum of the revenue lines.
|
|
pub total_revenue: Decimal,
|
|
/// Sum of the expense lines.
|
|
pub total_expenses: Decimal,
|
|
}
|
|
|
|
impl ProfitAndLossStatement {
|
|
/// Revenue minus expenses. Positive is profit.
|
|
pub fn net_income(&self) -> Decimal {
|
|
self.total_revenue - self.total_expenses
|
|
}
|
|
}
|
|
|
|
/// Balance sheet as of a date, with a line per account.
|
|
#[derive(Clone, Debug, Serialize, Deserialize)]
|
|
pub struct BalanceSheetStatement {
|
|
/// The as-of date, inclusive.
|
|
pub as_of: NaiveDate,
|
|
/// Asset accounts with a balance, by code.
|
|
pub assets: Vec<StatementLine>,
|
|
/// Liability accounts with a balance, by code.
|
|
pub liabilities: Vec<StatementLine>,
|
|
/// Equity accounts with a balance, by code (current earnings excluded).
|
|
pub equity: Vec<StatementLine>,
|
|
/// Revenue minus expenses through `as_of` not yet closed to retained
|
|
/// earnings. Shown as its own equity line.
|
|
pub current_earnings: Decimal,
|
|
/// Sum of the asset lines.
|
|
pub total_assets: Decimal,
|
|
/// Sum of the liability lines.
|
|
pub total_liabilities: Decimal,
|
|
/// Sum of the equity lines plus current earnings.
|
|
pub total_equity: Decimal,
|
|
}
|
|
|
|
impl BalanceSheetStatement {
|
|
/// The accounting equation: assets == liabilities + equity, exactly.
|
|
pub fn balanced(&self) -> bool {
|
|
self.total_assets == self.total_liabilities + self.total_equity
|
|
}
|
|
}
|
|
|
|
/// Normal-side amount per account over the posted lines accepted by
|
|
/// `include(entry, line)`, as statement lines keyed (and so sorted) by
|
|
/// code, skipping placeholders and zero amounts.
|
|
fn statement_lines(
|
|
ledger: &Ledger,
|
|
include: &dyn Fn(&JournalEntry, &JournalLine) -> bool,
|
|
) -> Result<BTreeMap<String, (AccountType, StatementLine)>, LedgerError> {
|
|
let mut movement: BTreeMap<crate::AccountId, Decimal> = BTreeMap::new();
|
|
for entry in ledger.posted_entries() {
|
|
for line in entry.lines() {
|
|
if !include(entry, line) {
|
|
continue;
|
|
}
|
|
let signed = match line.side {
|
|
crate::Side::Debit => line.amount.amount,
|
|
crate::Side::Credit => -line.amount.amount,
|
|
};
|
|
*movement.entry(line.account).or_default() += signed;
|
|
}
|
|
}
|
|
let mut out = BTreeMap::new();
|
|
for (id, m) in movement {
|
|
let account = ledger
|
|
.chart()
|
|
.get(id)
|
|
.ok_or(LedgerError::UnknownAccount(id))?;
|
|
if account.placeholder || m.is_zero() {
|
|
continue;
|
|
}
|
|
let amount = match account.account_type.normal_side() {
|
|
crate::Side::Debit => m,
|
|
crate::Side::Credit => -m,
|
|
};
|
|
out.insert(
|
|
account.code.clone(),
|
|
(
|
|
account.account_type,
|
|
StatementLine {
|
|
code: account.code.clone(),
|
|
name: account.name.clone(),
|
|
amount,
|
|
},
|
|
),
|
|
);
|
|
}
|
|
Ok(out)
|
|
}
|
|
|
|
/// Profit and loss for `[start, end]`, optionally for one class (`None`
|
|
/// counts every line). Closing entries are excluded: they move a period's
|
|
/// earnings into retained earnings, so counting them would report a closed
|
|
/// period as breaking even.
|
|
pub fn profit_and_loss_for_period(
|
|
ledger: &Ledger,
|
|
start: NaiveDate,
|
|
end: NaiveDate,
|
|
class_id: Option<ClassId>,
|
|
) -> Result<ProfitAndLossStatement, LedgerError> {
|
|
if end < start {
|
|
return Err(LedgerError::InvalidDateRange {
|
|
start: start.to_string(),
|
|
end: end.to_string(),
|
|
});
|
|
}
|
|
let closings = ledger.closing_entry_ids();
|
|
let lines = statement_lines(ledger, &|entry, line| {
|
|
entry.date() >= start
|
|
&& entry.date() <= end
|
|
&& !closings.contains(&entry.id())
|
|
&& class_id.is_none_or(|c| line.class_id == Some(c))
|
|
})?;
|
|
let mut revenue = Vec::new();
|
|
let mut expenses = Vec::new();
|
|
for (typ, line) in lines.into_values() {
|
|
match typ {
|
|
AccountType::Revenue => revenue.push(line),
|
|
AccountType::Expense => expenses.push(line),
|
|
_ => {}
|
|
}
|
|
}
|
|
let total_revenue = revenue.iter().map(|l| l.amount).sum();
|
|
let total_expenses = expenses.iter().map(|l| l.amount).sum();
|
|
Ok(ProfitAndLossStatement {
|
|
start,
|
|
end,
|
|
revenue,
|
|
expenses,
|
|
total_revenue,
|
|
total_expenses,
|
|
})
|
|
}
|
|
|
|
/// Balance sheet as of `as_of`: every posted entry dated on or before it.
|
|
/// Revenue and expense not yet closed to retained earnings appear as
|
|
/// current earnings inside equity, so the accounting equation holds on
|
|
/// every date, before and after closes.
|
|
pub fn balance_sheet_as_of(
|
|
ledger: &Ledger,
|
|
as_of: NaiveDate,
|
|
) -> Result<BalanceSheetStatement, LedgerError> {
|
|
let lines = statement_lines(ledger, &|entry, _| entry.date() <= as_of)?;
|
|
let mut assets = Vec::new();
|
|
let mut liabilities = Vec::new();
|
|
let mut equity = Vec::new();
|
|
let mut current_earnings = Decimal::ZERO;
|
|
for (typ, line) in lines.into_values() {
|
|
match typ {
|
|
AccountType::Asset => assets.push(line),
|
|
AccountType::Liability => liabilities.push(line),
|
|
AccountType::Equity => equity.push(line),
|
|
AccountType::Revenue => current_earnings += line.amount,
|
|
AccountType::Expense => current_earnings -= line.amount,
|
|
}
|
|
}
|
|
let total_assets = assets.iter().map(|l| l.amount).sum();
|
|
let total_liabilities = liabilities.iter().map(|l| l.amount).sum();
|
|
let total_equity = equity.iter().map(|l| l.amount).sum::<Decimal>() + current_earnings;
|
|
Ok(BalanceSheetStatement {
|
|
as_of,
|
|
assets,
|
|
liabilities,
|
|
equity,
|
|
current_earnings,
|
|
total_assets,
|
|
total_liabilities,
|
|
total_equity,
|
|
})
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// General ledger (by account)
|
|
// ---------------------------------------------------------------------------
|
|
|
|
/// One posting in the general ledger: a journal line, seen from its account.
|
|
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]
|
|
pub struct LedgerPosting {
|
|
/// Entry date.
|
|
pub date: NaiveDate,
|
|
/// The journal entry: its other lines are the other side.
|
|
pub entry_id: crate::EntryId,
|
|
/// Entry description.
|
|
pub description: String,
|
|
/// The other side: `"CODE Name"` of the one account on the entry's
|
|
/// opposite side, or `"-Split-"` when that side is several accounts
|
|
/// (the QuickBooks convention).
|
|
pub split: String,
|
|
/// Debit amount, or zero.
|
|
pub debit: Decimal,
|
|
/// Credit amount, or zero.
|
|
pub credit: Decimal,
|
|
/// Running balance after this posting, on the account's normal side.
|
|
pub balance: Decimal,
|
|
}
|
|
|
|
/// One account's section of the general ledger.
|
|
#[derive(Clone, Debug, Serialize, Deserialize)]
|
|
pub struct LedgerAccount {
|
|
/// Account code.
|
|
pub code: String,
|
|
/// Account name.
|
|
pub name: String,
|
|
/// Account type.
|
|
pub account_type: AccountType,
|
|
/// Balance before the period, normal side.
|
|
pub beginning_balance: Decimal,
|
|
/// Postings in the period, by date then entry.
|
|
pub postings: Vec<LedgerPosting>,
|
|
/// Sum of the period's debits.
|
|
pub total_debits: Decimal,
|
|
/// Sum of the period's credits.
|
|
pub total_credits: Decimal,
|
|
/// Balance at period end, normal side. Ties to the trial balance.
|
|
pub ending_balance: Decimal,
|
|
}
|
|
|
|
/// The general ledger for a period: every account with a beginning balance
|
|
/// or activity, in chart (code) order.
|
|
#[derive(Clone, Debug, Serialize, Deserialize)]
|
|
pub struct GeneralLedger {
|
|
/// First date, inclusive.
|
|
pub start: NaiveDate,
|
|
/// Last date, inclusive.
|
|
pub end: NaiveDate,
|
|
/// Accounts by code.
|
|
pub accounts: Vec<LedgerAccount>,
|
|
}
|
|
|
|
/// Build the general ledger for `[start, end]`. Every journal line is a
|
|
/// posting to its account, with the entry's other side as the split, and a
|
|
/// running balance from the account's balance before `start`. Closing
|
|
/// entries are postings like any other.
|
|
pub fn general_ledger(
|
|
ledger: &Ledger,
|
|
start: NaiveDate,
|
|
end: NaiveDate,
|
|
) -> Result<GeneralLedger, LedgerError> {
|
|
if end < start {
|
|
return Err(LedgerError::InvalidDateRange {
|
|
start: start.to_string(),
|
|
end: end.to_string(),
|
|
});
|
|
}
|
|
let display = |id: crate::AccountId| -> Result<String, LedgerError> {
|
|
let a = ledger
|
|
.chart()
|
|
.get(id)
|
|
.ok_or(LedgerError::UnknownAccount(id))?;
|
|
Ok(format!("{} {}", a.code, a.name))
|
|
};
|
|
let mut entries: Vec<&JournalEntry> = ledger.posted_entries().collect();
|
|
entries.sort_by_key(|e| (e.date(), e.id()));
|
|
|
|
let mut begin: BTreeMap<crate::AccountId, Decimal> = BTreeMap::new();
|
|
let mut postings: BTreeMap<crate::AccountId, Vec<(crate::Side, Decimal, LedgerPosting)>> =
|
|
BTreeMap::new();
|
|
for entry in &entries {
|
|
let lines = entry.lines();
|
|
for (i, line) in lines.iter().enumerate() {
|
|
let signed = match line.side {
|
|
crate::Side::Debit => line.amount.amount,
|
|
crate::Side::Credit => -line.amount.amount,
|
|
};
|
|
if entry.date() < start {
|
|
*begin.entry(line.account).or_default() += signed;
|
|
continue;
|
|
}
|
|
if entry.date() > end {
|
|
continue;
|
|
}
|
|
// The other side is the accounts on the opposite side of the
|
|
// entry: a credit's split is what was debited, and vice versa.
|
|
let others: BTreeSet<crate::AccountId> = lines
|
|
.iter()
|
|
.enumerate()
|
|
.filter(|(j, l)| *j != i && l.side != line.side)
|
|
.map(|(_, l)| l.account)
|
|
.collect();
|
|
let split = match others.len() {
|
|
1 => display(*others.iter().next().expect("one"))?,
|
|
_ => "-Split-".to_string(),
|
|
};
|
|
let (debit, credit) = match line.side {
|
|
crate::Side::Debit => (line.amount.amount, Decimal::ZERO),
|
|
crate::Side::Credit => (Decimal::ZERO, line.amount.amount),
|
|
};
|
|
postings.entry(line.account).or_default().push((
|
|
line.side,
|
|
line.amount.amount,
|
|
LedgerPosting {
|
|
date: entry.date(),
|
|
entry_id: entry.id(),
|
|
description: entry.description().to_string(),
|
|
split,
|
|
debit,
|
|
credit,
|
|
balance: Decimal::ZERO,
|
|
},
|
|
));
|
|
}
|
|
}
|
|
let mut accounts: Vec<_> = ledger.chart().all().collect();
|
|
accounts.sort_by(|a, b| a.code.cmp(&b.code));
|
|
let mut out = Vec::new();
|
|
for account in accounts {
|
|
let begin_movement = begin.get(&account.id).copied().unwrap_or_default();
|
|
let lines = postings.remove(&account.id).unwrap_or_default();
|
|
if begin_movement.is_zero() && lines.is_empty() {
|
|
continue;
|
|
}
|
|
let normal = account.account_type.normal_side();
|
|
let to_normal = |m: Decimal| if normal == crate::Side::Debit { m } else { -m };
|
|
let beginning_balance = to_normal(begin_movement) + Decimal::ZERO;
|
|
let mut balance = beginning_balance;
|
|
let (mut total_debits, mut total_credits) = (Decimal::ZERO, Decimal::ZERO);
|
|
let mut posted = Vec::with_capacity(lines.len());
|
|
for (side, amount, mut p) in lines {
|
|
balance += if side == normal { amount } else { -amount };
|
|
total_debits += p.debit;
|
|
total_credits += p.credit;
|
|
p.balance = balance;
|
|
posted.push(p);
|
|
}
|
|
out.push(LedgerAccount {
|
|
code: account.code.clone(),
|
|
name: account.name.clone(),
|
|
account_type: account.account_type,
|
|
beginning_balance: zero_clean(beginning_balance),
|
|
postings: posted,
|
|
total_debits,
|
|
total_credits,
|
|
ending_balance: zero_clean(balance),
|
|
});
|
|
}
|
|
Ok(GeneralLedger {
|
|
start,
|
|
end,
|
|
accounts: out,
|
|
})
|
|
}
|
|
|
|
/// Never let a negative zero reach a report.
|
|
fn zero_clean(d: Decimal) -> Decimal {
|
|
if d.is_zero() { Decimal::ZERO } else { d }
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Statement of changes in members' equity (partnership capital accounts)
|
|
// ---------------------------------------------------------------------------
|
|
|
|
/// A member of a partnership (multi-member LLC): their capital account and
|
|
/// their share of income.
|
|
#[derive(Clone, Debug)]
|
|
pub struct MemberCapital {
|
|
/// Member's name.
|
|
pub name: String,
|
|
/// Their capital account (equity): contributions credit it,
|
|
/// distributions debit it.
|
|
pub capital_account: crate::AccountId,
|
|
/// Their distributions account (contra-equity), if distributions are
|
|
/// recorded there rather than directly against capital. Part of their
|
|
/// capital on every statement; zeroed into capital at close.
|
|
pub distribution_account: Option<crate::AccountId>,
|
|
/// Share of income, e.g. 0.5. The shares must sum to exactly 1.
|
|
pub share: Decimal,
|
|
}
|
|
|
|
/// One member's (or the total) capital roll-forward for a period.
|
|
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]
|
|
pub struct MemberEquityColumn {
|
|
/// Member's name, or "Total".
|
|
pub name: String,
|
|
/// Capital at the start of the period, including their share of
|
|
/// earnings not yet distributed.
|
|
pub beginning: Decimal,
|
|
/// Contributions during the period.
|
|
pub contributions: Decimal,
|
|
/// Their share of the period's net income (loss).
|
|
pub net_income: Decimal,
|
|
/// Distributions during the period (a positive number, subtracted).
|
|
pub distributions: Decimal,
|
|
/// Any other change in equity, allocated by share. Zero unless equity
|
|
/// moved through an account that isn't a member's capital account, retained
|
|
/// earnings, or the period's income.
|
|
pub other: Decimal,
|
|
/// Capital at the end of the period.
|
|
pub ending: Decimal,
|
|
}
|
|
|
|
/// The statement of changes in members' equity: each member's capital
|
|
/// account roll-forward, plus a total that ties to the balance sheet.
|
|
#[derive(Clone, Debug, Serialize, Deserialize)]
|
|
pub struct MembersEquityStatement {
|
|
/// First date, inclusive.
|
|
pub start: NaiveDate,
|
|
/// Last date, inclusive.
|
|
pub end: NaiveDate,
|
|
/// One column per member, in the order given.
|
|
pub members: Vec<MemberEquityColumn>,
|
|
/// Sum of the members. `ending` equals total equity on the balance
|
|
/// sheet as of `end`.
|
|
pub total: MemberEquityColumn,
|
|
}
|
|
|
|
/// Build the statement of changes in members' equity for `[start, end]`.
|
|
///
|
|
/// Each member's capital account supplies contributions (credits) and
|
|
/// distributions (debits). Earnings are partnership earnings: net income
|
|
/// for the period, and earnings from before it not yet distributed (retained
|
|
/// earnings plus unclosed income), are allocated by share. Allocation is to
|
|
/// the cent with the last member absorbing rounding, so every row's total
|
|
/// is exact and the ending total equals balance-sheet equity.
|
|
pub fn members_equity(
|
|
ledger: &Ledger,
|
|
start: NaiveDate,
|
|
end: NaiveDate,
|
|
members: &[MemberCapital],
|
|
) -> Result<MembersEquityStatement, LedgerError> {
|
|
if members.is_empty() {
|
|
return Err(LedgerError::InvalidMembers("no members given".to_string()));
|
|
}
|
|
let shares: Vec<Decimal> = members.iter().map(|m| m.share).collect();
|
|
let total_share: Decimal = shares.iter().sum();
|
|
if total_share != Decimal::ONE || shares.iter().any(|s| *s <= Decimal::ZERO) {
|
|
return Err(LedgerError::InvalidMembers(format!(
|
|
"member shares must be positive and sum to 1, got {total_share}"
|
|
)));
|
|
}
|
|
let mut seen = BTreeSet::new();
|
|
for m in members {
|
|
for id in std::iter::once(m.capital_account).chain(m.distribution_account) {
|
|
let account = ledger
|
|
.chart()
|
|
.get(id)
|
|
.ok_or(LedgerError::UnknownAccount(id))?;
|
|
if account.account_type != AccountType::Equity || !seen.insert(id) {
|
|
return Err(LedgerError::InvalidMembers(format!(
|
|
"{}: account {} must be a distinct equity account",
|
|
m.name, account.code
|
|
)));
|
|
}
|
|
}
|
|
}
|
|
let day_before = start.pred_opt().ok_or(LedgerError::InvalidDateRange {
|
|
start: start.to_string(),
|
|
end: end.to_string(),
|
|
})?;
|
|
let pnl = profit_and_loss_for_period(ledger, start, end, None)?;
|
|
let begin_bs = balance_sheet_as_of(ledger, day_before)?;
|
|
let end_bs = balance_sheet_as_of(ledger, end)?;
|
|
|
|
// Capital account balances before the period, and their movement in it.
|
|
let capital_before = |id: crate::AccountId| -> Decimal {
|
|
let mut m = Decimal::ZERO;
|
|
for e in ledger.posted_entries().filter(|e| e.date() < start) {
|
|
for l in e.lines().iter().filter(|l| l.account == id) {
|
|
match l.side {
|
|
crate::Side::Credit => m += l.amount.amount,
|
|
crate::Side::Debit => m -= l.amount.amount,
|
|
}
|
|
}
|
|
}
|
|
m
|
|
};
|
|
// Contributions and distributions move a capital account; a closing
|
|
// entry allocating earnings into it is income, already counted below.
|
|
let closings = ledger.closing_entry_ids();
|
|
let flows = |id: crate::AccountId| -> (Decimal, Decimal) {
|
|
let (mut credits, mut debits) = (Decimal::ZERO, Decimal::ZERO);
|
|
for e in ledger
|
|
.posted_entries()
|
|
.filter(|e| e.date() >= start && e.date() <= end && !closings.contains(&e.id()))
|
|
{
|
|
for l in e.lines().iter().filter(|l| l.account == id) {
|
|
match l.side {
|
|
crate::Side::Credit => credits += l.amount.amount,
|
|
crate::Side::Debit => debits += l.amount.amount,
|
|
}
|
|
}
|
|
}
|
|
(credits, debits)
|
|
};
|
|
// A member's capital is their capital account plus their distributions
|
|
// account (a debit balance, so it reduces capital).
|
|
let capital: Vec<Decimal> = members
|
|
.iter()
|
|
.map(|m| {
|
|
capital_before(m.capital_account)
|
|
+ m.distribution_account.map_or(Decimal::ZERO, capital_before)
|
|
})
|
|
.collect();
|
|
// (contributions, distributions): credits to capital are
|
|
// contributions; debits to capital and net debits to the distributions
|
|
// account are distributions.
|
|
let flow: Vec<(Decimal, Decimal)> = members
|
|
.iter()
|
|
.map(|m| {
|
|
let (credits, debits) = flows(m.capital_account);
|
|
let (dc, dd) = m
|
|
.distribution_account
|
|
.map_or((Decimal::ZERO, Decimal::ZERO), flows);
|
|
(credits, debits + dd - dc)
|
|
})
|
|
.collect();
|
|
|
|
// Everything in equity that isn't a capital account is partnership
|
|
// earnings not yet distributed: allocate it by share.
|
|
let undistributed = begin_bs.total_equity - capital.iter().sum::<Decimal>();
|
|
let begin_alloc = allocate(undistributed, &shares);
|
|
let income_alloc = allocate(pnl.net_income(), &shares);
|
|
let explained = flow.iter().map(|(c, d)| c - d).sum::<Decimal>() + pnl.net_income();
|
|
let other_alloc = allocate(
|
|
end_bs.total_equity - begin_bs.total_equity - explained,
|
|
&shares,
|
|
);
|
|
|
|
let mut cols = Vec::with_capacity(members.len());
|
|
for (i, m) in members.iter().enumerate() {
|
|
let beginning = capital[i] + begin_alloc[i];
|
|
let (contributions, distributions) = flow[i];
|
|
let ending = beginning + contributions - distributions + income_alloc[i] + other_alloc[i];
|
|
cols.push(MemberEquityColumn {
|
|
name: m.name.clone(),
|
|
beginning: zero_clean(beginning),
|
|
contributions,
|
|
net_income: zero_clean(income_alloc[i]),
|
|
distributions,
|
|
other: zero_clean(other_alloc[i]),
|
|
ending: zero_clean(ending),
|
|
});
|
|
}
|
|
let sum = |f: fn(&MemberEquityColumn) -> Decimal| cols.iter().map(f).sum::<Decimal>();
|
|
let total = MemberEquityColumn {
|
|
name: "Total".to_string(),
|
|
beginning: sum(|c| c.beginning),
|
|
contributions: sum(|c| c.contributions),
|
|
net_income: sum(|c| c.net_income),
|
|
distributions: sum(|c| c.distributions),
|
|
other: sum(|c| c.other),
|
|
ending: sum(|c| c.ending),
|
|
};
|
|
debug_assert_eq!(total.ending, end_bs.total_equity);
|
|
Ok(MembersEquityStatement {
|
|
start,
|
|
end,
|
|
members: cols,
|
|
total,
|
|
})
|
|
}
|
|
|
|
/// 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(),
|
|
},
|
|
}))
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Cash flow statement (indirect method, R-FUTURE-3).
|
|
// ---------------------------------------------------------------------------
|
|
|
|
/// One labeled cash flow line. Positive is an inflow, negative an outflow.
|
|
#[derive(Clone, Debug, Serialize, Deserialize)]
|
|
pub struct CashFlowLine {
|
|
/// Human label, e.g. `"Increase in Accounts Receivable"` or account code/name.
|
|
pub label: String,
|
|
/// Signed amount: positive = cash in, negative = cash out.
|
|
pub amount: Decimal,
|
|
/// Code of the account the line is about, when it is about one (so a
|
|
/// renderer can relabel, e.g. a member's capital account by name).
|
|
#[serde(default)]
|
|
pub account: Option<String>,
|
|
}
|
|
|
|
/// Parameters for [`cash_flow`].
|
|
#[derive(Clone, Debug)]
|
|
pub struct CashFlowParams {
|
|
/// Period start, inclusive.
|
|
pub start: chrono::NaiveDate,
|
|
/// Period end, inclusive.
|
|
pub end: chrono::NaiveDate,
|
|
/// Accounts treated as cash. Their net change is the reconciling total.
|
|
pub cash_accounts: Vec<crate::AccountId>,
|
|
/// Liability accounts treated as financing (debt). Default empty: all
|
|
/// liabilities are operating working capital, all equity is financing.
|
|
pub financing_accounts: Vec<crate::AccountId>,
|
|
}
|
|
|
|
/// Cash flow statement, indirect method.
|
|
///
|
|
/// Starts from period net income, adds back non-cash depreciation, adjusts
|
|
/// for working-capital changes, then reports investing (fixed-asset
|
|
/// purchases at cost, disposal proceeds) and financing (equity changes,
|
|
/// designated debt accounts) flows. The three sections always sum to the
|
|
/// net change in the designated cash accounts.
|
|
///
|
|
/// Disposals are identified structurally: a posted entry that credits a
|
|
/// registered fixed-asset cost account and debits its linked accumulated
|
|
/// depreciation account. The gain/loss in that entry is removed from
|
|
/// operating and the proceeds are shown in investing.
|
|
#[derive(Clone, Debug, Serialize, Deserialize)]
|
|
pub struct CashFlowStatement {
|
|
/// Period start, inclusive.
|
|
pub start: chrono::NaiveDate,
|
|
/// Period end, inclusive.
|
|
pub end: chrono::NaiveDate,
|
|
/// Period net income (revenue minus expenses).
|
|
pub net_income: Decimal,
|
|
/// Non-cash depreciation added back to net income.
|
|
pub depreciation: Decimal,
|
|
/// Working-capital adjustments (non-cash balance-sheet changes).
|
|
pub working_capital: Vec<CashFlowLine>,
|
|
/// Net cash from operating activities.
|
|
pub cash_from_operations: Decimal,
|
|
/// Investing flows: asset purchases and disposal proceeds.
|
|
pub investing: Vec<CashFlowLine>,
|
|
/// Net cash from investing activities.
|
|
pub cash_from_investing: Decimal,
|
|
/// Financing flows: equity changes and designated debt.
|
|
pub financing: Vec<CashFlowLine>,
|
|
/// Net cash from financing activities.
|
|
pub cash_from_financing: Decimal,
|
|
/// Net change in cash: the three sections summed.
|
|
pub net_change_in_cash: Decimal,
|
|
/// Cash balance at period start.
|
|
pub cash_beginning: Decimal,
|
|
/// Cash balance at period end.
|
|
pub cash_ending: Decimal,
|
|
}
|
|
|
|
/// Build the indirect-method cash flow statement for a period.
|
|
///
|
|
/// Pure function of the ledger: net income plus non-cash depreciation plus
|
|
/// working-capital changes gives operating cash flow; fixed-asset purchases
|
|
/// and disposal proceeds give investing; equity and designated debt give
|
|
/// financing. The three sections sum to the net change in `cash_accounts`.
|
|
pub fn cash_flow(
|
|
ledger: &Ledger,
|
|
params: &CashFlowParams,
|
|
) -> Result<CashFlowStatement, LedgerError> {
|
|
use std::collections::{HashMap, HashSet};
|
|
|
|
if params.end < params.start {
|
|
return Err(LedgerError::InvalidDateRange {
|
|
start: params.start.to_string(),
|
|
end: params.end.to_string(),
|
|
});
|
|
}
|
|
|
|
// Fixed-asset account roles from the register.
|
|
let mut cost_accounts: HashSet<crate::AccountId> = HashSet::new();
|
|
let mut accum_accounts: HashSet<crate::AccountId> = HashSet::new();
|
|
let mut depr_expense_accounts: HashSet<crate::AccountId> = HashSet::new();
|
|
// cost_account -> accum_account, for disposal identification.
|
|
let mut cost_to_accum: HashMap<crate::AccountId, crate::AccountId> = HashMap::new();
|
|
for asset in ledger.registered_assets() {
|
|
cost_accounts.insert(asset.cost_account);
|
|
accum_accounts.insert(asset.accum_depr_account);
|
|
depr_expense_accounts.insert(asset.expense_account);
|
|
cost_to_accum.insert(asset.cost_account, asset.accum_depr_account);
|
|
}
|
|
|
|
let cash_set: HashSet<crate::AccountId> = params.cash_accounts.iter().copied().collect();
|
|
let financing_set: HashSet<crate::AccountId> =
|
|
params.financing_accounts.iter().copied().collect();
|
|
|
|
// Movement helpers: debits minus credits over a date predicate. Closing
|
|
// entries are skipped: a close moves earnings into retained earnings,
|
|
// which is neither net income nor an owner flow.
|
|
let closings = ledger.closing_entry_ids();
|
|
let movement_where = |account: crate::AccountId, pred: &dyn Fn(chrono::NaiveDate) -> bool| {
|
|
let mut total = Decimal::ZERO;
|
|
for entry in ledger.posted_entries() {
|
|
if !pred(entry.date()) || closings.contains(&entry.id()) {
|
|
continue;
|
|
}
|
|
for line in entry.lines() {
|
|
if line.account == account {
|
|
match line.side {
|
|
crate::Side::Debit => total += line.amount.amount,
|
|
crate::Side::Credit => total -= line.amount.amount,
|
|
}
|
|
}
|
|
}
|
|
}
|
|
total
|
|
};
|
|
let begin_movement = |a: crate::AccountId| movement_where(a, &|d| d < params.start);
|
|
let period_movement =
|
|
|a: crate::AccountId| movement_where(a, &|d| d >= params.start && d <= params.end);
|
|
|
|
// Normal-side balance from a movement.
|
|
let normal = |account: crate::AccountId, movement: Decimal| -> Decimal {
|
|
let acct = ledger.chart().get(account).expect("chart account exists");
|
|
match acct.account_type.normal_side() {
|
|
crate::Side::Debit => movement,
|
|
crate::Side::Credit => -movement,
|
|
}
|
|
};
|
|
|
|
// --- Net income and depreciation (period P&L) ---
|
|
let mut net_income = Decimal::ZERO;
|
|
let mut depreciation = Decimal::ZERO;
|
|
for account in ledger.chart().all() {
|
|
if account.placeholder {
|
|
continue;
|
|
}
|
|
let pm = period_movement(account.id);
|
|
if pm.is_zero() {
|
|
continue;
|
|
}
|
|
match account.account_type {
|
|
AccountType::Revenue => net_income += -pm, // revenue movement is credits
|
|
AccountType::Expense => {
|
|
net_income -= pm;
|
|
if depr_expense_accounts.contains(&account.id) {
|
|
depreciation += pm;
|
|
}
|
|
}
|
|
_ => {}
|
|
}
|
|
}
|
|
|
|
// --- Classify balance-sheet accounts ---
|
|
let mut working_capital: Vec<CashFlowLine> = Vec::new();
|
|
let mut investing: Vec<CashFlowLine> = Vec::new();
|
|
let mut financing: Vec<CashFlowLine> = Vec::new();
|
|
|
|
// Account display helper.
|
|
let display = |account: crate::AccountId| -> String {
|
|
let a = ledger.chart().get(account).expect("chart account exists");
|
|
format!("{} {}", a.code, a.name)
|
|
};
|
|
|
|
// By code: the chart iterates in hash order, and a statement's lines
|
|
// must come out in the same order every time.
|
|
let mut accounts: Vec<_> = ledger.chart().all().collect();
|
|
accounts.sort_by(|a, b| a.code.cmp(&b.code));
|
|
for account in accounts {
|
|
if account.placeholder {
|
|
continue;
|
|
}
|
|
let id = account.id;
|
|
if cash_set.contains(&id) || cost_accounts.contains(&id) || accum_accounts.contains(&id) {
|
|
continue; // cash reconciles; fixed assets are investing (gross, below)
|
|
}
|
|
let begin = normal(id, begin_movement(id));
|
|
let end = normal(id, period_movement(id) + begin_movement(id));
|
|
let delta = end - begin;
|
|
if delta.is_zero() {
|
|
continue;
|
|
}
|
|
// Cash effect of the balance change: for both assets and
|
|
// liabilities, it is minus the movement (debits - credits).
|
|
let movement_delta = period_movement(id);
|
|
let cash_effect = -movement_delta;
|
|
let (label, line) = match account.account_type {
|
|
AccountType::Asset => {
|
|
let dir = if delta > Decimal::ZERO {
|
|
"Increase in"
|
|
} else {
|
|
"Decrease in"
|
|
};
|
|
(format!("{dir} {}", display(id)), cash_effect)
|
|
}
|
|
AccountType::Liability if financing_set.contains(&id) => {
|
|
let dir = if delta > Decimal::ZERO {
|
|
"Proceeds from"
|
|
} else {
|
|
"Repayment of"
|
|
};
|
|
(format!("{dir} {}", display(id)), cash_effect)
|
|
}
|
|
AccountType::Liability => {
|
|
let dir = if delta > Decimal::ZERO {
|
|
"Increase in"
|
|
} else {
|
|
"Decrease in"
|
|
};
|
|
(format!("{dir} {}", display(id)), cash_effect)
|
|
}
|
|
AccountType::Equity => {
|
|
let dir = if delta > Decimal::ZERO {
|
|
"Owner contributions"
|
|
} else {
|
|
"Owner distributions"
|
|
};
|
|
(format!("{dir} — {}", display(id)), cash_effect)
|
|
}
|
|
_ => continue, // revenue/expense handled via net income
|
|
};
|
|
let target = match account.account_type {
|
|
AccountType::Equity => &mut financing,
|
|
AccountType::Liability if financing_set.contains(&id) => &mut financing,
|
|
_ => &mut working_capital,
|
|
};
|
|
target.push(CashFlowLine {
|
|
label,
|
|
amount: line,
|
|
account: Some(account.code.clone()),
|
|
});
|
|
}
|
|
|
|
// --- Investing, gross: purchases and disposal proceeds ---
|
|
// A disposal entry credits a cost account and debits its linked accum.
|
|
let mut disposal_gain_loss = Decimal::ZERO; // +gain / -loss, to remove from operating
|
|
for entry in ledger.posted_entries() {
|
|
let d = entry.date();
|
|
if d < params.start || d > params.end {
|
|
continue;
|
|
}
|
|
// Is this a disposal? Credit to a cost account + debit to its accum.
|
|
let mut disposed: Option<(crate::AccountId, Decimal, Decimal)> = None;
|
|
for line in entry.lines() {
|
|
if line.side == crate::Side::Credit
|
|
&& let Some(&accum) = cost_to_accum.get(&line.account)
|
|
{
|
|
// Confirm the linked accum account is debited in this entry.
|
|
let accum_debit: Decimal = entry
|
|
.lines()
|
|
.iter()
|
|
.filter(|l| l.account == accum && l.side == crate::Side::Debit)
|
|
.map(|l| l.amount.amount)
|
|
.sum();
|
|
if !accum_debit.is_zero() {
|
|
disposed = Some((line.account, line.amount.amount, accum_debit));
|
|
break;
|
|
}
|
|
}
|
|
}
|
|
if let Some((cost_acct, cost_amount, accum_debit)) = disposed {
|
|
let accum_acct = cost_to_accum[&cost_acct];
|
|
let mut proceeds = Decimal::ZERO;
|
|
let mut proceeds_label = String::new();
|
|
let mut gain = Decimal::ZERO;
|
|
let mut loss = Decimal::ZERO;
|
|
for line in entry.lines() {
|
|
if line.account == cost_acct || line.account == accum_acct {
|
|
continue;
|
|
}
|
|
let acct = ledger
|
|
.chart()
|
|
.get(line.account)
|
|
.expect("chart account exists");
|
|
match (acct.account_type, line.side) {
|
|
(AccountType::Asset, crate::Side::Debit) => {
|
|
proceeds += line.amount.amount;
|
|
proceeds_label = display(line.account);
|
|
}
|
|
(AccountType::Revenue, crate::Side::Credit) => gain += line.amount.amount,
|
|
(AccountType::Expense, crate::Side::Debit) => loss += line.amount.amount,
|
|
_ => {}
|
|
}
|
|
}
|
|
let book_value = cost_amount - accum_debit;
|
|
// Sanity: the entry balances, so proceeds + accum debit = cost + gain - loss.
|
|
debug_assert!((proceeds + accum_debit - cost_amount - gain + loss).is_zero());
|
|
let _ = book_value;
|
|
disposal_gain_loss += gain - loss;
|
|
investing.push(CashFlowLine {
|
|
label: format!("Sale of fixed assets — {proceeds_label}"),
|
|
amount: proceeds,
|
|
account: ledger.chart().get(cost_acct).map(|a| a.code.clone()),
|
|
});
|
|
continue;
|
|
}
|
|
// Not a disposal: debits to cost accounts are purchases.
|
|
for line in entry.lines() {
|
|
if cost_accounts.contains(&line.account) && line.side == crate::Side::Debit {
|
|
investing.push(CashFlowLine {
|
|
label: format!("Purchase of fixed assets — {}", display(line.account)),
|
|
amount: -line.amount.amount,
|
|
account: ledger.chart().get(line.account).map(|a| a.code.clone()),
|
|
});
|
|
}
|
|
}
|
|
}
|
|
|
|
// Remove disposal gains/losses from operating (they live in investing).
|
|
if !disposal_gain_loss.is_zero() {
|
|
working_capital.push(CashFlowLine {
|
|
label: "Less: gain on sale of fixed assets (in investing)".to_string(),
|
|
amount: -disposal_gain_loss,
|
|
account: None,
|
|
});
|
|
}
|
|
|
|
let cash_from_operations: Decimal =
|
|
net_income + depreciation + working_capital.iter().map(|l| l.amount).sum::<Decimal>();
|
|
let cash_from_investing: Decimal = investing.iter().map(|l| l.amount).sum();
|
|
let cash_from_financing: Decimal = financing.iter().map(|l| l.amount).sum();
|
|
|
|
// --- Cash reconciliation ---
|
|
let mut cash_beginning = Decimal::ZERO;
|
|
let mut cash_ending = Decimal::ZERO;
|
|
for &id in ¶ms.cash_accounts {
|
|
cash_beginning += normal(id, begin_movement(id));
|
|
cash_ending += normal(id, begin_movement(id) + period_movement(id));
|
|
}
|
|
let net_change_in_cash = cash_ending - cash_beginning;
|
|
|
|
Ok(CashFlowStatement {
|
|
start: params.start,
|
|
end: params.end,
|
|
net_income,
|
|
depreciation,
|
|
working_capital,
|
|
cash_from_operations,
|
|
investing,
|
|
cash_from_investing,
|
|
financing,
|
|
cash_from_financing,
|
|
net_change_in_cash,
|
|
cash_beginning,
|
|
cash_ending,
|
|
})
|
|
}
|