Files
wanderbooks/crates/ledger-core/tests/statements.rs
T
swaitsandClaude Opus 5.5 37824e55d3
check / check (push) Canceled after 0s
members' equity: split undistributed earnings cumulatively
A capital account is one running balance (Form 1065 Schedule K-1
instructions, Item L: beginning and ending capital "based on the
partnership's books and records"), but the statement split prior
earnings and the period's income separately, so a member's ending on one
statement could differ by a cent from their beginning on the next.

Now each member's share of undistributed earnings is allocated as of a
date (largest remainder, as before): beginning as of the day before the
period, ending as of its last day. A member's period income is the
roll-forward's remainder (their share within a rounding cent); incomes
still sum to net income. New property
each_members_capital_chains_across_statements (random books, odd-cent
shares, closes to members or not, any split date) fails on the old code
and passes now. The hand-computed test is recomputed: the old split gave
two cents off an even 50/50 to date.

Also: rules.md B-13 quotes Item L; docs/testing.md #7 and #8 marked
changed; the CPA-question list is replaced by the rule that wanderbooks
settles questions from the sources and makes real choices per-company
settings.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GWVo7x3UHw5kwCUaWxiejX
2026-10-13 18:41:37 -06:00

1525 lines
55 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! 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, profit_and_loss_for_period,
};
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 = profit_and_loss(&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<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,
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<Option<&str>> = 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<Decimal> = 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<Decimal> {
let shares: Vec<Decimal> = 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, &params(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, &params(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(2) (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, &params).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, &params).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, &params).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, &params).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<Value = Month> {
(
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::<bool>(), 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<MemberCapital> = 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
);
}
}
}
}
}