Files
wanderbooks/crates/ledger-core/tests/statements.rs
T
swaitsandClaude Opus 5.5 d7fa16e974
check / check (push) Canceled after 0s
feat(arap)!: documents read their figures from standing lines; control accounts
refuse hand entries (P4b S1, part A)

Plan P4b (D-45), slice S1, the engine half: rules.md B-38.

- ledger-core: every receivables or payables line carries its document
  (Ledger.line_docs: DocRef, Movement, DocTag), written by the document
  commands in the step that posts it, derived on replay from the existing
  document events (the role's account as it stood then; payments by the
  ordinal rule), copied onto a reversal's mirror. Ledger::stands_on;
  doc_state / invoice_state / bill_state (total, paid, outstanding,
  status Open/Partial/Paid/WrittenOff/Void, overdue days) as of any day;
  ar_aging / ap_aging read them, so reversed payments and voids are right.
  The control guard (add_line, add_line_fx, post_entry,
  register_recurring) refuses untagged lines on receivables/payables,
  exempting a reversal's mirror of a legacy line; replay never refuses.
  Bounds (kernel::running_bounds, bounds_kept) on payments and reversals;
  a document reversal never dated before its entry; deposit/payer accounts
  must be cash-like and hold no role. New API: receive_payment_with /
  pay_bill_with (PaymentDetails: instrument, reference), void_document /
  check_void_document, control_check, payments_recorded,
  standing_payments, replay_reverse_entry. Invoice.amount_paid/status
  remain only as derived caches for the MCP layer until part B.
  cashflow bill_splits reads payments from the tags.
- ledger-store: journal feature document_standing (D-39 gated) for a
  payment accepted only because an earlier one was reversed;
  receive_payment_with / pay_bill_with (instrument journaled as
  entry.instrument_set, reference in the description), void_document;
  replay uses replay_reverse_entry; schema 15 drops the projection's
  amount_paid/status. Fixture p4-arap-before + golden figures written by
  the version before S1, adjudicated in arap_standing.rs.
- ledger-gui: party card, cash "coming up" and company owed totals read
  doc_state as of today (late from the derived state).
- Lean Documents.lean (control_agrees and friends); Kani
  bounds_check_matches_definition,
bounds_kept_is_the_definition_on_books_in_bounds;
  rules B-38, B-18/B-23 amended; bookspec `older = true` for legacy hand
  lines; tests that hand-post to 1200/2000 post as replay or renumbered.

Review fixes (both reviews of part A):
- control_role_of guards only an account the role is assigned to or one
  holding its documents' lines (a chart's own 1200 Equipment or card at
  2000 is free until a document posts there); payment accounts check
  other roles by holds_role (type-checked). The MCP tests that failed on
  the guard (oracle_mcp, spec_projects) pass unchanged.
- standing_payments leaves out reversals (sorted, deduped); replayed
  tags match the movement's side; payments_recorded counts every payment
  event (tagged or not, Ledger.payment_events) and refuses overflow.
- The record caches settled (paid + discounted + written off) and owed
  (Invoice/Bill.owed, never serialized): a void never reads as paid.
- assign_role: receivables/payables refuse moving onto an account with
  untagged lines (RoleAccountHasHandLines) and, on their control account
  holding a balance, say DocumentRoleHoldsBalance (no hand entry named);
  B-23 reworded. The reversal-mirror exemption only takes a hand line
  off (even chain depth); putting it back is refused.
- DocStatus Overpaid and Unattached; aging leaves out credit balances;
  ControlCheck.attention lists out-of-bounds and unattached documents.
- stands_on counts a chain in order; indexes (doc_index, doc_accounts,
  order_pos, reversed_by) keep the derived figures linear.
- Viewer reads a post-dated document as of its own date; one state per
  row. bounds_kept's side-crossing documented as intended.
- Tests: ten new spec_documents scenarios, stronger proptests, store
  proptest arap_props (live = replay = snapshot at every date, closed
  months), fuzz ops void/payments with details, a GUI post-dated test;
  Lean Step.assign / Step.release with unattached_zero kept; model
  limits stated in docs/testing.md and B-38.
Review fixes (CPA and engineering lenses): only an assigned role's
account or one holding document lines is a control account (a chart's
own 1200 asset stays free); a role can't move onto untagged lines or off
document lines (Lean Step.assign); a void's cache never reads as paid;
no Decimal sum panics; per-document indexes keep figures linear; a
replayed tag matches its side; the feature gate counts payment events;
a payment's reversal is never listed as standing; an overpaid document
has its own status; the viewer keeps post-dated documents.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01T3gXMVt7XJPi6ens1or9Dj
2026-10-24 09:17:42 -06:00

1787 lines
66 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 | 2050 Card |
//! | Feb 10 | client pays 1,000 | 1000 Cash | 4000 Revenue |
//! | Feb 25 | pay the card 120 | 2050 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("2050", "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("2050", "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![],
members: vec![],
},
)
.unwrap();
assert_eq!(cf.operating_reconciliation.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 kinds 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![],
members: vec![],
},
)
.unwrap();
// Kinds read "Contributions from owners — 3000 3000" (code, then
// name): one per account, in code order.
let codes: Vec<&str> = cf
.financing
.iter()
.map(|f| f.kind.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", "2050", "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), "2050 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("2050", "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("2050"), ("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(_))
));
}
/// Each line names the accounts it moved, and is listed under its
/// member's kind (D-35: "Contributions from members — <member>").
#[test]
fn cash_flow_lines_name_their_account() {
let (l, members) = 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![],
members,
},
)
.unwrap();
let accounts: Vec<&[String]> = cf.financing.iter().map(|f| &f.accounts[..]).collect();
// Gross (ASC 230-10-45-7): 3001 contributed and took a distribution,
// so it has a line for each.
assert_eq!(accounts, [["3000"], ["3001"], ["3001"]]);
let kinds: Vec<&str> = cf.financing.iter().map(|f| f.kind.as_str()).collect();
assert_eq!(
kinds,
[
"Contributions from members — Alice",
"Contributions from members — Bob",
"Distributions to members — Bob"
]
);
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")
)
);
// February's 100.01 isn't closed yet: each member's share of it is
// what February's close will give them (D-38): the running split of
// 666.67 + 100.01 = 766.68 is 383.34 each, less January's 333.34 /
// 333.33, so Alice 50.00 and Bob 50.01. Bob ends 733.33 − 100 + 50.01.
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.34"));
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 (owner,
// 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![],
members: vec![],
}
}
fn reconciles(cf: &ledger_core::CashFlowStatement) {
// B-14 / cash_flow INVARIANT 4: the three sections sum to the change in
// cash, exactly, and net income and its reconciling items to operating.
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);
assert_eq!(cf.reconciles.difference, Decimal::ZERO);
let r = &cf.operating_reconciliation;
assert_eq!(
r.net_income + r.lines.iter().map(|l| l.amount).sum::<Decimal>(),
cf.cash_from_operations
);
}
#[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.operating_reconciliation.net_income, dec(2_500));
assert_eq!(
cf.operating_by_class(),
[(ledger_core::OperatingClass::FromCustomers, 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("2010", "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 rec = |cf: &ledger_core::CashFlowStatement| -> Vec<(String, Decimal)> {
let r = &cf.operating_reconciliation.lines;
r.iter().map(|l| (l.label.clone(), l.amount)).collect()
};
let kinds = |v: &[ledger_core::CashFlowLine]| -> Vec<(String, Decimal)> {
v.iter().map(|l| (l.kind.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![],
members: vec![],
};
// February: net income 100 - 50 = 50; receivable up 100 (cash out),
// payable up 50 (cash in): operations 0. Borrowed 300, contributed 400
// (the kinds in the statement's order: contributions, then
// borrowing; ASC 230-10-45-14(a), (b)).
let feb = cash_flow(&l, &params(d(2, 1), d(2, 28))).unwrap();
assert_eq!(feb.operating_reconciliation.net_income, dec(50));
assert_eq!(
rec(&feb),
[
(s("Increase in 1100 Receivable"), dec(-100)),
(s("Increase in 2010 Payable"), dec(50)),
]
);
assert_eq!(
kinds(&feb.financing),
[
(s("Contributions from owners — 3000 Capital"), dec(400)),
(s("Proceeds from borrowing — 2500 Loan"), dec(300)),
]
);
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 (45-15(a), (b)): an original's direction is its
// nature; only a reversal never takes a label from its sign.
let mar = cash_flow(&l, &params(d(3, 1), d(3, 31))).unwrap();
assert_eq!(mar.operating_reconciliation.net_income, dec(0));
assert_eq!(
rec(&mar),
[
(s("Decrease in 1100 Receivable"), dec(100)),
(s("Decrease in 2010 Payable"), dec(-50)),
]
);
assert_eq!(
kinds(&mar.financing),
[
(s("Distributions to owners — 3000 Capital"), dec(-400)),
(s("Repayments of borrowing — 2500 Loan"), dec(-300)),
]
);
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(),
in_service_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();
// February: net income -300; the loss is added back (positive, it used
// no cash), so operations is 0; the 600 proceeds are investing (ASC
// 230-10-45-12(c), 45-28(b)).
let feb = cash_flow(&l, &cf_params(d(2, 1), d(2, 28), cash)).unwrap();
assert_eq!(feb.operating_reconciliation.net_income, dec(-300));
let rec: Vec<(&str, Decimal)> = feb
.operating_reconciliation
.lines
.iter()
.map(|l| (l.label.as_str(), l.amount))
.collect();
assert_eq!(
rec,
[(
"(Gain) loss and other noncash changes in investing assets",
dec(300)
)]
);
assert_eq!(feb.cash_from_operations, dec(0));
let inv: Vec<(&str, Decimal)> = feb
.investing
.iter()
.map(|l| (l.kind.as_str(), l.amount))
.collect();
assert_eq!(
inv,
[("Proceeds from sales of property and equipment", 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],
members: vec![],
};
(l, params)
}
fn lines(ls: &[ledger_core::reports::CashFlowLine]) -> Vec<(String, Decimal)> {
ls.iter()
.map(|l| (l.accounts.join(","), 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(b) ("Repayments of
/// amounts borrowed"): borrowing and repayment are separate lines, one
/// per entry, borrowing first (each lender's, by code), then repayments.
/// The software the owner paid personally is borrowed from the owner and
/// spent on its date (rules.md B-14 rule 11, D-42: constructive receipt
/// and disbursement). Financing: +100 + 1,000 − 100 − 400 = 600; nothing
/// is disclosed.
#[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)),
("2500".into(), dec(1000)),
("2100".into(), dec(-100)),
("2500".into(), dec(-400)),
]
);
assert_eq!(cf.cash_from_financing, dec(600));
assert!(cf.noncash_disclosures.is_empty());
}
/// 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(-500)), ("1400".into(), dec(200))]
);
assert!(cf.investing[0].kind.starts_with("Payments for"));
assert!(cf.investing[1].kind.starts_with("Collections on"));
assert!(
cf.operating_reconciliation
.lines
.iter()
.all(|w| w.account.as_deref() != Some("1400"))
);
assert_eq!(cf.cash_from_investing, dec(-300));
}
/// Oracle: D-42 (rules.md B-14 rule 11; ASC 230 is silent on a payment
/// made on the entity's behalf, PwC FSP 6.9.3.1 and DART Roadmap 7.2
/// present it as constructive receipt and disbursement). The owner paying
/// software personally is cash the company borrowed (financing +100) and
/// spent (operating −100, 230-10-45-17(b)) on Mar 5; reimbursing the owner
/// is a repayment (−100, 45-15(b)). Hand-computed: operating −100 (net
/// income −100, no reconciling item), investing −300, financing +600 =
/// +200, the change in cash. Replaces
/// `an_expense_the_owner_paid_is_disclosed_then_repaid` (D-35).
#[test]
fn an_expense_the_owner_paid_is_borrowed_spent_then_repaid() {
let (l, params) = roles();
let cf = cash_flow(&l, &params).unwrap();
assert_eq!(cf.operating_reconciliation.net_income, dec(-100));
assert_eq!(cf.cash_from_operations, dec(-100));
assert_eq!(lines(&cf.operating), [("6000".into(), dec(-100))]);
assert_eq!(cf.operating[0].on_behalf, dec(-100));
assert!(cf.operating_reconciliation.lines.is_empty());
assert_eq!(cf.net_change_in_cash, dec(200));
reconciles(&cf);
}
/// 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);
// Nothing exchanged either: the account nets to zero first.
assert!(cf.noncash_disclosures.iter().all(|n| n.entry != 7));
}
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
);
}
}
}
}
}
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<MemberClose> {
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"
);
// An unbroken run splits the same under the run rule of
// 2026-10-13 (journaled "cumulative"), which still replays.
let (l, caps) = three_cent_months(|c| CloseTo::MembersRun(half(c)));
assert_eq!(
(bal(&l, caps[0]), bal(&l, caps[1])),
(cents("0.02"), cents("0.01")),
"the run rule"
);
}
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::<i64>(), 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);
}
}
}
/// Under the run rule of 2026-10-13 (CloseTo::MembersRun, journaled
/// "cumulative"; only replay makes it now), a run is closes with the
/// same members and shares, back to back: a close to anything else ends
/// it, and the next members close starts afresh, so a tied cent can go
/// to the same member again (P3 defect 17). Closes journaled under it
/// still replay that way.
#[test]
fn a_different_close_ends_a_legacy_run() {
let (a, b) = cent_months_around_a_retained_earnings_close(CloseTo::MembersRun);
assert_eq!((a, b), (cents("0.02"), cents("0")));
}
/// The rule since P3 (rules.md B-13, D-10): a close to retained
/// earnings allocates nothing to the members and is skipped, so March's
/// members close continues January's count: the running 0.02 splits
/// 0.01 / 0.01, and March gives B the cent.
#[test]
fn a_different_close_no_longer_restarts_the_count() {
let (a, b) = cent_months_around_a_retained_earnings_close(CloseTo::Members);
assert_eq!((a, b), (cents("0.01"), cents("0.01")));
}
/// A cent in each of January, February and March, 50/50; January and
/// March close to the members with `to`, February to retained
/// earnings. The members' capital balances after March.
fn cent_months_around_a_retained_earnings_close(
to: fn(Vec<MemberClose>) -> CloseTo,
) -> (Decimal, Decimal) {
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, month 3 to members again.
cent(&mut l, 1);
close(&mut l, 1, &to(half(&caps)));
cent(&mut l, 2);
close(&mut l, 2, &CloseTo::RetainedEarnings { sweep: vec![] });
cent(&mut l, 3);
close(&mut l, 3, &to(half(&caps)));
(
l.account_balance(caps[0]).unwrap(),
l.account_balance(caps[1]).unwrap(),
)
}
}