diff --git a/crates/ledger-core/src/error.rs b/crates/ledger-core/src/error.rs index f9fdd1c..74f686d 100644 --- a/crates/ledger-core/src/error.rs +++ b/crates/ledger-core/src/error.rs @@ -132,6 +132,10 @@ pub enum LedgerError { #[error("purchase date plus useful life overflows the calendar")] AssetDateOverflow, + /// Partnership members are misconfigured (shares, capital accounts). + #[error("invalid members: {0}")] + InvalidMembers(String), + /// Cash-flow period end is before period start. #[error("invalid date range: end {end} is before start {start}")] InvalidDateRange { diff --git a/crates/ledger-core/src/lib.rs b/crates/ledger-core/src/lib.rs index 3c43e38..009e428 100644 --- a/crates/ledger-core/src/lib.rs +++ b/crates/ledger-core/src/lib.rs @@ -46,6 +46,8 @@ pub use payroll::{PayrollLine, PayrollRun, PayrollRunId}; pub use periods::{Period, PeriodId, PeriodStatus}; pub use reports::{ BalanceSheet, BalanceSheetStatement, CashFlowLine, CashFlowParams, CashFlowStatement, - ClassProfitAndLoss, ProfitAndLoss, ProfitAndLossStatement, StatementLine, TrialBalance, - TrialBalanceLine, balance_sheet_as_of, cash_flow, profit_and_loss_for_period, + ClassProfitAndLoss, GeneralLedger, LedgerAccount, LedgerPosting, MemberCapital, + 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, }; diff --git a/crates/ledger-core/src/reports.rs b/crates/ledger-core/src/reports.rs index 0d05edc..1aac603 100644 --- a/crates/ledger-core/src/reports.rs +++ b/crates/ledger-core/src/reports.rs @@ -44,6 +44,27 @@ impl TrialBalance { /// 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 = 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)); @@ -54,7 +75,7 @@ pub fn trial_balance(ledger: &Ledger) -> TrialBalance { if account.placeholder { continue; } - let movement = ledger.account_movement(account.id); + let movement = movements.get(&account.id).copied().unwrap_or_default(); if movement.is_zero() { 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, + /// 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, +} + +/// 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 { + if end < start { + return Err(LedgerError::InvalidDateRange { + start: start.to_string(), + end: end.to_string(), + }); + } + let display = |id: crate::AccountId| -> Result { + 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 = BTreeMap::new(); + let mut postings: BTreeMap> = + 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 = 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, + /// 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 { + use rust_decimal::RoundingStrategy::MidpointAwayFromZero; + let mut parts: Vec = 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::(); + } + 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 { + if members.is_empty() { + return Err(LedgerError::InvalidMembers("no members given".to_string())); + } + let shares: Vec = 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 = 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::(); + let begin_alloc = allocate(undistributed, &shares); + let income_alloc = allocate(pnl.net_income(), &shares); + let explained = flow.iter().map(|(c, d)| c - d).sum::() + 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::(); + 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 @@ -507,6 +896,10 @@ pub struct CashFlowLine { 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, } /// Parameters for [`cash_flow`]. @@ -734,6 +1127,7 @@ pub fn cash_flow( target.push(CashFlowLine { label, amount: line, + account: Some(account.code.clone()), }); } @@ -796,6 +1190,7 @@ pub fn cash_flow( 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; } @@ -805,6 +1200,7 @@ pub fn cash_flow( 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()), }); } } @@ -815,6 +1211,7 @@ pub fn cash_flow( working_capital.push(CashFlowLine { label: "Less: gain on sale of fixed assets (in investing)".to_string(), amount: -disposal_gain_loss, + account: None, }); } diff --git a/crates/ledger-core/tests/statements.rs b/crates/ledger-core/tests/statements.rs index 027d4cf..ea8eb3d 100644 --- a/crates/ledger-core/tests/statements.rs +++ b/crates/ledger-core/tests/statements.rs @@ -18,10 +18,11 @@ use ledger_core::reports::{ balance_sheet, balance_sheet_as_of, profit_and_loss, profit_and_loss_for_period, }; use ledger_core::{ - AccountId, AccountType, CashFlowParams, ClassId, Currency, Ledger, Money, Side, StatementLine, - cash_flow, + AccountId, AccountType, CashFlowParams, ClassId, Currency, Ledger, LedgerError, MemberCapital, + Money, Side, StatementLine, cash_flow, general_ledger, members_equity, trial_balance_as_of, }; use rust_decimal::Decimal; +use std::str::FromStr; fn d(m: u32, day: u32) -> NaiveDate { 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) { + 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> = cf.financing.iter().map(|f| f.account.as_deref()).collect(); + assert_eq!(accounts, [Some("3000"), Some("3001")]); +} diff --git a/docs/parity.md b/docs/parity.md index 14fb072..271baf3 100644 --- a/docs/parity.md +++ b/docs/parity.md @@ -21,6 +21,9 @@ No other statuses. | 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 | | 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` | | 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 | diff --git a/docs/requirements.md b/docs/requirements.md index e1ea344..923eb49 100644 --- a/docs/requirements.md +++ b/docs/requirements.md @@ -47,6 +47,24 @@ requirement conflict, the requirement wins. 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 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 core reports as deterministic JSON (decimal strings, sorted lines, fixed keys). Two engines fed the same books must produce identical output. This