//! Period-scoped financial statements: profit and loss for a date range and //! the balance sheet as of a date, line per account. //! //! Every expected number below is computed by hand from the entries, not //! read off the engine. The fixture is two months of a small company: //! //! | date | entry | Dr | Cr | //! |--------|-------------------------------|---------------|---------------| //! | Jan 2 | owner contributes 10,000 | 1000 Cash | 3000 Capital | //! | Jan 15 | client pays 2,500 | 1000 Cash | 4000 Revenue | //! | Jan 20 | rent 800 | 6100 Rent | 1000 Cash | //! | Feb 3 | software 120 on the card | 6000 Software | 2000 Card | //! | Feb 10 | client pays 1,000 | 1000 Cash | 4000 Revenue | //! | Feb 25 | pay the card 120 | 2000 Card | 1000 Cash | use chrono::NaiveDate; use ledger_core::reports::{ balance_sheet, balance_sheet_as_of, profit_and_loss_for_period, unclosed_earnings, }; use ledger_core::{ 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() } fn usd(n: i64) -> Money { Money::new(Decimal::from(n), Currency::USD) } fn dec(n: i64) -> Decimal { Decimal::from(n) } struct Books { ledger: Ledger, cash: AccountId, software_class: ClassId, } fn post(ledger: &mut Ledger, date: NaiveDate, desc: &str, dr: AccountId, cr: AccountId, n: i64) { let e = ledger.create_entry(date, desc); ledger.add_line(e, dr, Side::Debit, usd(n)).unwrap(); ledger.add_line(e, cr, Side::Credit, usd(n)).unwrap(); ledger.post_entry(e).unwrap(); } /// The fixture. With `close_january`, January is closed before any February /// entry exists (the close sweeps every revenue and expense balance). fn books(close_january: bool) -> Books { 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 capital = l .add_account("3000", "Capital", AccountType::Equity, None, false) .unwrap(); let revenue = l .add_account("4000", "Revenue", AccountType::Revenue, None, false) .unwrap(); let software = l .add_account("6000", "Software", AccountType::Expense, None, false) .unwrap(); let rent = l .add_account("6100", "Rent", AccountType::Expense, None, false) .unwrap(); let software_class = l.add_class("Apps").unwrap(); post(&mut l, d(1, 2), "Owner contribution", cash, capital, 10_000); post(&mut l, d(1, 15), "Client payment", cash, revenue, 2_500); post(&mut l, d(1, 20), "Rent", rent, cash, 800); if close_january { let jan = l.create_period("2026-01", d(1, 1), d(1, 31)).unwrap(); l.close_period(jan).unwrap(); } // Software on the card, tagged to the Apps class. let e = l.create_entry(d(2, 3), "Software"); l.add_line(e, software, Side::Debit, usd(120)).unwrap(); l.add_line(e, card, Side::Credit, usd(120)).unwrap(); l.set_line_class(e, 0, Some(software_class)).unwrap(); l.post_entry(e).unwrap(); post(&mut l, d(2, 10), "Client payment", cash, revenue, 1_000); post(&mut l, d(2, 25), "Pay card", card, cash, 120); Books { ledger: l, cash, software_class, } } fn line(code: &str, name: &str, n: i64) -> StatementLine { StatementLine { code: code.to_string(), name: name.to_string(), amount: dec(n), } } #[test] fn profit_and_loss_is_scoped_to_the_period() { let b = books(false); let jan = profit_and_loss_for_period(&b.ledger, d(1, 1), d(1, 31), None).unwrap(); assert_eq!(jan.revenue, vec![line("4000", "Revenue", 2_500)]); assert_eq!(jan.expenses, vec![line("6100", "Rent", 800)]); assert_eq!(jan.net_income(), dec(1_700)); let feb = profit_and_loss_for_period(&b.ledger, d(2, 1), d(2, 28), None).unwrap(); assert_eq!(feb.revenue, vec![line("4000", "Revenue", 1_000)]); assert_eq!(feb.expenses, vec![line("6000", "Software", 120)]); assert_eq!(feb.net_income(), dec(880)); // Both months: 3,500 revenue, 920 expenses, lines sorted by code. let both = profit_and_loss_for_period(&b.ledger, d(1, 1), d(2, 28), None).unwrap(); assert_eq!( both.expenses, vec![line("6000", "Software", 120), line("6100", "Rent", 800)] ); assert_eq!( (both.total_revenue, both.total_expenses), (dec(3_500), dec(920)) ); // ... which is the life-of-ledger P&L, because nothing is outside it. let all = unclosed_earnings(&b.ledger).unwrap(); assert_eq!((all.revenue, all.expenses), (dec(3_500), dec(920))); // One class: only the tagged software line. let apps = profit_and_loss_for_period(&b.ledger, d(1, 1), d(12, 31), Some(b.software_class)).unwrap(); assert!(apps.revenue.is_empty()); assert_eq!(apps.expenses, vec![line("6000", "Software", 120)]); // A backwards range is rejected, not silently empty. assert!(profit_and_loss_for_period(&b.ledger, d(2, 1), d(1, 1), None).is_err()); } #[test] fn balance_sheet_is_as_of_the_date() { let b = books(false); // Before anything happened: empty and balanced. let start = balance_sheet_as_of(&b.ledger, d(1, 1)).unwrap(); assert!(start.assets.is_empty() && start.equity.is_empty()); assert!(start.balanced()); // Jan 31: cash 10,000 + 2,500 − 800 = 11,700; equity 10,000 + 1,700. let jan = balance_sheet_as_of(&b.ledger, d(1, 31)).unwrap(); assert_eq!(jan.assets, vec![line("1000", "Cash", 11_700)]); assert!(jan.liabilities.is_empty()); assert_eq!(jan.equity, vec![line("3000", "Capital", 10_000)]); assert_eq!(jan.current_earnings, dec(1_700)); assert_eq!(jan.total_equity, dec(11_700)); assert!(jan.balanced()); // Feb 15: cash 12,700; the card owes 120; earnings 1,700 + 880. let mid = balance_sheet_as_of(&b.ledger, d(2, 15)).unwrap(); assert_eq!(mid.total_assets, dec(12_700)); assert_eq!(mid.liabilities, vec![line("2000", "Card", 120)]); assert_eq!(mid.current_earnings, dec(2_580)); assert_eq!(mid.total_liabilities + mid.total_equity, dec(12_700)); // Feb 28: card paid off, so it drops off the statement. let end = balance_sheet_as_of(&b.ledger, d(2, 28)).unwrap(); assert_eq!(end.total_assets, dec(12_580)); assert!(end.liabilities.is_empty()); assert!(end.balanced()); // ... and agrees with the life-of-ledger balance sheet. let all = balance_sheet(&b.ledger).unwrap(); assert_eq!( (all.assets, all.liabilities, all.equity), (end.total_assets, end.total_liabilities, end.total_equity) ); } #[test] fn a_closed_period_still_reports_its_earnings() { let b = books(true); // The close posted Dr 4000 2,500 / Cr 6100 800 / Cr 3999 1,700 on Jan // 31. The January P&L must not count it: January earned 1,700. let jan = profit_and_loss_for_period(&b.ledger, d(1, 1), d(1, 31), None).unwrap(); assert_eq!(jan.net_income(), dec(1_700)); assert_eq!(jan.revenue, vec![line("4000", "Revenue", 2_500)]); let feb = profit_and_loss_for_period(&b.ledger, d(2, 1), d(2, 28), None).unwrap(); assert_eq!(feb.net_income(), dec(880)); // After the close, January's earnings sit in retained earnings, not in // current earnings; February's are current. let jan_bs = balance_sheet_as_of(&b.ledger, d(1, 31)).unwrap(); assert_eq!( jan_bs.equity, vec![ line("3000", "Capital", 10_000), line("3999", "Retained Earnings", 1_700) ] ); assert_eq!(jan_bs.current_earnings, Decimal::ZERO); assert!(jan_bs.balanced()); let feb_bs = balance_sheet_as_of(&b.ledger, d(2, 28)).unwrap(); assert_eq!(feb_bs.current_earnings, dec(880)); assert_eq!(feb_bs.total_equity, dec(12_580)); assert!(feb_bs.balanced()); // The cash flow for January: net income 1,700, and the only financing // flow is the owner's 10,000. The close is not an owner contribution. let cf = cash_flow( &b.ledger, &CashFlowParams { start: d(1, 1), end: d(1, 31), cash_accounts: vec![b.cash], financing_accounts: vec![], investing_accounts: vec![], }, ) .unwrap(); assert_eq!(cf.net_income, dec(1_700)); assert_eq!(cf.financing.len(), 1); assert_eq!(cf.cash_from_financing, dec(10_000)); assert_eq!(cf.net_change_in_cash, dec(11_700)); } /// Cash flow lines come out in account-code order, every time. (The chart /// iterates in hash order; the statement must not.) #[test] fn cash_flow_lines_are_in_code_order() { for _ in 0..20 { let mut l = Ledger::new(Currency::USD); let cash = l .add_account("1000", "Cash", AccountType::Asset, None, false) .unwrap(); let mut owners = Vec::new(); for code in ["3005", "3001", "3009", "3000", "3003"] { owners.push( l.add_account(code, code, AccountType::Equity, None, false) .unwrap(), ); } for o in &owners { post(&mut l, d(3, 1), "Contribution", cash, *o, 100); } let cf = cash_flow( &l, &CashFlowParams { start: d(1, 1), end: d(12, 31), cash_accounts: vec![cash], financing_accounts: vec![], investing_accounts: vec![], }, ) .unwrap(); // Labels read "Owner contributions — 3000 3000" (code, then name). let codes: Vec<&str> = cf .financing .iter() .map(|f| f.label.rsplit(' ').next().unwrap()) .collect(); assert_eq!( codes, ["3000", "3001", "3003", "3005", "3009"], "{:?}", cf.financing ); } } 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, distribution_account: None, share: half, }, MemberCapital { name: "Bob".into(), capital_account: bob, distribution_account: None, 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 (still undistributed either way). // Cumulative split (docs/testing.md #7): everything to date, 666.67 // + 100.01 = 766.68, is 383.34 each, so Alice's February share is // 383.34 - 333.34 = 50.00 and Bob's 383.34 - 333.33 = 50.01. let feb = members_equity(&l, d(2, 1), d(2, 28), &members).unwrap(); let (alice, bob) = (("50.00", "983.34"), ("50.01", "683.34")); assert_eq!( row(&feb.members[0]), ( cents("933.34"), cents("0"), cents(alice.0), cents("0"), cents(alice.1) ) ); assert_eq!( row(&feb.members[1]), ( cents("733.33"), cents("0"), cents(bob.0), cents("100"), cents(bob.1) ) ); 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![], investing_accounts: vec![], }, ) .unwrap(); let accounts: Vec> = cf.financing.iter().map(|f| f.account.as_deref()).collect(); // Gross (ASC 230-10-45-7): 3001 contributed and took a distribution, // so it has a line for each. assert_eq!(accounts, [Some("3000"), Some("3001"), Some("3001")]); assert!(cf.financing[1].label.starts_with("Owner contributions")); assert!(cf.financing[2].label.starts_with("Owner distributions")); assert!(cf.financing[1].amount > Decimal::ZERO && cf.financing[2].amount < Decimal::ZERO); } #[test] fn a_close_sweeps_only_through_the_period_end() { // February is already posted when January closes: the close must take // January's 2,500 revenue and 800 rent (net 1,700) and leave // February's 1,000 revenue and 120 software open. let mut b = books(false); let jan = b .ledger .create_period("2026-01", d(1, 1), d(1, 31)) .unwrap(); let closing = b.ledger.close_period(jan).unwrap().unwrap(); let lines: Vec<(String, Side, Decimal)> = b .ledger .get_entry(closing) .unwrap() .lines() .iter() .map(|l| { ( b.ledger.chart().get(l.account).unwrap().code.clone(), l.side, l.amount.amount, ) }) .collect(); assert_eq!( lines, [ ("4000".to_string(), Side::Debit, dec(2_500)), ("6100".to_string(), Side::Credit, dec(800)), ("3999".to_string(), Side::Credit, dec(1_700)), ] ); let feb = profit_and_loss_for_period(&b.ledger, d(2, 1), d(2, 28), None).unwrap(); assert_eq!(feb.net_income(), dec(880)); let bs = balance_sheet_as_of(&b.ledger, d(2, 28)).unwrap(); assert_eq!(bs.current_earnings, dec(880)); assert_eq!( bs.equity.last().unwrap(), &line("3999", "Retained Earnings", 1_700) ); assert!(bs.balanced()); } #[test] fn periods_close_in_order() { let mut b = books(false); let jan = b .ledger .create_period("2026-01", d(1, 1), d(1, 31)) .unwrap(); let feb = b .ledger .create_period("2026-02", d(2, 1), d(2, 28)) .unwrap(); b.ledger.close_period(feb).unwrap(); // February swept everything through Feb 28, January included. assert!(matches!( b.ledger.close_period(jan), Err(LedgerError::CloseOutOfOrder(j, f)) if j == jan && f == feb )); } #[test] fn a_partnership_closes_into_members_capital() { let (mut l, members) = partnership(false); let to = ledger_core::CloseTo::Members( members .iter() .map(|m| ledger_core::MemberClose { capital: m.capital_account, share: m.share, distributions: None, }) .collect(), ); let jan = l.create_period("2026-01", d(1, 1), d(1, 31)).unwrap(); l.close_period_to(jan, &to).unwrap(); // January earned 666.67: Alice's capital gets 333.34, Bob's 333.33, // and there is no Retained Earnings account at all. assert!(l.chart().find_by_code("3999").is_none()); let bs = balance_sheet_as_of(&l, d(1, 31)).unwrap(); let capital: Vec = bs.equity.iter().map(|e| e.amount).collect(); assert_eq!(capital, [cents("933.34"), cents("733.33")]); assert_eq!(bs.current_earnings, Decimal::ZERO); // The statement still reads: contributions are contributions (the // close is income, not a contribution), and February starts where // January ended. let row = |c: &ledger_core::MemberEquityColumn| { ( c.beginning, c.contributions, c.net_income, c.distributions, c.ending, ) }; let jan_me = members_equity(&l, d(1, 1), d(1, 31), &members).unwrap(); assert_eq!( row(&jan_me.members[0]), ( cents("0"), cents("600"), cents("333.34"), cents("0"), cents("933.34") ) ); assert_eq!( row(&jan_me.members[1]), ( cents("0"), cents("400"), cents("333.33"), cents("0"), cents("733.33") ) ); let feb_me = members_equity(&l, d(2, 1), d(2, 28), &members).unwrap(); assert_eq!(feb_me.members[0].beginning, cents("933.34")); assert_eq!(feb_me.members[1].ending, cents("683.33")); assert_eq!( feb_me.total.ending, balance_sheet_as_of(&l, d(2, 28)).unwrap().total_equity ); // Shares must still add up. let feb = l.create_period("2026-02", d(2, 1), d(2, 28)).unwrap(); let bad = ledger_core::CloseTo::Members(vec![ledger_core::MemberClose { capital: members[0].capital_account, share: cents("0.5"), distributions: None, }]); assert!(matches!( l.close_period_to(feb, &bad), Err(LedgerError::InvalidMembers(_)) )); } fn entry_lines(l: &Ledger, id: ledger_core::EntryId) -> Vec<(String, Side, Decimal)> { l.get_entry(id) .unwrap() .lines() .iter() .map(|x| { ( l.chart().get(x.account).unwrap().code.clone(), x.side, x.amount.amount, ) }) .collect() } #[test] fn a_sole_proprietor_closes_net_income_and_draws_into_capital() { let mut l = Ledger::new(Currency::USD); let cash = l .add_account("1000", "Cash", AccountType::Asset, None, false) .unwrap(); let capital = l .add_account("3000", "Owner's Capital", AccountType::Equity, None, false) .unwrap(); let draws = l .add_account("3100", "Owner's Draws", AccountType::Equity, None, false) .unwrap(); let sales = l .add_account("4000", "Sales", AccountType::Revenue, None, false) .unwrap(); let rent = l .add_account("6000", "Rent", AccountType::Expense, None, false) .unwrap(); post(&mut l, d(1, 2), "Owner invests", cash, capital, 5_000); post(&mut l, d(1, 10), "Sales", cash, sales, 3_000); post(&mut l, d(1, 15), "Rent", rent, cash, 1_000); post(&mut l, d(1, 20), "Owner draw", draws, cash, 500); let jan = l.create_period("2026-01", d(1, 1), d(1, 31)).unwrap(); let to = ledger_core::CloseTo::Account { into: capital, sweep: vec![draws], }; let closing = l.close_period_to(jan, &to).unwrap().unwrap(); // Net 2,000 to capital; the 500 of draws zeroed into capital. assert_eq!( entry_lines(&l, closing), [ ("4000".to_string(), Side::Debit, dec(3_000)), ("6000".to_string(), Side::Credit, dec(1_000)), ("3000".to_string(), Side::Credit, dec(2_000)), ("3100".to_string(), Side::Credit, dec(500)), ("3000".to_string(), Side::Debit, dec(500)), ] ); let bs = balance_sheet_as_of(&l, d(1, 31)).unwrap(); // 5,000 + 2,000 − 500 = 6,500, which is exactly the cash. assert_eq!(bs.equity, vec![line("3000", "Owner's Capital", 6_500)]); assert_eq!(bs.total_assets, dec(6_500)); assert_eq!(bs.current_earnings, Decimal::ZERO); } #[test] fn a_corporation_closes_dividends_into_retained_earnings() { let mut l = Ledger::new(Currency::USD); let cash = l .add_account("1000", "Cash", AccountType::Asset, None, false) .unwrap(); let stock = l .add_account("3000", "Common Stock", AccountType::Equity, None, false) .unwrap(); let dividends = l .add_account("3200", "Dividends Paid", AccountType::Equity, None, false) .unwrap(); let sales = l .add_account("4000", "Sales", AccountType::Revenue, None, false) .unwrap(); post(&mut l, d(1, 2), "Stock issued", cash, stock, 1_000); post(&mut l, d(1, 10), "Sales", cash, sales, 900); post(&mut l, d(1, 25), "Dividend", dividends, cash, 300); let jan = l.create_period("2026-01", d(1, 1), d(1, 31)).unwrap(); let to = ledger_core::CloseTo::RetainedEarnings { sweep: vec![dividends], }; l.close_period_to(jan, &to).unwrap(); let bs = balance_sheet_as_of(&l, d(1, 31)).unwrap(); // Retained earnings: 900 earned − 300 paid out. assert_eq!( bs.equity, vec![ line("3000", "Common Stock", 1_000), line("3999", "Retained Earnings", 600) ] ); assert!(bs.balanced()); } #[test] fn partner_distribution_accounts_roll_into_capital() { let mut l = Ledger::new(Currency::USD); let cash = l .add_account("1000", "Cash", AccountType::Asset, None, false) .unwrap(); let cap_a = l .add_account("3000", "Capital — Alice", AccountType::Equity, None, false) .unwrap(); let cap_b = l .add_account("3001", "Capital — Bob", AccountType::Equity, None, false) .unwrap(); let dist_a = l .add_account( "3100", "Distributions — Alice", AccountType::Equity, None, false, ) .unwrap(); let dist_b = l .add_account( "3101", "Distributions — Bob", AccountType::Equity, None, false, ) .unwrap(); let sales = l .add_account("4000", "Sales", AccountType::Revenue, None, false) .unwrap(); post(&mut l, d(1, 2), "Alice invests", cash, cap_a, 100); post(&mut l, d(1, 2), "Bob invests", cash, cap_b, 100); post(&mut l, d(1, 10), "Sales", cash, sales, 50); post(&mut l, d(1, 20), "Bob's distribution", dist_b, cash, 20); let half = cents("0.5"); let members = vec![ MemberCapital { name: "Alice".into(), capital_account: cap_a, distribution_account: Some(dist_a), share: half, }, MemberCapital { name: "Bob".into(), capital_account: cap_b, distribution_account: Some(dist_b), share: half, }, ]; // January: Bob's distribution comes from his distributions account. let jan_me = members_equity(&l, d(1, 1), d(1, 31), &members).unwrap(); let row = |c: &ledger_core::MemberEquityColumn| { (c.contributions, c.net_income, c.distributions, c.ending) }; assert_eq!( row(&jan_me.members[0]), (dec(100), dec(25), dec(0), dec(125)) ); assert_eq!( row(&jan_me.members[1]), (dec(100), dec(25), dec(20), dec(105)) ); assert_eq!( jan_me.total.ending, balance_sheet_as_of(&l, d(1, 31)).unwrap().total_equity ); // The close sweeps Bob's distributions into his capital. let jan = l.create_period("2026-01", d(1, 1), d(1, 31)).unwrap(); let to = ledger_core::CloseTo::Members( members .iter() .map(|m| ledger_core::MemberClose { capital: m.capital_account, share: m.share, distributions: m.distribution_account, }) .collect(), ); l.close_period_to(jan, &to).unwrap(); let bs = balance_sheet_as_of(&l, d(1, 31)).unwrap(); assert_eq!( bs.equity, vec![ line("3000", "Capital — Alice", 125), line("3001", "Capital — Bob", 105) ] ); // February starts there. let feb_me = members_equity(&l, d(2, 1), d(2, 28), &members).unwrap(); assert_eq!( (feb_me.members[0].beginning, feb_me.members[1].beginning), (dec(125), dec(105)) ); // A sweep account must be equity. let mut l2 = Ledger::new(Currency::USD); let c = l2 .add_account("1000", "Cash", AccountType::Asset, None, false) .unwrap(); let e = l2 .add_account("3000", "Capital", AccountType::Equity, None, false) .unwrap(); let p = l2.create_period("2026-01", d(1, 1), d(1, 31)).unwrap(); let bad = ledger_core::CloseTo::Account { into: e, sweep: vec![c], }; assert!(matches!( l2.close_period_to(p, &bad), Err(LedgerError::InvalidMembers(_)) )); } // --------------------------------------------------------------------------- // Splitting odd cents among members: largest remainder (Stephen, // 2026-10-13). Spec: periods::allocate. // --------------------------------------------------------------------------- fn split(amount: &str, shares: &[&str]) -> Vec { let shares: Vec = shares.iter().map(|s| cents(s)).collect(); ledger_core::periods::allocate(cents(amount), &shares) } #[test] fn an_odd_cent_goes_to_the_largest_remainder_not_the_last_member() { // Three equal partners: the cent goes to the first (ties to the // earlier), where the old rule always handed it to the last. assert_eq!( split("100.00", &["0.3333333333333333333333333333"; 3]), [cents("33.34"), cents("33.33"), cents("33.33")] ); // Unequal shares: 6.666... has the larger remainder. assert_eq!( split( "10.00", &[ "0.6666666666666666666666666667", "0.3333333333333333333333333333" ] ), [cents("6.67"), cents("3.33")] ); // Equal remainders (0.035 each): ties go to the earlier member. assert_eq!( split("0.07", &["0.5", "0.5"]), [cents("0.04"), cents("0.03")] ); // 0.015 and 0.035: half a cent each, a tie again. assert_eq!( split("0.05", &["0.3", "0.7"]), [cents("0.02"), cents("0.03")] ); // 0.003 and 0.007: the later member's remainder is larger, so the // cent is theirs. assert_eq!( split("0.01", &["0.3", "0.7"]), [cents("0.00"), cents("0.01")] ); // One cent, two ways: someone gets it, nobody gets a fraction. assert_eq!( split("0.01", &["0.5", "0.5"]), [cents("0.01"), cents("0.00")] ); } #[test] fn a_loss_splits_as_the_negation_of_the_same_gain() { assert_eq!( split("-100.00", &["0.3333333333333333333333333333"; 3]), [cents("-33.34"), cents("-33.33"), cents("-33.33")] ); } #[test] fn closes_journaled_under_the_old_rule_replay_with_it() { // Contract: a close made before 2026-10-13 (last member absorbs) must // replay to the same lines forever. let shares = [cents("0.3333333333333333333333333333"); 3]; assert_eq!( ledger_core::periods::allocate_last_absorbs(cents("100.00"), &shares), [cents("33.33"), cents("33.33"), cents("33.34")] ); } // --------------------------------------------------------------------------- // Boundaries, signs, and labels found untested by mutation testing // (cargo-mutants on src/reports.rs). Expected values are hand-computed. // --------------------------------------------------------------------------- #[test] fn the_balance_checks_catch_an_imbalance() { let b = books(false); let mut tb = trial_balance_as_of(&b.ledger, d(2, 28)); assert!(tb.balanced()); tb.total_credits += cents("0.01"); assert!(!tb.balanced(), "a one-cent difference is out of balance"); let mut bs = balance_sheet(&b.ledger).unwrap(); assert!(bs.balanced()); bs.liabilities += cents("0.01"); assert!(!bs.balanced()); let mut bss = balance_sheet_as_of(&b.ledger, d(2, 28)).unwrap(); assert!(bss.balanced()); bss.total_assets -= cents("0.01"); assert!(!bss.balanced()); } #[test] fn a_one_day_general_ledger_is_valid_and_a_backwards_one_is_not() { let b = books(false); // Jan 15 only: cash begins at the Jan 2 contribution, then the 2,500 // client payment lands. let gl = general_ledger(&b.ledger, d(1, 15), d(1, 15)).unwrap(); let cash = gl.accounts.iter().find(|a| a.code == "1000").unwrap(); assert_eq!(cash.beginning_balance, dec(10_000)); assert_eq!(cash.postings.len(), 1); assert_eq!(cash.ending_balance, dec(12_500)); assert!(matches!( general_ledger(&b.ledger, d(1, 16), d(1, 15)), Err(LedgerError::InvalidDateRange { .. }) )); } fn cf_params(start: NaiveDate, end: NaiveDate, cash: AccountId) -> CashFlowParams { CashFlowParams { start, end, cash_accounts: vec![cash], financing_accounts: vec![], investing_accounts: vec![], } } fn reconciles(cf: &ledger_core::CashFlowStatement) { // B-14 / cash_flow INVARIANT 1: the three sections sum to the change in // cash, exactly. assert_eq!( cf.cash_from_operations + cf.cash_from_investing + cf.cash_from_financing, cf.net_change_in_cash ); assert_eq!(cf.cash_ending - cf.cash_beginning, cf.net_change_in_cash); } #[test] fn cash_flow_starts_counting_on_the_start_date() { let b = books(false); // A one-day period is a valid range. The Jan 15 payment is in it, not // in the beginning balance. let cf = cash_flow(&b.ledger, &cf_params(d(1, 15), d(1, 15), b.cash)).unwrap(); assert_eq!(cf.cash_beginning, dec(10_000)); assert_eq!(cf.cash_ending, dec(12_500)); assert_eq!(cf.net_income, dec(2_500)); reconciles(&cf); // Backwards is refused. assert!(matches!( cash_flow(&b.ledger, &cf_params(d(1, 16), d(1, 15), b.cash)), Err(LedgerError::InvalidDateRange { .. }) )); } #[test] fn cash_flow_labels_say_which_way_each_balance_moved() { let mut l = Ledger::new(Currency::USD); let cash = l .add_account("1000", "Cash", AccountType::Asset, None, false) .unwrap(); let ar = l .add_account("1100", "Receivable", AccountType::Asset, None, false) .unwrap(); let ap = l .add_account("2000", "Payable", AccountType::Liability, None, false) .unwrap(); let loan = l .add_account("2500", "Loan", AccountType::Liability, None, false) .unwrap(); let capital = l .add_account("3000", "Capital", 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(); // January: every account starts February with a balance. post(&mut l, d(1, 2), "Contribution", cash, capital, 10_000); post(&mut l, d(1, 3), "Invoice", ar, rev, 1_000); post(&mut l, d(1, 4), "Bill", exp, ap, 500); post(&mut l, d(1, 5), "Borrow", cash, loan, 2_000); // February: every balance goes up. post(&mut l, d(2, 3), "Invoice", ar, rev, 100); post(&mut l, d(2, 4), "Bill", exp, ap, 50); post(&mut l, d(2, 5), "Borrow", cash, loan, 300); post(&mut l, d(2, 6), "Contribution", cash, capital, 400); // March: every balance comes back down. post(&mut l, d(3, 3), "Collect", cash, ar, 100); post(&mut l, d(3, 4), "Pay bill", ap, cash, 50); post(&mut l, d(3, 5), "Repay", loan, cash, 300); post(&mut l, d(3, 6), "Distribution", capital, cash, 400); let lines = |v: &[ledger_core::CashFlowLine]| -> Vec<(String, Decimal)> { v.iter().map(|l| (l.label.clone(), l.amount)).collect() }; let s = |x: &str| x.to_string(); let params = |start, end| CashFlowParams { start, end, cash_accounts: vec![cash], financing_accounts: vec![loan], investing_accounts: vec![], }; // February: net income 100 - 50 = 50; receivable up 100 (cash out), // payable up 50 (cash in): operations 0. Borrowed 300, contributed 400. let feb = cash_flow(&l, ¶ms(d(2, 1), d(2, 28))).unwrap(); assert_eq!(feb.net_income, dec(50)); assert_eq!( lines(&feb.working_capital), [ (s("Increase in 1100 Receivable"), dec(-100)), (s("Increase in 2000 Payable"), dec(50)), ] ); assert_eq!( lines(&feb.financing), [ (s("Proceeds from 2500 Loan"), dec(300)), (s("Owner contributions — 3000 Capital"), dec(400)), ] ); assert_eq!(feb.cash_from_operations, dec(0)); assert_eq!(feb.net_change_in_cash, dec(700)); reconciles(&feb); // March: the mirror image. Collected 100, paid 50, repaid 300, // distributed 400. let mar = cash_flow(&l, ¶ms(d(3, 1), d(3, 31))).unwrap(); assert_eq!(mar.net_income, dec(0)); assert_eq!( lines(&mar.working_capital), [ (s("Decrease in 1100 Receivable"), dec(100)), (s("Decrease in 2000 Payable"), dec(-50)), ] ); assert_eq!( lines(&mar.financing), [ (s("Repayment of 2500 Loan"), dec(-300)), (s("Owner distributions — 3000 Capital"), dec(-400)), ] ); assert_eq!(mar.net_change_in_cash, dec(-650)); reconciles(&mar); } #[test] fn a_loss_on_disposal_moves_out_of_operating_into_investing() { let mut l = Ledger::new(Currency::USD); let cash = l .add_account("1000", "Cash", AccountType::Asset, None, false) .unwrap(); let equipment = l .add_account("1500", "Equipment", AccountType::Asset, None, false) .unwrap(); let accum = l .add_account("1501", "Accum Depr", AccountType::Asset, None, false) .unwrap(); let capital = l .add_account("3000", "Capital", AccountType::Equity, None, false) .unwrap(); let depr = l .add_account("6500", "Depreciation", AccountType::Expense, None, false) .unwrap(); let loss = l .add_account( "6900", "Loss on Disposal", AccountType::Expense, None, false, ) .unwrap(); l.register_asset(ledger_core::NewAsset { description: "Laptop".to_string(), purchase_date: d(1, 10), cost: usd(1_000), salvage_value: usd(0), useful_life_months: 10, cost_account: equipment, accum_depr_account: accum, expense_account: depr, }) .unwrap(); post(&mut l, d(1, 5), "Contribution", cash, capital, 5_000); post(&mut l, d(1, 10), "Buy laptop", equipment, cash, 1_000); post(&mut l, d(1, 31), "Depreciation", depr, accum, 100); // Sold for 600 at a book value of 900: a 300 loss. let e = l.create_entry(d(2, 15), "Sell laptop"); l.add_line(e, cash, Side::Debit, usd(600)).unwrap(); l.add_line(e, accum, Side::Debit, usd(100)).unwrap(); l.add_line(e, loss, Side::Debit, usd(300)).unwrap(); l.add_line(e, equipment, Side::Credit, usd(1_000)).unwrap(); l.post_entry(e).unwrap(); let lines = |v: &[ledger_core::CashFlowLine]| -> Vec<(String, Decimal)> { v.iter().map(|l| (l.label.clone(), l.amount)).collect() }; // February: net income -300; the loss is added back (positive, it used // no cash), so operations is 0; the 600 proceeds are investing. let feb = cash_flow(&l, &cf_params(d(2, 1), d(2, 28), cash)).unwrap(); assert_eq!(feb.net_income, dec(-300)); assert_eq!( lines(&feb.working_capital), [( "Less: gain on sale of fixed assets (in investing)".to_string(), dec(300) )] ); assert_eq!(feb.cash_from_operations, dec(0)); assert_eq!( lines(&feb.investing), [("Sale of fixed assets — 1000 Cash".to_string(), dec(600))] ); assert_eq!(feb.net_change_in_cash, dec(600)); reconciles(&feb); } #[test] fn a_contribution_on_the_start_date_is_a_contribution_not_beginning_capital() { let (l, members) = partnership(false); // The partnership fixture's contributions are dated Jan 2. Starting the // statement that day must give the same rows as all of January. let me = members_equity(&l, d(1, 2), d(1, 31), &members).unwrap(); let row = |c: &ledger_core::MemberEquityColumn| (c.beginning, c.contributions, c.ending); assert_eq!( row(&me.members[0]), (cents("0"), cents("600"), cents("933.34")) ); assert_eq!( row(&me.members[1]), (cents("0"), cents("400"), cents("733.33")) ); } #[test] fn distribution_accounts_reduce_capital_and_returns_reduce_distributions() { let mut l = Ledger::new(Currency::USD); let cash = l .add_account("1000", "Cash", AccountType::Asset, None, false) .unwrap(); let cap_a = l .add_account("3000", "Capital — Alice", AccountType::Equity, None, false) .unwrap(); let cap_b = l .add_account("3001", "Capital — Bob", AccountType::Equity, None, false) .unwrap(); let dist_a = l .add_account("3100", "Dist — Alice", AccountType::Equity, None, false) .unwrap(); let dist_b = l .add_account("3101", "Dist — Bob", AccountType::Equity, None, false) .unwrap(); post(&mut l, d(1, 2), "Alice invests", cash, cap_a, 100); post(&mut l, d(1, 2), "Bob invests", cash, cap_b, 100); post(&mut l, d(1, 20), "Bob's distribution", dist_b, cash, 20); // February: Bob takes 30 and hands 10 back: 20 distributed, net. post(&mut l, d(2, 5), "Bob's distribution", dist_b, cash, 30); post(&mut l, d(2, 6), "Bob returns some", cash, dist_b, 10); let half = cents("0.5"); let members = vec![ MemberCapital { name: "Alice".into(), capital_account: cap_a, distribution_account: Some(dist_a), share: half, }, MemberCapital { name: "Bob".into(), capital_account: cap_b, distribution_account: Some(dist_b), share: half, }, ]; // No close: Bob's January distribution is still in his distributions // account, and it comes off his capital, not the partnership's. let feb = members_equity(&l, d(2, 1), d(2, 28), &members).unwrap(); let row = |c: &ledger_core::MemberEquityColumn| { (c.beginning, c.contributions, c.distributions, c.ending) }; assert_eq!(row(&feb.members[0]), (dec(100), dec(0), dec(0), dec(100))); assert_eq!(row(&feb.members[1]), (dec(80), dec(0), dec(20), dec(60))); 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())); } /// The cash flow fixture for financing and investing roles. Hand-computed: /// /// | date | entry | Dr | Cr | /// |--------|----------------------------------------|----------------|----------------| /// | Mar 1 | bank loan 1,000 | 1000 Cash | 2500 Loan | /// | Mar 5 | owner pays software 100 personally | 6000 Software | 2100 Due to owners | /// | Mar 10 | lend a member 500 | 1400 Member loan | 1000 Cash | /// | Mar 20 | repay 400 of the bank loan | 2500 Loan | 1000 Cash | /// | Mar 25 | the member repays 200 | 1000 Cash | 1400 Member loan | /// | Mar 28 | reimburse the owner 100 | 2100 Due to owners | 1000 Cash | /// /// Cash: +1,000 - 500 - 400 + 200 - 100 = +200. fn roles() -> (Ledger, CashFlowParams) { let mut l = Ledger::new(Currency::USD); let mut add = |code: &str, t| l.add_account(code, code, t, None, false).unwrap(); let cash = add("1000", AccountType::Asset); let member_loan = add("1400", AccountType::Asset); let due_owners = add("2100", AccountType::Liability); let loan = add("2500", AccountType::Liability); let software = add("6000", AccountType::Expense); post(&mut l, d(3, 1), "loan", cash, loan, 1000); post(&mut l, d(3, 5), "software", software, due_owners, 100); post(&mut l, d(3, 10), "lend", member_loan, cash, 500); post(&mut l, d(3, 20), "repay", loan, cash, 400); post(&mut l, d(3, 25), "collect", cash, member_loan, 200); post(&mut l, d(3, 28), "reimburse", due_owners, cash, 100); let params = CashFlowParams { start: d(3, 1), end: d(3, 31), cash_accounts: vec![cash], financing_accounts: vec![loan, due_owners], investing_accounts: vec![member_loan], }; (l, params) } fn lines(ls: &[ledger_core::reports::CashFlowLine]) -> Vec<(String, Decimal)> { ls.iter() .map(|l| (l.account.clone().unwrap_or_default(), l.amount)) .collect() } /// Oracle: ASC 230-10-45-7 ("information about the gross amounts of cash /// receipts and cash payments during a period is more relevant than /// information about the net amounts"), 230-10-45-14(b) (proceeds from /// "other short- or long-term borrowing") and 45-15(2) ("Repayments of /// amounts borrowed"): borrowing and repayment are separate lines. #[test] fn financing_is_gross_borrowing_apart_from_repayment() { let (l, params) = roles(); let cf = cash_flow(&l, ¶ms).unwrap(); assert_eq!( lines(&cf.financing), [ ("2100".into(), dec(100)), ("2100".into(), dec(-100)), ("2500".into(), dec(1000)), ("2500".into(), dec(-400)), ] ); assert_eq!(cf.cash_from_financing, dec(600)); } /// Oracle: ASC 230-10-20 ("Investing activities include making and /// collecting loans") and 230-10-45-13 ("Disbursements for loans made by /// the entity"): a loan to a member is investing, gross, not working /// capital. #[test] fn a_loan_the_entity_makes_is_investing_gross() { let (l, params) = roles(); let cf = cash_flow(&l, ¶ms).unwrap(); assert_eq!( lines(&cf.investing), [("1400".into(), dec(200)), ("1400".into(), dec(-500))] ); assert!(cf.investing[0].label.starts_with("Collections on")); assert!(cf.investing[1].label.starts_with("Payments for")); assert!( cf.working_capital .iter() .all(|w| w.account.as_deref() != Some("1400")) ); assert_eq!(cf.cash_from_investing, dec(-300)); } /// Oracle: PwC Viewpoint, Financial statement presentation 6.9.3.1 /// (constructive receipts and disbursements: cash a third party pays on /// the entity's behalf is presented "as though the transaction took place /// through the bank accounts of the reporting entity"). The owner paying /// software personally is a borrowing (financing in) and an operating /// payment (in net income); reimbursing the owner is a repayment. /// Hand-computed: operating -100, investing -300, financing +600 = +200, /// the change in cash. #[test] fn an_expense_the_owner_paid_is_borrowing_then_operating() { let (l, params) = roles(); let cf = cash_flow(&l, ¶ms).unwrap(); assert_eq!(cf.net_income, dec(-100)); assert_eq!(cf.cash_from_operations, dec(-100)); assert_eq!(cf.net_change_in_cash, dec(200)); assert_eq!( cf.cash_from_operations + cf.cash_from_investing + cf.cash_from_financing, cf.net_change_in_cash ); } /// Gross lines net only within one entry: an entry that both debits and /// credits a financing account by the same amount moves no cash and adds /// no line. #[test] fn an_entry_that_nets_to_nothing_on_an_account_adds_no_line() { let (mut l, params) = roles(); let loan = l.chart().find_by_code("2500").unwrap().id; let e = l.create_entry(d(3, 30), "reclass within the loan"); l.add_line(e, loan, Side::Debit, usd(50)).unwrap(); l.add_line(e, loan, Side::Credit, usd(50)).unwrap(); l.post_entry(e).unwrap(); let cf = cash_flow(&l, ¶ms).unwrap(); assert_eq!(cf.cash_from_financing, dec(600)); assert_eq!(cf.financing.len(), 4); } mod chaining { use super::*; use ledger_core::{CloseTo, MemberClose}; use proptest::prelude::*; /// One month of activity: (member, contribution cents, distribution /// cents, revenue cents, expense cents). type Month = (usize, i64, i64, i64, i64); fn month() -> impl Strategy { ( 0usize..3, 0i64..50_000, 0i64..20_000, 0i64..100_000, 0i64..80_000, ) } proptest! { #![proptest_config(ProptestConfig { cases: 256, ..ProptestConfig::default() })] /// Oracle: members' equity INVARIANT 3a (docs/testing.md #7; Form /// 1065 Schedule K-1 instructions, Item L: one running capital /// balance per member). For random books, shares with odd cents, /// months closed to the members or not, and any split date: each /// member's ending on the first statement is their beginning on /// the second, and the two statements together roll to the /// one-statement figures; every member's incomes sum to net income. #[test] fn each_members_capital_chains_across_statements( months in prop::collection::vec(month(), 1..12), closes in prop::collection::vec(any::(), 12), split in 1u32..330, ) { let mut l = Ledger::new(Currency::USD); let mut add = |code: &str, t| l.add_account(code, code, t, None, false).unwrap(); let cash = add("1000", AccountType::Asset); let caps = [ add("3000", AccountType::Equity), add("3001", AccountType::Equity), add("3002", AccountType::Equity), ]; let rev = add("4000", AccountType::Revenue); let exp = add("6000", AccountType::Expense); let shares = [ Decimal::from_str("0.3333").unwrap(), Decimal::from_str("0.3333").unwrap(), Decimal::from_str("0.3334").unwrap(), ]; let cents = |n: i64| Money::new(Decimal::new(n, 2), Currency::USD); let post2 = |l: &mut Ledger, date, dr, cr, n: i64| { if n > 0 { let e = l.create_entry(date, "x"); l.add_line(e, dr, Side::Debit, cents(n)).unwrap(); l.add_line(e, cr, Side::Credit, cents(n)).unwrap(); l.post_entry(e).unwrap(); } }; for (i, (who, contrib, dist, sales, costs)) in months.iter().enumerate() { let m = i as u32 + 1; post2(&mut l, d(m, 3), cash, caps[*who], *contrib); post2(&mut l, d(m, 10), cash, rev, *sales); post2(&mut l, d(m, 15), exp, cash, *costs); post2(&mut l, d(m, 20), caps[*who], cash, *dist); if closes[i] { let end = if m == 12 { d(12, 31) } else { d(m + 1, 1).pred_opt().unwrap() }; let p = l.create_period(&format!("2026-{m:02}"), d(m, 1), end).unwrap(); let to = CloseTo::Members( caps.iter() .zip(shares) .map(|(&capital, share)| MemberClose { capital, share, distributions: None }) .collect(), ); // Closes must go in order; a refused close just leaves // the month open. let _ = l.close_period_to(p, &to); } } let members: Vec = caps .iter() .zip(shares) .enumerate() .map(|(i, (&capital_account, share))| MemberCapital { name: format!("M{i}"), capital_account, distribution_account: None, share, }) .collect(); let mid = d(1, 1) + chrono::Days::new(u64::from(split)); let first = members_equity(&l, d(1, 1), mid, &members).unwrap(); let second = members_equity(&l, mid.succ_opt().unwrap(), d(12, 31), &members).unwrap(); let whole = members_equity(&l, d(1, 1), d(12, 31), &members).unwrap(); for i in 0..3 { prop_assert_eq!(first.members[i].ending, second.members[i].beginning); prop_assert_eq!(second.members[i].ending, whole.members[i].ending); prop_assert_eq!(first.members[i].beginning, whole.members[i].beginning); } for st in [&first, &second, &whole] { let incomes: Decimal = st.members.iter().map(|m| m.net_income).sum(); prop_assert_eq!(incomes, st.total.net_income); for m in &st.members { prop_assert_eq!( m.beginning + m.contributions + m.net_income - m.distributions + m.other, m.ending ); } } } } } mod cumulative_closes { use super::*; use ledger_core::{CloseTo, MemberClose}; /// Three months of one-cent income, 50/50, each month closed into the /// members' capital. fn three_cent_months(to: impl Fn(&[AccountId; 2]) -> CloseTo) -> (Ledger, [AccountId; 2]) { let mut l = Ledger::new(Currency::USD); let mut add = |code: &str, t| l.add_account(code, code, t, None, false).unwrap(); let cash = add("1000", AccountType::Asset); let caps = [ add("3000", AccountType::Equity), add("3001", AccountType::Equity), ]; let rev = add("4000", AccountType::Revenue); for m in 1..=3 { let e = l.create_entry(d(m, 10), "a cent"); l.add_line( e, cash, Side::Debit, Money::new(cents("0.01"), Currency::USD), ) .unwrap(); l.add_line( e, rev, Side::Credit, Money::new(cents("0.01"), Currency::USD), ) .unwrap(); l.post_entry(e).unwrap(); let end = d(m + 1, 1).pred_opt().unwrap(); let p = l .create_period(&format!("2026-{m:02}"), d(m, 1), end) .unwrap(); l.close_period_to(p, &to(&caps)).unwrap(); } (l, caps) } fn half(caps: &[AccountId; 2]) -> Vec { caps.iter() .map(|&capital| MemberClose { capital, share: cents("0.5"), distributions: None, }) .collect() } /// Oracle: rules.md B-13 (cumulative closes; docs/testing.md #12), /// hand-computed. Closing each cent on its own gives the earlier member /// every tie: A 0.03, B 0.00 after three months. Cumulatively: after /// month 1 the run's 0.01 splits A 0.01 / B 0; after month 2, 0.02 /// splits 0.01 / 0.01, so month 2 gives B the cent; after month 3, 0.03 /// splits 0.02 / 0.01, so month 3 gives A the cent. A 0.02, B 0.01. #[test] fn a_run_of_closes_never_piles_up_rounding() { let (l, caps) = three_cent_months(|c| CloseTo::MembersPerClose(half(c))); let bal = |l: &Ledger, a| l.account_balance(a).unwrap(); assert_eq!( (bal(&l, caps[0]), bal(&l, caps[1])), (cents("0.03"), cents("0.00")), "per close: the earlier member takes every tie" ); let (l, caps) = three_cent_months(|c| CloseTo::Members(half(c))); assert_eq!( (bal(&l, caps[0]), bal(&l, caps[1])), (cents("0.02"), cents("0.01")), "cumulative: within a cent of half of 0.03 each" ); } proptest::proptest! { /// Oracle: rules.md B-13, cumulative closes. For any run of monthly /// profits and losses closed into three members at 33.33% / /// 33.33% / 33.34%, each member's total from the run is within one /// cent of their exact share of everything closed. #[test] fn each_member_stays_within_a_cent_of_their_share( nets in proptest::collection::vec(-500_000i64..500_000, 1..12), ) { let mut l = Ledger::new(Currency::USD); let mut add = |code: &str, t| l.add_account(code, code, t, None, false).unwrap(); let cash = add("1000", AccountType::Asset); let caps = [ add("3000", AccountType::Equity), add("3001", AccountType::Equity), add("3002", AccountType::Equity), ]; let rev = add("4000", AccountType::Revenue); let exp = add("6000", AccountType::Expense); let shares = [cents("0.3333"), cents("0.3333"), cents("0.3334")]; let to = CloseTo::Members( caps.iter() .zip(shares) .map(|(&capital, share)| MemberClose { capital, share, distributions: None }) .collect(), ); for (i, n) in nets.iter().enumerate() { let m = i as u32 + 1; if *n != 0 { let amount = Money::new(Decimal::new(n.abs(), 2), Currency::USD); let (dr, cr) = if *n > 0 { (cash, rev) } else { (exp, cash) }; let e = l.create_entry(d(m, 10), "month"); l.add_line(e, dr, Side::Debit, amount).unwrap(); l.add_line(e, cr, Side::Credit, amount).unwrap(); l.post_entry(e).unwrap(); } let end = if m == 12 { d(12, 31) } else { d(m + 1, 1).pred_opt().unwrap() }; let p = l.create_period(&format!("2026-{m:02}"), d(m, 1), end).unwrap(); l.close_period_to(p, &to).unwrap(); } let total = Decimal::new(nets.iter().sum::(), 2); for (cap, share) in caps.iter().zip(shares) { let got = l.account_balance(*cap).unwrap(); proptest::prop_assert!((got - total * share).abs() < cents("0.01"), "{got} vs {}", total * share); } } } /// A run is closes with the same members and shares, back to back: a /// close to anything else (or with other shares) ends it, and the next /// members close starts afresh. #[test] fn a_different_close_ends_the_run() { let mut l = Ledger::new(Currency::USD); let mut add = |code: &str, t| l.add_account(code, code, t, None, false).unwrap(); let cash = add("1000", AccountType::Asset); let caps = [ add("3000", AccountType::Equity), add("3001", AccountType::Equity), ]; let rev = add("4000", AccountType::Revenue); let cent = |l: &mut Ledger, m| { let e = l.create_entry(d(m, 10), "a cent"); l.add_line( e, cash, Side::Debit, Money::new(cents("0.01"), Currency::USD), ) .unwrap(); l.add_line( e, rev, Side::Credit, Money::new(cents("0.01"), Currency::USD), ) .unwrap(); l.post_entry(e).unwrap(); }; let close = |l: &mut Ledger, m: u32, to: &CloseTo| { let end = d(m + 1, 1).pred_opt().unwrap(); let p = l .create_period(&format!("2026-{m:02}"), d(m, 1), end) .unwrap(); l.close_period_to(p, to).unwrap(); }; // Month 1 to members (A gets the cent), month 2 to retained // earnings (ends the run), month 3 to members again: a new run, so // A gets that cent too. cent(&mut l, 1); close(&mut l, 1, &CloseTo::Members(half(&caps))); cent(&mut l, 2); close(&mut l, 2, &CloseTo::RetainedEarnings { sweep: vec![] }); cent(&mut l, 3); close(&mut l, 3, &CloseTo::Members(half(&caps))); assert_eq!(l.account_balance(caps[0]).unwrap(), cents("0.02")); assert_eq!(l.account_balance(caps[1]).unwrap(), cents("0")); } }