feat: general ledger by account, trial balance as of, members' equity
Three reports a CPA expects that the engine could not produce: - general_ledger(start, end): the canonical general ledger, organized by account in code order. Each account has its beginning balance, every posting in the period (date, entry, description, debit, credit, running balance on the normal side) and its ending balance, which ties to the trial balance. Each posting names its split: the one account on the opposite side of its entry, or "-Split-" when that side has several, so a compound entry (one debit, several credits) reads correctly from every account it touches. The entry-ordered listing the exports called a "general ledger" is really the general journal. - trial_balance_as_of(as_of): a trial balance is always as of a date. - members_equity(start, end, members): the statement of changes in members' equity for a multi-member LLC taxed as a partnership. Each member's capital roll-forward: beginning (capital plus their share of undistributed earnings), contributions, allocated net income (loss), distributions, ending. Allocation is by ownership share to the cent, the last member absorbing rounding, so totals are exact and the ending total equals balance-sheet equity; any equity change the roll-forward can't explain shows as "other" instead of disappearing. Shares must be positive and sum to exactly 1; capital accounts must be distinct equity accounts (LedgerError::InvalidMembers). - CashFlowLine.account: the account a line is about, so renderers can name members instead of account codes. Tests (every expectation computed by hand): trial balance as of Jan 31; February general ledger with beginning balances, running balances, a card that nets to zero, an inactive account still listed, and every ending balance tied to the trial balance; a compound entry's splits from all three sides; a two-member partnership where 666.67 and 100.01 split unevenly (333.34/333.33, 50.01/50.00), with and without closing January, tying to the balance sheet each month; bad member configuration. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01MtYEaR6M6Ybdhm6X98BWNu
This commit is contained in:
6 files changed
+697
-5
No files matched your search
@@ -132,6 +132,10 @@ pub enum LedgerError {
|
|||||||
#[error("purchase date plus useful life overflows the calendar")]
|
#[error("purchase date plus useful life overflows the calendar")]
|
||||||
AssetDateOverflow,
|
AssetDateOverflow,
|
||||||
|
|
||||||
|
/// Partnership members are misconfigured (shares, capital accounts).
|
||||||
|
#[error("invalid members: {0}")]
|
||||||
|
InvalidMembers(String),
|
||||||
|
|
||||||
/// Cash-flow period end is before period start.
|
/// Cash-flow period end is before period start.
|
||||||
#[error("invalid date range: end {end} is before start {start}")]
|
#[error("invalid date range: end {end} is before start {start}")]
|
||||||
InvalidDateRange {
|
InvalidDateRange {
|
||||||
|
|||||||
@@ -46,6 +46,8 @@ pub use payroll::{PayrollLine, PayrollRun, PayrollRunId};
|
|||||||
pub use periods::{Period, PeriodId, PeriodStatus};
|
pub use periods::{Period, PeriodId, PeriodStatus};
|
||||||
pub use reports::{
|
pub use reports::{
|
||||||
BalanceSheet, BalanceSheetStatement, CashFlowLine, CashFlowParams, CashFlowStatement,
|
BalanceSheet, BalanceSheetStatement, CashFlowLine, CashFlowParams, CashFlowStatement,
|
||||||
ClassProfitAndLoss, ProfitAndLoss, ProfitAndLossStatement, StatementLine, TrialBalance,
|
ClassProfitAndLoss, GeneralLedger, LedgerAccount, LedgerPosting, MemberCapital,
|
||||||
TrialBalanceLine, balance_sheet_as_of, cash_flow, profit_and_loss_for_period,
|
MemberEquityColumn, MembersEquityStatement, ProfitAndLoss, ProfitAndLossStatement,
|
||||||
|
StatementLine, TrialBalance, TrialBalanceLine, balance_sheet_as_of, cash_flow, general_ledger,
|
||||||
|
members_equity, profit_and_loss_for_period, trial_balance_as_of,
|
||||||
};
|
};
|
||||||
@@ -44,6 +44,27 @@ impl TrialBalance {
|
|||||||
|
|
||||||
/// Build the trial balance from all posted entries.
|
/// Build the trial balance from all posted entries.
|
||||||
pub fn trial_balance(ledger: &Ledger) -> TrialBalance {
|
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();
|
let mut accounts: Vec<_> = ledger.chart().all().collect();
|
||||||
accounts.sort_by(|a, b| a.code.cmp(&b.code));
|
accounts.sort_by(|a, b| a.code.cmp(&b.code));
|
||||||
|
|
||||||
@@ -54,7 +75,7 @@ pub fn trial_balance(ledger: &Ledger) -> TrialBalance {
|
|||||||
if account.placeholder {
|
if account.placeholder {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
let movement = ledger.account_movement(account.id);
|
let movement = movements.get(&account.id).copied().unwrap_or_default();
|
||||||
if movement.is_zero() {
|
if movement.is_zero() {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
@@ -460,6 +481,374 @@ pub fn balance_sheet_as_of(
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// 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,
|
||||||
|
/// 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,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Split `amount` by `shares` to the cent; the last share absorbs rounding,
|
||||||
|
/// so the parts always sum to `amount` exactly.
|
||||||
|
fn allocate(amount: Decimal, shares: &[Decimal]) -> Vec<Decimal> {
|
||||||
|
use rust_decimal::RoundingStrategy::MidpointAwayFromZero;
|
||||||
|
let mut parts: Vec<Decimal> = shares
|
||||||
|
.iter()
|
||||||
|
.map(|s| (amount * s).round_dp_with_strategy(2, MidpointAwayFromZero))
|
||||||
|
.collect();
|
||||||
|
if let Some((last, rest)) = parts.split_last_mut() {
|
||||||
|
*last = amount - rest.iter().sum::<Decimal>();
|
||||||
|
}
|
||||||
|
parts
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 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 {
|
||||||
|
let account = ledger
|
||||||
|
.chart()
|
||||||
|
.get(m.capital_account)
|
||||||
|
.ok_or(LedgerError::UnknownAccount(m.capital_account))?;
|
||||||
|
if account.account_type != AccountType::Equity || !seen.insert(m.capital_account) {
|
||||||
|
return Err(LedgerError::InvalidMembers(format!(
|
||||||
|
"{}: capital 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
|
||||||
|
};
|
||||||
|
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)
|
||||||
|
{
|
||||||
|
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)
|
||||||
|
};
|
||||||
|
let capital: Vec<Decimal> = members
|
||||||
|
.iter()
|
||||||
|
.map(|m| capital_before(m.capital_account))
|
||||||
|
.collect();
|
||||||
|
let flow: Vec<(Decimal, Decimal)> = members.iter().map(|m| flows(m.capital_account)).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.
|
/// Render the three core reports as canonical JSON for oracle diffing.
|
||||||
///
|
///
|
||||||
/// Amounts are decimal strings (never floats, never locale-formatted), lines
|
/// Amounts are decimal strings (never floats, never locale-formatted), lines
|
||||||
@@ -507,6 +896,10 @@ pub struct CashFlowLine {
|
|||||||
pub label: String,
|
pub label: String,
|
||||||
/// Signed amount: positive = cash in, negative = cash out.
|
/// Signed amount: positive = cash in, negative = cash out.
|
||||||
pub amount: Decimal,
|
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`].
|
/// Parameters for [`cash_flow`].
|
||||||
@@ -734,6 +1127,7 @@ pub fn cash_flow(
|
|||||||
target.push(CashFlowLine {
|
target.push(CashFlowLine {
|
||||||
label,
|
label,
|
||||||
amount: line,
|
amount: line,
|
||||||
|
account: Some(account.code.clone()),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -796,6 +1190,7 @@ pub fn cash_flow(
|
|||||||
investing.push(CashFlowLine {
|
investing.push(CashFlowLine {
|
||||||
label: format!("Sale of fixed assets — {proceeds_label}"),
|
label: format!("Sale of fixed assets — {proceeds_label}"),
|
||||||
amount: proceeds,
|
amount: proceeds,
|
||||||
|
account: ledger.chart().get(cost_acct).map(|a| a.code.clone()),
|
||||||
});
|
});
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
@@ -805,6 +1200,7 @@ pub fn cash_flow(
|
|||||||
investing.push(CashFlowLine {
|
investing.push(CashFlowLine {
|
||||||
label: format!("Purchase of fixed assets — {}", display(line.account)),
|
label: format!("Purchase of fixed assets — {}", display(line.account)),
|
||||||
amount: -line.amount.amount,
|
amount: -line.amount.amount,
|
||||||
|
account: ledger.chart().get(line.account).map(|a| a.code.clone()),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -815,6 +1211,7 @@ pub fn cash_flow(
|
|||||||
working_capital.push(CashFlowLine {
|
working_capital.push(CashFlowLine {
|
||||||
label: "Less: gain on sale of fixed assets (in investing)".to_string(),
|
label: "Less: gain on sale of fixed assets (in investing)".to_string(),
|
||||||
amount: -disposal_gain_loss,
|
amount: -disposal_gain_loss,
|
||||||
|
account: None,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -18,10 +18,11 @@ use ledger_core::reports::{
|
|||||||
balance_sheet, balance_sheet_as_of, profit_and_loss, profit_and_loss_for_period,
|
balance_sheet, balance_sheet_as_of, profit_and_loss, profit_and_loss_for_period,
|
||||||
};
|
};
|
||||||
use ledger_core::{
|
use ledger_core::{
|
||||||
AccountId, AccountType, CashFlowParams, ClassId, Currency, Ledger, Money, Side, StatementLine,
|
AccountId, AccountType, CashFlowParams, ClassId, Currency, Ledger, LedgerError, MemberCapital,
|
||||||
cash_flow,
|
Money, Side, StatementLine, cash_flow, general_ledger, members_equity, trial_balance_as_of,
|
||||||
};
|
};
|
||||||
use rust_decimal::Decimal;
|
use rust_decimal::Decimal;
|
||||||
|
use std::str::FromStr;
|
||||||
|
|
||||||
fn d(m: u32, day: u32) -> NaiveDate {
|
fn d(m: u32, day: u32) -> NaiveDate {
|
||||||
NaiveDate::from_ymd_opt(2026, m, day).unwrap()
|
NaiveDate::from_ymd_opt(2026, m, day).unwrap()
|
||||||
@@ -265,3 +266,270 @@ fn cash_flow_lines_are_in_code_order() {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn cents(s: &str) -> Decimal {
|
||||||
|
Decimal::from_str(s).unwrap()
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn trial_balance_is_as_of_the_date() {
|
||||||
|
let b = books(false);
|
||||||
|
// Jan 31: Dr cash 11,700 and rent 800; Cr capital 10,000 and revenue 2,500.
|
||||||
|
let tb = trial_balance_as_of(&b.ledger, d(1, 31));
|
||||||
|
let rows: Vec<(&str, Decimal, Decimal)> = tb
|
||||||
|
.lines
|
||||||
|
.iter()
|
||||||
|
.map(|l| (l.code.as_str(), l.debit, l.credit))
|
||||||
|
.collect();
|
||||||
|
assert_eq!(
|
||||||
|
rows,
|
||||||
|
[
|
||||||
|
("1000", dec(11_700), dec(0)),
|
||||||
|
("3000", dec(0), dec(10_000)),
|
||||||
|
("4000", dec(0), dec(2_500)),
|
||||||
|
("6100", dec(800), dec(0)),
|
||||||
|
]
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
(tb.total_debits, tb.total_credits),
|
||||||
|
(dec(12_500), dec(12_500))
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn general_ledger_runs_balances_by_account() {
|
||||||
|
let b = books(false);
|
||||||
|
let gl = general_ledger(&b.ledger, d(2, 1), d(2, 28)).unwrap();
|
||||||
|
let codes: Vec<&str> = gl.accounts.iter().map(|a| a.code.as_str()).collect();
|
||||||
|
// Every account with a beginning balance or February activity, by code.
|
||||||
|
assert_eq!(codes, ["1000", "2000", "3000", "4000", "6000", "6100"]);
|
||||||
|
|
||||||
|
let cash = &gl.accounts[0];
|
||||||
|
assert_eq!(cash.beginning_balance, dec(11_700));
|
||||||
|
let p: Vec<(Decimal, Decimal, &str, Decimal)> = cash
|
||||||
|
.postings
|
||||||
|
.iter()
|
||||||
|
.map(|p| (p.debit, p.credit, p.split.as_str(), p.balance))
|
||||||
|
.collect();
|
||||||
|
assert_eq!(
|
||||||
|
p,
|
||||||
|
[
|
||||||
|
(dec(1_000), dec(0), "4000 Revenue", dec(12_700)),
|
||||||
|
(dec(0), dec(120), "2000 Card", dec(12_580)),
|
||||||
|
]
|
||||||
|
);
|
||||||
|
assert_eq!(cash.ending_balance, dec(12_580));
|
||||||
|
|
||||||
|
// The card: borrowed 120, paid 120, back to zero (credit-normal).
|
||||||
|
let card = &gl.accounts[1];
|
||||||
|
assert_eq!(card.beginning_balance, dec(0));
|
||||||
|
assert_eq!(card.postings[0].balance, dec(120));
|
||||||
|
assert_eq!(card.ending_balance, dec(0));
|
||||||
|
// Capital: no February activity, still listed with its balance.
|
||||||
|
assert!(gl.accounts[2].postings.is_empty());
|
||||||
|
assert_eq!(gl.accounts[2].ending_balance, dec(10_000));
|
||||||
|
|
||||||
|
// Every ending balance ties to the trial balance as of the end.
|
||||||
|
let tb = trial_balance_as_of(&b.ledger, d(2, 28));
|
||||||
|
for a in &gl.accounts {
|
||||||
|
let tb_line = tb.lines.iter().find(|l| l.code == a.code);
|
||||||
|
let tb_amount = tb_line.map_or(Decimal::ZERO, |l| l.debit - l.credit);
|
||||||
|
let normal = match a.account_type {
|
||||||
|
AccountType::Asset | AccountType::Expense => a.ending_balance,
|
||||||
|
_ => -a.ending_balance,
|
||||||
|
};
|
||||||
|
assert_eq!(tb_amount, normal, "account {}", a.code);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn general_ledger_marks_compound_entries_as_split() {
|
||||||
|
let mut l = Ledger::new(Currency::USD);
|
||||||
|
let cash = l
|
||||||
|
.add_account("1000", "Cash", AccountType::Asset, None, false)
|
||||||
|
.unwrap();
|
||||||
|
let card = l
|
||||||
|
.add_account("2000", "Card", AccountType::Liability, None, false)
|
||||||
|
.unwrap();
|
||||||
|
let soft = l
|
||||||
|
.add_account("6000", "Software", AccountType::Expense, None, false)
|
||||||
|
.unwrap();
|
||||||
|
// Dr software 100 / Cr cash 60 / Cr card 40: one debit, split credits.
|
||||||
|
let e = l.create_entry(d(3, 1), "Annual license, paid two ways");
|
||||||
|
l.add_line(e, soft, Side::Debit, usd(100)).unwrap();
|
||||||
|
l.add_line(e, cash, Side::Credit, usd(60)).unwrap();
|
||||||
|
l.add_line(e, card, Side::Credit, usd(40)).unwrap();
|
||||||
|
l.post_entry(e).unwrap();
|
||||||
|
let gl = general_ledger(&l, d(1, 1), d(12, 31)).unwrap();
|
||||||
|
let split_of = |code: &str| {
|
||||||
|
let a = gl.accounts.iter().find(|a| a.code == code).unwrap();
|
||||||
|
(a.postings[0].split.clone(), a.postings[0].entry_id)
|
||||||
|
};
|
||||||
|
// The debit side has two other accounts; each credit has one.
|
||||||
|
assert_eq!(split_of("6000"), ("-Split-".to_string(), e));
|
||||||
|
assert_eq!(split_of("1000"), ("6000 Software".to_string(), e));
|
||||||
|
assert_eq!(split_of("2000"), ("6000 Software".to_string(), e));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Two members, 50/50: Alice and Bob. January: Alice puts in 600, Bob 400;
|
||||||
|
/// revenue 1,000; expense 333.33, so net income 666.67, which doesn't split
|
||||||
|
/// evenly. February: Bob takes a 100 distribution; revenue 100.01.
|
||||||
|
fn partnership(close_january: bool) -> (Ledger, Vec<MemberCapital>) {
|
||||||
|
let mut l = Ledger::new(Currency::USD);
|
||||||
|
let cash = l
|
||||||
|
.add_account("1000", "Cash", AccountType::Asset, None, false)
|
||||||
|
.unwrap();
|
||||||
|
let alice = l
|
||||||
|
.add_account("3000", "Capital — Alice", AccountType::Equity, None, false)
|
||||||
|
.unwrap();
|
||||||
|
let bob = l
|
||||||
|
.add_account("3001", "Capital — Bob", AccountType::Equity, None, false)
|
||||||
|
.unwrap();
|
||||||
|
let rev = l
|
||||||
|
.add_account("4000", "Revenue", AccountType::Revenue, None, false)
|
||||||
|
.unwrap();
|
||||||
|
let exp = l
|
||||||
|
.add_account("6000", "Expense", AccountType::Expense, None, false)
|
||||||
|
.unwrap();
|
||||||
|
let money = |s: &str| Money::new(cents(s), Currency::USD);
|
||||||
|
let post = |l: &mut Ledger, date, dr, cr, amt: &str| {
|
||||||
|
let e = l.create_entry(date, "x");
|
||||||
|
l.add_line(e, dr, Side::Debit, money(amt)).unwrap();
|
||||||
|
l.add_line(e, cr, Side::Credit, money(amt)).unwrap();
|
||||||
|
l.post_entry(e).unwrap();
|
||||||
|
};
|
||||||
|
post(&mut l, d(1, 2), cash, alice, "600");
|
||||||
|
post(&mut l, d(1, 2), cash, bob, "400");
|
||||||
|
post(&mut l, d(1, 10), cash, rev, "1000");
|
||||||
|
post(&mut l, d(1, 20), exp, cash, "333.33");
|
||||||
|
if close_january {
|
||||||
|
let jan = l.create_period("2026-01", d(1, 1), d(1, 31)).unwrap();
|
||||||
|
l.close_period(jan).unwrap();
|
||||||
|
}
|
||||||
|
post(&mut l, d(2, 5), bob, cash, "100");
|
||||||
|
post(&mut l, d(2, 9), cash, rev, "100.01");
|
||||||
|
let half = cents("0.5");
|
||||||
|
let members = vec![
|
||||||
|
MemberCapital {
|
||||||
|
name: "Alice".into(),
|
||||||
|
capital_account: alice,
|
||||||
|
share: half,
|
||||||
|
},
|
||||||
|
MemberCapital {
|
||||||
|
name: "Bob".into(),
|
||||||
|
capital_account: bob,
|
||||||
|
share: half,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
(l, members)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn members_equity_rolls_capital_forward_and_ties_to_the_balance_sheet() {
|
||||||
|
for close in [false, true] {
|
||||||
|
let (l, members) = partnership(close);
|
||||||
|
// January: 666.67 splits 333.34 / 333.33 (half a cent rounds up for
|
||||||
|
// Alice; Bob gets the remainder so the total is exact).
|
||||||
|
let jan = members_equity(&l, d(1, 1), d(1, 31), &members).unwrap();
|
||||||
|
let row = |c: &ledger_core::MemberEquityColumn| {
|
||||||
|
(
|
||||||
|
c.beginning,
|
||||||
|
c.contributions,
|
||||||
|
c.net_income,
|
||||||
|
c.distributions,
|
||||||
|
c.ending,
|
||||||
|
)
|
||||||
|
};
|
||||||
|
assert_eq!(
|
||||||
|
row(&jan.members[0]),
|
||||||
|
(
|
||||||
|
cents("0"),
|
||||||
|
cents("600"),
|
||||||
|
cents("333.34"),
|
||||||
|
cents("0"),
|
||||||
|
cents("933.34")
|
||||||
|
)
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
row(&jan.members[1]),
|
||||||
|
(
|
||||||
|
cents("0"),
|
||||||
|
cents("400"),
|
||||||
|
cents("333.33"),
|
||||||
|
cents("0"),
|
||||||
|
cents("733.33")
|
||||||
|
)
|
||||||
|
);
|
||||||
|
assert_eq!(jan.total.ending, cents("1666.67"));
|
||||||
|
assert_eq!(
|
||||||
|
jan.total.ending,
|
||||||
|
balance_sheet_as_of(&l, d(1, 31)).unwrap().total_equity
|
||||||
|
);
|
||||||
|
|
||||||
|
// February begins where January ended, whether or not January was
|
||||||
|
// closed to retained earnings. 100.01 splits 50.01 / 50.00.
|
||||||
|
let feb = members_equity(&l, d(2, 1), d(2, 28), &members).unwrap();
|
||||||
|
assert_eq!(
|
||||||
|
row(&feb.members[0]),
|
||||||
|
(
|
||||||
|
cents("933.34"),
|
||||||
|
cents("0"),
|
||||||
|
cents("50.01"),
|
||||||
|
cents("0"),
|
||||||
|
cents("983.35")
|
||||||
|
)
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
row(&feb.members[1]),
|
||||||
|
(
|
||||||
|
cents("733.33"),
|
||||||
|
cents("0"),
|
||||||
|
cents("50.00"),
|
||||||
|
cents("100"),
|
||||||
|
cents("683.33")
|
||||||
|
)
|
||||||
|
);
|
||||||
|
assert_eq!(feb.total.ending, cents("1666.68"));
|
||||||
|
assert_eq!(
|
||||||
|
feb.total.ending,
|
||||||
|
balance_sheet_as_of(&l, d(2, 28)).unwrap().total_equity
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
feb.members.iter().all(|m| m.other.is_zero()),
|
||||||
|
"close={close}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn members_must_be_configured_correctly() {
|
||||||
|
let (l, mut members) = partnership(false);
|
||||||
|
members[1].share = cents("0.4");
|
||||||
|
assert!(matches!(
|
||||||
|
members_equity(&l, d(1, 1), d(1, 31), &members),
|
||||||
|
Err(LedgerError::InvalidMembers(_))
|
||||||
|
));
|
||||||
|
members[1].share = cents("0.5");
|
||||||
|
members[1].capital_account = members[0].capital_account;
|
||||||
|
assert!(matches!(
|
||||||
|
members_equity(&l, d(1, 1), d(1, 31), &members),
|
||||||
|
Err(LedgerError::InvalidMembers(_))
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn cash_flow_lines_name_their_account() {
|
||||||
|
let (l, _) = partnership(false);
|
||||||
|
let cf = cash_flow(
|
||||||
|
&l,
|
||||||
|
&CashFlowParams {
|
||||||
|
start: d(1, 1),
|
||||||
|
end: d(12, 31),
|
||||||
|
cash_accounts: vec![1],
|
||||||
|
financing_accounts: vec![],
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
let accounts: Vec<Option<&str>> = cf.financing.iter().map(|f| f.account.as_deref()).collect();
|
||||||
|
assert_eq!(accounts, [Some("3000"), Some("3001")]);
|
||||||
|
}
|
||||||
@@ -21,6 +21,9 @@ No other statuses.
|
|||||||
| Profit & loss | Reports → Income Statement | done | golden-covered |
|
| Profit & loss | Reports → Income Statement | done | golden-covered |
|
||||||
| P&L for a date range, line per account (closing entries excluded) | Reports → Income Statement (custom dates) | partial | hand-verified fixture + property tests; no golden file yet |
|
| P&L for a date range, line per account (closing entries excluded) | Reports → Income Statement (custom dates) | partial | hand-verified fixture + property tests; no golden file yet |
|
||||||
| Balance sheet as of a date, line per account | Reports → Balance Sheet (as of) | partial | hand-verified fixture + property tests; no golden file yet |
|
| Balance sheet as of a date, line per account | Reports → Balance Sheet (as of) | partial | hand-verified fixture + property tests; no golden file yet |
|
||||||
|
| General ledger by account (running balance, split) | Reports → General Ledger | partial | hand-verified fixture, ties to trial balance; no golden file yet |
|
||||||
|
| Trial balance as of a date | Reports → Trial Balance (as of) | partial | hand-verified fixture; no golden file yet |
|
||||||
|
| Statement of changes in members' equity | Reports → Statement of Changes in Equity | partial | hand-verified two-member fixture incl. rounding and a close; no golden file yet |
|
||||||
| Reversing entries | Reverse transaction | done | linked via `reversal_of` |
|
| Reversing entries | Reverse transaction | done | linked via `reversal_of` |
|
||||||
| Canonical report snapshots (JSON) | — (our oracle format) | done | `reports::snapshot` |
|
| Canonical report snapshots (JSON) | — (our oracle format) | done | `reports::snapshot` |
|
||||||
| Fixed-asset register + straight-line depreciation | Fixed Asset register | done | `assets.rs`; golden-covered schedule; idempotent runs; disposal with gain/loss |
|
| Fixed-asset register + straight-line depreciation | Fixed Asset register | done | `assets.rs`; golden-covered schedule; idempotent runs; disposal with gain/loss |
|
||||||
|
|||||||
@@ -47,6 +47,24 @@ requirement conflict, the requirement wins.
|
|||||||
reason. Period P&Ls partition: split a range anywhere and the halves sum
|
reason. Period P&Ls partition: split a range anywhere and the halves sum
|
||||||
to the whole, and a range's net income equals the change in current
|
to the whole, and a range's net income equals the change in current
|
||||||
earnings across it (property-tested).
|
earnings across it (property-tested).
|
||||||
|
- **R-9a — General ledger.** `general_ledger(start, end)`: by account in
|
||||||
|
code order, each with its beginning balance, every posting in the period
|
||||||
|
(date, entry, description, debit, credit, running balance on the account's
|
||||||
|
normal side), and its ending balance, which ties to the trial balance as
|
||||||
|
of `end`. Each posting names its *split*: the one account on the entry's
|
||||||
|
opposite side, or "-Split-" when that side has several (the QuickBooks
|
||||||
|
convention). The entry-ordered listing is the *general journal*, a
|
||||||
|
different report.
|
||||||
|
- **R-9b — Trial balance as of a date.** `trial_balance_as_of(as_of)`.
|
||||||
|
- **R-9c — Statement of changes in members' equity.** For a multi-member LLC
|
||||||
|
(partnership): each member's capital roll-forward — beginning,
|
||||||
|
contributions (credits to their capital account), allocated net income
|
||||||
|
(loss), distributions (debits), ending — plus a total that equals
|
||||||
|
balance-sheet equity. Earnings not yet distributed (retained earnings and
|
||||||
|
unclosed income) and the period's income are allocated by ownership share
|
||||||
|
to the cent, the last member absorbing rounding, so every row's total is
|
||||||
|
exact. Any equity change the roll-forward doesn't explain is shown as
|
||||||
|
"other", never hidden.
|
||||||
- **R-10 — Canonical snapshots.** `reports::snapshot()` renders the three
|
- **R-10 — Canonical snapshots.** `reports::snapshot()` renders the three
|
||||||
core reports as deterministic JSON (decimal strings, sorted lines, fixed
|
core reports as deterministic JSON (decimal strings, sorted lines, fixed
|
||||||
keys). Two engines fed the same books must produce identical output. This
|
keys). Two engines fed the same books must produce identical output. This
|
||||||
|
|||||||
Reference in new issue
Block a user