Files
wanderbooks/crates/ledger-core/tests/statements.rs
T
swaitsandClaude Opus 5.5 0340f316ee fix(core): due-today is Current; odd cents split by largest remainder
Max-testing pass, step 2: two spec decisions Stephen confirmed
(2026-10-13), each test-first.

Aging (arap::aging_bucket): a document due today was "1-30 days past
due"; it is Current, since past due starts the day after (QuickBooks'
convention). Spec in the doc comment. Boundary examples at every edge
(due today, 1, 30, 31, 60, 61, 90, 91 days) fail on the old code. A
property: the balance lands in exactly one bucket, and time only ages
it.

Member splits (periods::allocate): the last member absorbed every odd
cent, so the same partner always got or lost it. Now largest remainder:
each share rounded toward zero to the cent, leftover cents to the
largest remainders, ties to the earlier member, a loss the negation of
the gain. The core is a new kernel function, kernel::apportion,
allocation-free so Kani proves it (n <= 3): apportion_is_exact (it
succeeds exactly when the shortfall is under a unit per part; then the
parts sum to the whole, each gains 0 or 1 unit, one may take the
sub-unit fraction) and apportion_is_fair (never a unit to a part while
a larger remainder goes without). Kani found an off-by-one in the first
version: a shortfall of n units plus a fraction was accepted. Native
regression apportion_refuses_a_shortfall_of_a_unit_per_part_or_more.
Properties at Decimal (2,000 cases, up to 5 members): conservation,
within a cent of each exact share, fairness, sign symmetry; they fail on
the old rule. Bridge: apportion agrees on i64 and Decimal.

Contract: closes journaled before this change replay exactly. New
member closes journal "rounding": "largest_remainder"; journal lines
without it replay as CloseTo::MembersLastAbsorbs, the old rule
(periods::allocate_last_absorbs, pinned by
closes_journaled_under_the_old_rule_replay_with_it). The members'
equity statement isn't journaled and uses the new rule.

Open gap: Lean proof of apportion's sum law for any n (needs a
permutation lemma on the ranking); Kani covers n <= 3, properties n <= 5.
The Lean closing theorems already hold for any split summing to the
net.

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

962 lines
33 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![],
},
)
.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![],
},
)
.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. 100.01 splits 50.01 / 50.00.
let feb = members_equity(&l, d(2, 1), d(2, 28), &members).unwrap();
assert_eq!(
row(&feb.members[0]),
(
cents("933.34"),
cents("0"),
cents("50.01"),
cents("0"),
cents("983.35")
)
);
assert_eq!(
row(&feb.members[1]),
(
cents("733.33"),
cents("0"),
cents("50.00"),
cents("100"),
cents("683.33")
)
);
assert_eq!(feb.total.ending, cents("1666.68"));
assert_eq!(
feb.total.ending,
balance_sheet_as_of(&l, d(2, 28)).unwrap().total_equity
);
assert!(
feb.members.iter().all(|m| m.other.is_zero()),
"close={close}"
);
}
}
#[test]
fn members_must_be_configured_correctly() {
let (l, mut members) = partnership(false);
members[1].share = cents("0.4");
assert!(matches!(
members_equity(&l, d(1, 1), d(1, 31), &members),
Err(LedgerError::InvalidMembers(_))
));
members[1].share = cents("0.5");
members[1].capital_account = members[0].capital_account;
assert!(matches!(
members_equity(&l, d(1, 1), d(1, 31), &members),
Err(LedgerError::InvalidMembers(_))
));
}
#[test]
fn cash_flow_lines_name_their_account() {
let (l, _) = partnership(false);
let cf = cash_flow(
&l,
&CashFlowParams {
start: d(1, 1),
end: d(12, 31),
cash_accounts: vec![1],
financing_accounts: vec![],
},
)
.unwrap();
let accounts: Vec<Option<&str>> = cf.financing.iter().map(|f| f.account.as_deref()).collect();
assert_eq!(accounts, [Some("3000"), Some("3001")]);
}
#[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")]
);
}