check / check (push) Canceled after 0s
Read in the ASC basic view and vendored as short quotes with paragraph numbers (docs/spec/sources/fasb-asc.md). Depreciation: any systematic and rational allocation of cost less salvage (360-10-35-4); prepaids are current assets with no prescribed expensing method (340-10-05-4, 210-10-45-1/2); cash flows gross and classified three ways, owners' advances financing (230-10-45-7/10/14(b)/15(b)); a principal's revenue is gross (606-10-55-37B). Citation fixed: 230-10-45-14(b), not (2). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01GWVo7x3UHw5kwCUaWxiejX
1699 lines
62 KiB
Rust
1699 lines
62 KiB
Rust
//! Period-scoped financial statements: profit and loss for a date range and
|
||
//! the balance sheet as of a date, line per account.
|
||
//!
|
||
//! Every expected number below is computed by hand from the entries, not
|
||
//! read off the engine. The fixture is two months of a small company:
|
||
//!
|
||
//! | date | entry | Dr | Cr |
|
||
//! |--------|-------------------------------|---------------|---------------|
|
||
//! | Jan 2 | owner contributes 10,000 | 1000 Cash | 3000 Capital |
|
||
//! | Jan 15 | client pays 2,500 | 1000 Cash | 4000 Revenue |
|
||
//! | Jan 20 | rent 800 | 6100 Rent | 1000 Cash |
|
||
//! | Feb 3 | software 120 on the card | 6000 Software | 2000 Card |
|
||
//! | Feb 10 | client pays 1,000 | 1000 Cash | 4000 Revenue |
|
||
//! | Feb 25 | pay the card 120 | 2000 Card | 1000 Cash |
|
||
|
||
use chrono::NaiveDate;
|
||
use ledger_core::reports::{
|
||
balance_sheet, balance_sheet_as_of, profit_and_loss_for_period, unclosed_earnings,
|
||
};
|
||
use ledger_core::{
|
||
AccountId, AccountType, CashFlowParams, ClassId, Currency, Ledger, LedgerError, MemberCapital,
|
||
Money, Side, StatementLine, cash_flow, general_ledger, members_equity, trial_balance_as_of,
|
||
};
|
||
use rust_decimal::Decimal;
|
||
use std::str::FromStr;
|
||
|
||
fn d(m: u32, day: u32) -> NaiveDate {
|
||
NaiveDate::from_ymd_opt(2026, m, day).unwrap()
|
||
}
|
||
|
||
fn usd(n: i64) -> Money {
|
||
Money::new(Decimal::from(n), Currency::USD)
|
||
}
|
||
|
||
fn dec(n: i64) -> Decimal {
|
||
Decimal::from(n)
|
||
}
|
||
|
||
struct Books {
|
||
ledger: Ledger,
|
||
cash: AccountId,
|
||
software_class: ClassId,
|
||
}
|
||
|
||
fn post(ledger: &mut Ledger, date: NaiveDate, desc: &str, dr: AccountId, cr: AccountId, n: i64) {
|
||
let e = ledger.create_entry(date, desc);
|
||
ledger.add_line(e, dr, Side::Debit, usd(n)).unwrap();
|
||
ledger.add_line(e, cr, Side::Credit, usd(n)).unwrap();
|
||
ledger.post_entry(e).unwrap();
|
||
}
|
||
|
||
/// The fixture. With `close_january`, January is closed before any February
|
||
/// entry exists (the close sweeps every revenue and expense balance).
|
||
fn books(close_january: bool) -> Books {
|
||
let mut l = Ledger::new(Currency::USD);
|
||
let cash = l
|
||
.add_account("1000", "Cash", AccountType::Asset, None, false)
|
||
.unwrap();
|
||
let card = l
|
||
.add_account("2000", "Card", AccountType::Liability, None, false)
|
||
.unwrap();
|
||
let capital = l
|
||
.add_account("3000", "Capital", AccountType::Equity, None, false)
|
||
.unwrap();
|
||
let revenue = l
|
||
.add_account("4000", "Revenue", AccountType::Revenue, None, false)
|
||
.unwrap();
|
||
let software = l
|
||
.add_account("6000", "Software", AccountType::Expense, None, false)
|
||
.unwrap();
|
||
let rent = l
|
||
.add_account("6100", "Rent", AccountType::Expense, None, false)
|
||
.unwrap();
|
||
let software_class = l.add_class("Apps").unwrap();
|
||
|
||
post(&mut l, d(1, 2), "Owner contribution", cash, capital, 10_000);
|
||
post(&mut l, d(1, 15), "Client payment", cash, revenue, 2_500);
|
||
post(&mut l, d(1, 20), "Rent", rent, cash, 800);
|
||
if close_january {
|
||
let jan = l.create_period("2026-01", d(1, 1), d(1, 31)).unwrap();
|
||
l.close_period(jan).unwrap();
|
||
}
|
||
// Software on the card, tagged to the Apps class.
|
||
let e = l.create_entry(d(2, 3), "Software");
|
||
l.add_line(e, software, Side::Debit, usd(120)).unwrap();
|
||
l.add_line(e, card, Side::Credit, usd(120)).unwrap();
|
||
l.set_line_class(e, 0, Some(software_class)).unwrap();
|
||
l.post_entry(e).unwrap();
|
||
post(&mut l, d(2, 10), "Client payment", cash, revenue, 1_000);
|
||
post(&mut l, d(2, 25), "Pay card", card, cash, 120);
|
||
Books {
|
||
ledger: l,
|
||
cash,
|
||
software_class,
|
||
}
|
||
}
|
||
|
||
fn line(code: &str, name: &str, n: i64) -> StatementLine {
|
||
StatementLine {
|
||
code: code.to_string(),
|
||
name: name.to_string(),
|
||
amount: dec(n),
|
||
}
|
||
}
|
||
|
||
#[test]
|
||
fn profit_and_loss_is_scoped_to_the_period() {
|
||
let b = books(false);
|
||
let jan = profit_and_loss_for_period(&b.ledger, d(1, 1), d(1, 31), None).unwrap();
|
||
assert_eq!(jan.revenue, vec![line("4000", "Revenue", 2_500)]);
|
||
assert_eq!(jan.expenses, vec![line("6100", "Rent", 800)]);
|
||
assert_eq!(jan.net_income(), dec(1_700));
|
||
|
||
let feb = profit_and_loss_for_period(&b.ledger, d(2, 1), d(2, 28), None).unwrap();
|
||
assert_eq!(feb.revenue, vec![line("4000", "Revenue", 1_000)]);
|
||
assert_eq!(feb.expenses, vec![line("6000", "Software", 120)]);
|
||
assert_eq!(feb.net_income(), dec(880));
|
||
|
||
// Both months: 3,500 revenue, 920 expenses, lines sorted by code.
|
||
let both = profit_and_loss_for_period(&b.ledger, d(1, 1), d(2, 28), None).unwrap();
|
||
assert_eq!(
|
||
both.expenses,
|
||
vec![line("6000", "Software", 120), line("6100", "Rent", 800)]
|
||
);
|
||
assert_eq!(
|
||
(both.total_revenue, both.total_expenses),
|
||
(dec(3_500), dec(920))
|
||
);
|
||
// ... which is the life-of-ledger P&L, because nothing is outside it.
|
||
let all = unclosed_earnings(&b.ledger).unwrap();
|
||
assert_eq!((all.revenue, all.expenses), (dec(3_500), dec(920)));
|
||
|
||
// One class: only the tagged software line.
|
||
let apps =
|
||
profit_and_loss_for_period(&b.ledger, d(1, 1), d(12, 31), Some(b.software_class)).unwrap();
|
||
assert!(apps.revenue.is_empty());
|
||
assert_eq!(apps.expenses, vec![line("6000", "Software", 120)]);
|
||
|
||
// A backwards range is rejected, not silently empty.
|
||
assert!(profit_and_loss_for_period(&b.ledger, d(2, 1), d(1, 1), None).is_err());
|
||
}
|
||
|
||
#[test]
|
||
fn balance_sheet_is_as_of_the_date() {
|
||
let b = books(false);
|
||
// Before anything happened: empty and balanced.
|
||
let start = balance_sheet_as_of(&b.ledger, d(1, 1)).unwrap();
|
||
assert!(start.assets.is_empty() && start.equity.is_empty());
|
||
assert!(start.balanced());
|
||
|
||
// Jan 31: cash 10,000 + 2,500 − 800 = 11,700; equity 10,000 + 1,700.
|
||
let jan = balance_sheet_as_of(&b.ledger, d(1, 31)).unwrap();
|
||
assert_eq!(jan.assets, vec![line("1000", "Cash", 11_700)]);
|
||
assert!(jan.liabilities.is_empty());
|
||
assert_eq!(jan.equity, vec![line("3000", "Capital", 10_000)]);
|
||
assert_eq!(jan.current_earnings, dec(1_700));
|
||
assert_eq!(jan.total_equity, dec(11_700));
|
||
assert!(jan.balanced());
|
||
|
||
// Feb 15: cash 12,700; the card owes 120; earnings 1,700 + 880.
|
||
let mid = balance_sheet_as_of(&b.ledger, d(2, 15)).unwrap();
|
||
assert_eq!(mid.total_assets, dec(12_700));
|
||
assert_eq!(mid.liabilities, vec![line("2000", "Card", 120)]);
|
||
assert_eq!(mid.current_earnings, dec(2_580));
|
||
assert_eq!(mid.total_liabilities + mid.total_equity, dec(12_700));
|
||
|
||
// Feb 28: card paid off, so it drops off the statement.
|
||
let end = balance_sheet_as_of(&b.ledger, d(2, 28)).unwrap();
|
||
assert_eq!(end.total_assets, dec(12_580));
|
||
assert!(end.liabilities.is_empty());
|
||
assert!(end.balanced());
|
||
// ... and agrees with the life-of-ledger balance sheet.
|
||
let all = balance_sheet(&b.ledger).unwrap();
|
||
assert_eq!(
|
||
(all.assets, all.liabilities, all.equity),
|
||
(end.total_assets, end.total_liabilities, end.total_equity)
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn a_closed_period_still_reports_its_earnings() {
|
||
let b = books(true);
|
||
// The close posted Dr 4000 2,500 / Cr 6100 800 / Cr 3999 1,700 on Jan
|
||
// 31. The January P&L must not count it: January earned 1,700.
|
||
let jan = profit_and_loss_for_period(&b.ledger, d(1, 1), d(1, 31), None).unwrap();
|
||
assert_eq!(jan.net_income(), dec(1_700));
|
||
assert_eq!(jan.revenue, vec![line("4000", "Revenue", 2_500)]);
|
||
let feb = profit_and_loss_for_period(&b.ledger, d(2, 1), d(2, 28), None).unwrap();
|
||
assert_eq!(feb.net_income(), dec(880));
|
||
|
||
// After the close, January's earnings sit in retained earnings, not in
|
||
// current earnings; February's are current.
|
||
let jan_bs = balance_sheet_as_of(&b.ledger, d(1, 31)).unwrap();
|
||
assert_eq!(
|
||
jan_bs.equity,
|
||
vec![
|
||
line("3000", "Capital", 10_000),
|
||
line("3999", "Retained Earnings", 1_700)
|
||
]
|
||
);
|
||
assert_eq!(jan_bs.current_earnings, Decimal::ZERO);
|
||
assert!(jan_bs.balanced());
|
||
let feb_bs = balance_sheet_as_of(&b.ledger, d(2, 28)).unwrap();
|
||
assert_eq!(feb_bs.current_earnings, dec(880));
|
||
assert_eq!(feb_bs.total_equity, dec(12_580));
|
||
assert!(feb_bs.balanced());
|
||
|
||
// The cash flow for January: net income 1,700, and the only financing
|
||
// flow is the owner's 10,000. The close is not an owner contribution.
|
||
let cf = cash_flow(
|
||
&b.ledger,
|
||
&CashFlowParams {
|
||
start: d(1, 1),
|
||
end: d(1, 31),
|
||
cash_accounts: vec![b.cash],
|
||
financing_accounts: vec![],
|
||
investing_accounts: vec![],
|
||
},
|
||
)
|
||
.unwrap();
|
||
assert_eq!(cf.net_income, dec(1_700));
|
||
assert_eq!(cf.financing.len(), 1);
|
||
assert_eq!(cf.cash_from_financing, dec(10_000));
|
||
assert_eq!(cf.net_change_in_cash, dec(11_700));
|
||
}
|
||
|
||
/// Cash flow lines come out in account-code order, every time. (The chart
|
||
/// iterates in hash order; the statement must not.)
|
||
#[test]
|
||
fn cash_flow_lines_are_in_code_order() {
|
||
for _ in 0..20 {
|
||
let mut l = Ledger::new(Currency::USD);
|
||
let cash = l
|
||
.add_account("1000", "Cash", AccountType::Asset, None, false)
|
||
.unwrap();
|
||
let mut owners = Vec::new();
|
||
for code in ["3005", "3001", "3009", "3000", "3003"] {
|
||
owners.push(
|
||
l.add_account(code, code, AccountType::Equity, None, false)
|
||
.unwrap(),
|
||
);
|
||
}
|
||
for o in &owners {
|
||
post(&mut l, d(3, 1), "Contribution", cash, *o, 100);
|
||
}
|
||
let cf = cash_flow(
|
||
&l,
|
||
&CashFlowParams {
|
||
start: d(1, 1),
|
||
end: d(12, 31),
|
||
cash_accounts: vec![cash],
|
||
financing_accounts: vec![],
|
||
investing_accounts: vec![],
|
||
},
|
||
)
|
||
.unwrap();
|
||
// Labels read "Owner contributions — 3000 3000" (code, then name).
|
||
let codes: Vec<&str> = cf
|
||
.financing
|
||
.iter()
|
||
.map(|f| f.label.rsplit(' ').next().unwrap())
|
||
.collect();
|
||
assert_eq!(
|
||
codes,
|
||
["3000", "3001", "3003", "3005", "3009"],
|
||
"{:?}",
|
||
cf.financing
|
||
);
|
||
}
|
||
}
|
||
|
||
fn cents(s: &str) -> Decimal {
|
||
Decimal::from_str(s).unwrap()
|
||
}
|
||
|
||
#[test]
|
||
fn trial_balance_is_as_of_the_date() {
|
||
let b = books(false);
|
||
// Jan 31: Dr cash 11,700 and rent 800; Cr capital 10,000 and revenue 2,500.
|
||
let tb = trial_balance_as_of(&b.ledger, d(1, 31));
|
||
let rows: Vec<(&str, Decimal, Decimal)> = tb
|
||
.lines
|
||
.iter()
|
||
.map(|l| (l.code.as_str(), l.debit, l.credit))
|
||
.collect();
|
||
assert_eq!(
|
||
rows,
|
||
[
|
||
("1000", dec(11_700), dec(0)),
|
||
("3000", dec(0), dec(10_000)),
|
||
("4000", dec(0), dec(2_500)),
|
||
("6100", dec(800), dec(0)),
|
||
]
|
||
);
|
||
assert_eq!(
|
||
(tb.total_debits, tb.total_credits),
|
||
(dec(12_500), dec(12_500))
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn general_ledger_runs_balances_by_account() {
|
||
let b = books(false);
|
||
let gl = general_ledger(&b.ledger, d(2, 1), d(2, 28)).unwrap();
|
||
let codes: Vec<&str> = gl.accounts.iter().map(|a| a.code.as_str()).collect();
|
||
// Every account with a beginning balance or February activity, by code.
|
||
assert_eq!(codes, ["1000", "2000", "3000", "4000", "6000", "6100"]);
|
||
|
||
let cash = &gl.accounts[0];
|
||
assert_eq!(cash.beginning_balance, dec(11_700));
|
||
let p: Vec<(Decimal, Decimal, &str, Decimal)> = cash
|
||
.postings
|
||
.iter()
|
||
.map(|p| (p.debit, p.credit, p.split.as_str(), p.balance))
|
||
.collect();
|
||
assert_eq!(
|
||
p,
|
||
[
|
||
(dec(1_000), dec(0), "4000 Revenue", dec(12_700)),
|
||
(dec(0), dec(120), "2000 Card", dec(12_580)),
|
||
]
|
||
);
|
||
assert_eq!(cash.ending_balance, dec(12_580));
|
||
|
||
// The card: borrowed 120, paid 120, back to zero (credit-normal).
|
||
let card = &gl.accounts[1];
|
||
assert_eq!(card.beginning_balance, dec(0));
|
||
assert_eq!(card.postings[0].balance, dec(120));
|
||
assert_eq!(card.ending_balance, dec(0));
|
||
// Capital: no February activity, still listed with its balance.
|
||
assert!(gl.accounts[2].postings.is_empty());
|
||
assert_eq!(gl.accounts[2].ending_balance, dec(10_000));
|
||
|
||
// Every ending balance ties to the trial balance as of the end.
|
||
let tb = trial_balance_as_of(&b.ledger, d(2, 28));
|
||
for a in &gl.accounts {
|
||
let tb_line = tb.lines.iter().find(|l| l.code == a.code);
|
||
let tb_amount = tb_line.map_or(Decimal::ZERO, |l| l.debit - l.credit);
|
||
let normal = match a.account_type {
|
||
AccountType::Asset | AccountType::Expense => a.ending_balance,
|
||
_ => -a.ending_balance,
|
||
};
|
||
assert_eq!(tb_amount, normal, "account {}", a.code);
|
||
}
|
||
}
|
||
|
||
#[test]
|
||
fn general_ledger_marks_compound_entries_as_split() {
|
||
let mut l = Ledger::new(Currency::USD);
|
||
let cash = l
|
||
.add_account("1000", "Cash", AccountType::Asset, None, false)
|
||
.unwrap();
|
||
let card = l
|
||
.add_account("2000", "Card", AccountType::Liability, None, false)
|
||
.unwrap();
|
||
let soft = l
|
||
.add_account("6000", "Software", AccountType::Expense, None, false)
|
||
.unwrap();
|
||
// Dr software 100 / Cr cash 60 / Cr card 40: one debit, split credits.
|
||
let e = l.create_entry(d(3, 1), "Annual license, paid two ways");
|
||
l.add_line(e, soft, Side::Debit, usd(100)).unwrap();
|
||
l.add_line(e, cash, Side::Credit, usd(60)).unwrap();
|
||
l.add_line(e, card, Side::Credit, usd(40)).unwrap();
|
||
l.post_entry(e).unwrap();
|
||
let gl = general_ledger(&l, d(1, 1), d(12, 31)).unwrap();
|
||
let split_of = |code: &str| {
|
||
let a = gl.accounts.iter().find(|a| a.code == code).unwrap();
|
||
(a.postings[0].split.clone(), a.postings[0].entry_id)
|
||
};
|
||
// The debit side has two other accounts; each credit has one.
|
||
assert_eq!(split_of("6000"), ("-Split-".to_string(), e));
|
||
assert_eq!(split_of("1000"), ("6000 Software".to_string(), e));
|
||
assert_eq!(split_of("2000"), ("6000 Software".to_string(), e));
|
||
}
|
||
|
||
/// Two members, 50/50: Alice and Bob. January: Alice puts in 600, Bob 400;
|
||
/// revenue 1,000; expense 333.33, so net income 666.67, which doesn't split
|
||
/// evenly. February: Bob takes a 100 distribution; revenue 100.01.
|
||
fn partnership(close_january: bool) -> (Ledger, Vec<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, ¶ms(d(2, 1), d(2, 28))).unwrap();
|
||
assert_eq!(feb.net_income, dec(50));
|
||
assert_eq!(
|
||
lines(&feb.working_capital),
|
||
[
|
||
(s("Increase in 1100 Receivable"), dec(-100)),
|
||
(s("Increase in 2000 Payable"), dec(50)),
|
||
]
|
||
);
|
||
assert_eq!(
|
||
lines(&feb.financing),
|
||
[
|
||
(s("Proceeds from 2500 Loan"), dec(300)),
|
||
(s("Owner contributions — 3000 Capital"), dec(400)),
|
||
]
|
||
);
|
||
assert_eq!(feb.cash_from_operations, dec(0));
|
||
assert_eq!(feb.net_change_in_cash, dec(700));
|
||
reconciles(&feb);
|
||
|
||
// March: the mirror image. Collected 100, paid 50, repaid 300,
|
||
// distributed 400.
|
||
let mar = cash_flow(&l, ¶ms(d(3, 1), d(3, 31))).unwrap();
|
||
assert_eq!(mar.net_income, dec(0));
|
||
assert_eq!(
|
||
lines(&mar.working_capital),
|
||
[
|
||
(s("Decrease in 1100 Receivable"), dec(100)),
|
||
(s("Decrease in 2000 Payable"), dec(-50)),
|
||
]
|
||
);
|
||
assert_eq!(
|
||
lines(&mar.financing),
|
||
[
|
||
(s("Repayment of 2500 Loan"), dec(-300)),
|
||
(s("Owner distributions — 3000 Capital"), dec(-400)),
|
||
]
|
||
);
|
||
assert_eq!(mar.net_change_in_cash, dec(-650));
|
||
reconciles(&mar);
|
||
}
|
||
|
||
#[test]
|
||
fn a_loss_on_disposal_moves_out_of_operating_into_investing() {
|
||
let mut l = Ledger::new(Currency::USD);
|
||
let cash = l
|
||
.add_account("1000", "Cash", AccountType::Asset, None, false)
|
||
.unwrap();
|
||
let equipment = l
|
||
.add_account("1500", "Equipment", AccountType::Asset, None, false)
|
||
.unwrap();
|
||
let accum = l
|
||
.add_account("1501", "Accum Depr", AccountType::Asset, None, false)
|
||
.unwrap();
|
||
let capital = l
|
||
.add_account("3000", "Capital", AccountType::Equity, None, false)
|
||
.unwrap();
|
||
let depr = l
|
||
.add_account("6500", "Depreciation", AccountType::Expense, None, false)
|
||
.unwrap();
|
||
let loss = l
|
||
.add_account(
|
||
"6900",
|
||
"Loss on Disposal",
|
||
AccountType::Expense,
|
||
None,
|
||
false,
|
||
)
|
||
.unwrap();
|
||
l.register_asset(ledger_core::NewAsset {
|
||
description: "Laptop".to_string(),
|
||
purchase_date: d(1, 10),
|
||
cost: usd(1_000),
|
||
salvage_value: usd(0),
|
||
useful_life_months: 10,
|
||
cost_account: equipment,
|
||
accum_depr_account: accum,
|
||
expense_account: depr,
|
||
})
|
||
.unwrap();
|
||
post(&mut l, d(1, 5), "Contribution", cash, capital, 5_000);
|
||
post(&mut l, d(1, 10), "Buy laptop", equipment, cash, 1_000);
|
||
post(&mut l, d(1, 31), "Depreciation", depr, accum, 100);
|
||
// Sold for 600 at a book value of 900: a 300 loss.
|
||
let e = l.create_entry(d(2, 15), "Sell laptop");
|
||
l.add_line(e, cash, Side::Debit, usd(600)).unwrap();
|
||
l.add_line(e, accum, Side::Debit, usd(100)).unwrap();
|
||
l.add_line(e, loss, Side::Debit, usd(300)).unwrap();
|
||
l.add_line(e, equipment, Side::Credit, usd(1_000)).unwrap();
|
||
l.post_entry(e).unwrap();
|
||
|
||
let lines = |v: &[ledger_core::CashFlowLine]| -> Vec<(String, Decimal)> {
|
||
v.iter().map(|l| (l.label.clone(), l.amount)).collect()
|
||
};
|
||
// February: net income -300; the loss is added back (positive, it used
|
||
// no cash), so operations is 0; the 600 proceeds are investing.
|
||
let feb = cash_flow(&l, &cf_params(d(2, 1), d(2, 28), cash)).unwrap();
|
||
assert_eq!(feb.net_income, dec(-300));
|
||
assert_eq!(
|
||
lines(&feb.working_capital),
|
||
[(
|
||
"Less: gain on sale of fixed assets (in investing)".to_string(),
|
||
dec(300)
|
||
)]
|
||
);
|
||
assert_eq!(feb.cash_from_operations, dec(0));
|
||
assert_eq!(
|
||
lines(&feb.investing),
|
||
[("Sale of fixed assets — 1000 Cash".to_string(), dec(600))]
|
||
);
|
||
assert_eq!(feb.net_change_in_cash, dec(600));
|
||
reconciles(&feb);
|
||
}
|
||
|
||
#[test]
|
||
fn a_contribution_on_the_start_date_is_a_contribution_not_beginning_capital() {
|
||
let (l, members) = partnership(false);
|
||
// The partnership fixture's contributions are dated Jan 2. Starting the
|
||
// statement that day must give the same rows as all of January.
|
||
let me = members_equity(&l, d(1, 2), d(1, 31), &members).unwrap();
|
||
let row = |c: &ledger_core::MemberEquityColumn| (c.beginning, c.contributions, c.ending);
|
||
assert_eq!(
|
||
row(&me.members[0]),
|
||
(cents("0"), cents("600"), cents("933.34"))
|
||
);
|
||
assert_eq!(
|
||
row(&me.members[1]),
|
||
(cents("0"), cents("400"), cents("733.33"))
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn distribution_accounts_reduce_capital_and_returns_reduce_distributions() {
|
||
let mut l = Ledger::new(Currency::USD);
|
||
let cash = l
|
||
.add_account("1000", "Cash", AccountType::Asset, None, false)
|
||
.unwrap();
|
||
let cap_a = l
|
||
.add_account("3000", "Capital — Alice", AccountType::Equity, None, false)
|
||
.unwrap();
|
||
let cap_b = l
|
||
.add_account("3001", "Capital — Bob", AccountType::Equity, None, false)
|
||
.unwrap();
|
||
let dist_a = l
|
||
.add_account("3100", "Dist — Alice", AccountType::Equity, None, false)
|
||
.unwrap();
|
||
let dist_b = l
|
||
.add_account("3101", "Dist — Bob", AccountType::Equity, None, false)
|
||
.unwrap();
|
||
post(&mut l, d(1, 2), "Alice invests", cash, cap_a, 100);
|
||
post(&mut l, d(1, 2), "Bob invests", cash, cap_b, 100);
|
||
post(&mut l, d(1, 20), "Bob's distribution", dist_b, cash, 20);
|
||
// February: Bob takes 30 and hands 10 back: 20 distributed, net.
|
||
post(&mut l, d(2, 5), "Bob's distribution", dist_b, cash, 30);
|
||
post(&mut l, d(2, 6), "Bob returns some", cash, dist_b, 10);
|
||
let half = cents("0.5");
|
||
let members = vec![
|
||
MemberCapital {
|
||
name: "Alice".into(),
|
||
capital_account: cap_a,
|
||
distribution_account: Some(dist_a),
|
||
share: half,
|
||
},
|
||
MemberCapital {
|
||
name: "Bob".into(),
|
||
capital_account: cap_b,
|
||
distribution_account: Some(dist_b),
|
||
share: half,
|
||
},
|
||
];
|
||
// No close: Bob's January distribution is still in his distributions
|
||
// account, and it comes off his capital, not the partnership's.
|
||
let feb = members_equity(&l, d(2, 1), d(2, 28), &members).unwrap();
|
||
let row = |c: &ledger_core::MemberEquityColumn| {
|
||
(c.beginning, c.contributions, c.distributions, c.ending)
|
||
};
|
||
assert_eq!(row(&feb.members[0]), (dec(100), dec(0), dec(0), dec(100)));
|
||
assert_eq!(row(&feb.members[1]), (dec(80), dec(0), dec(20), dec(60)));
|
||
assert_eq!(
|
||
feb.total.ending,
|
||
balance_sheet_as_of(&l, d(2, 28)).unwrap().total_equity
|
||
);
|
||
assert!(feb.members.iter().all(|m| m.other.is_zero()));
|
||
}
|
||
|
||
/// The cash flow fixture for financing and investing roles. Hand-computed:
|
||
///
|
||
/// | date | entry | Dr | Cr |
|
||
/// |--------|----------------------------------------|----------------|----------------|
|
||
/// | Mar 1 | bank loan 1,000 | 1000 Cash | 2500 Loan |
|
||
/// | Mar 5 | owner pays software 100 personally | 6000 Software | 2100 Due to owners |
|
||
/// | Mar 10 | lend a member 500 | 1400 Member loan | 1000 Cash |
|
||
/// | Mar 20 | repay 400 of the bank loan | 2500 Loan | 1000 Cash |
|
||
/// | Mar 25 | the member repays 200 | 1000 Cash | 1400 Member loan |
|
||
/// | Mar 28 | reimburse the owner 100 | 2100 Due to owners | 1000 Cash |
|
||
///
|
||
/// Cash: +1,000 - 500 - 400 + 200 - 100 = +200.
|
||
fn roles() -> (Ledger, CashFlowParams) {
|
||
let mut l = Ledger::new(Currency::USD);
|
||
let mut add = |code: &str, t| l.add_account(code, code, t, None, false).unwrap();
|
||
let cash = add("1000", AccountType::Asset);
|
||
let member_loan = add("1400", AccountType::Asset);
|
||
let due_owners = add("2100", AccountType::Liability);
|
||
let loan = add("2500", AccountType::Liability);
|
||
let software = add("6000", AccountType::Expense);
|
||
post(&mut l, d(3, 1), "loan", cash, loan, 1000);
|
||
post(&mut l, d(3, 5), "software", software, due_owners, 100);
|
||
post(&mut l, d(3, 10), "lend", member_loan, cash, 500);
|
||
post(&mut l, d(3, 20), "repay", loan, cash, 400);
|
||
post(&mut l, d(3, 25), "collect", cash, member_loan, 200);
|
||
post(&mut l, d(3, 28), "reimburse", due_owners, cash, 100);
|
||
let params = CashFlowParams {
|
||
start: d(3, 1),
|
||
end: d(3, 31),
|
||
cash_accounts: vec![cash],
|
||
financing_accounts: vec![loan, due_owners],
|
||
investing_accounts: vec![member_loan],
|
||
};
|
||
(l, params)
|
||
}
|
||
|
||
fn lines(ls: &[ledger_core::reports::CashFlowLine]) -> Vec<(String, Decimal)> {
|
||
ls.iter()
|
||
.map(|l| (l.account.clone().unwrap_or_default(), l.amount))
|
||
.collect()
|
||
}
|
||
|
||
/// Oracle: ASC 230-10-45-7 ("information about the gross amounts of cash
|
||
/// receipts and cash payments during a period is more relevant than
|
||
/// information about the net amounts"), 230-10-45-14(b) (proceeds from
|
||
/// "other short- or long-term borrowing") and 45-15(2) ("Repayments of
|
||
/// amounts borrowed"): borrowing and repayment are separate lines.
|
||
#[test]
|
||
fn financing_is_gross_borrowing_apart_from_repayment() {
|
||
let (l, params) = roles();
|
||
let cf = cash_flow(&l, ¶ms).unwrap();
|
||
assert_eq!(
|
||
lines(&cf.financing),
|
||
[
|
||
("2100".into(), dec(100)),
|
||
("2100".into(), dec(-100)),
|
||
("2500".into(), dec(1000)),
|
||
("2500".into(), dec(-400)),
|
||
]
|
||
);
|
||
assert_eq!(cf.cash_from_financing, dec(600));
|
||
}
|
||
|
||
/// Oracle: ASC 230-10-20 ("Investing activities include making and
|
||
/// collecting loans") and 230-10-45-13 ("Disbursements for loans made by
|
||
/// the entity"): a loan to a member is investing, gross, not working
|
||
/// capital.
|
||
#[test]
|
||
fn a_loan_the_entity_makes_is_investing_gross() {
|
||
let (l, params) = roles();
|
||
let cf = cash_flow(&l, ¶ms).unwrap();
|
||
assert_eq!(
|
||
lines(&cf.investing),
|
||
[("1400".into(), dec(200)), ("1400".into(), dec(-500))]
|
||
);
|
||
assert!(cf.investing[0].label.starts_with("Collections on"));
|
||
assert!(cf.investing[1].label.starts_with("Payments for"));
|
||
assert!(
|
||
cf.working_capital
|
||
.iter()
|
||
.all(|w| w.account.as_deref() != Some("1400"))
|
||
);
|
||
assert_eq!(cf.cash_from_investing, dec(-300));
|
||
}
|
||
|
||
/// Oracle: PwC Viewpoint, Financial statement presentation 6.9.3.1
|
||
/// (constructive receipts and disbursements: cash a third party pays on
|
||
/// the entity's behalf is presented "as though the transaction took place
|
||
/// through the bank accounts of the reporting entity"). The owner paying
|
||
/// software personally is a borrowing (financing in) and an operating
|
||
/// payment (in net income); reimbursing the owner is a repayment.
|
||
/// Hand-computed: operating -100, investing -300, financing +600 = +200,
|
||
/// the change in cash.
|
||
#[test]
|
||
fn an_expense_the_owner_paid_is_borrowing_then_operating() {
|
||
let (l, params) = roles();
|
||
let cf = cash_flow(&l, ¶ms).unwrap();
|
||
assert_eq!(cf.net_income, dec(-100));
|
||
assert_eq!(cf.cash_from_operations, dec(-100));
|
||
assert_eq!(cf.net_change_in_cash, dec(200));
|
||
assert_eq!(
|
||
cf.cash_from_operations + cf.cash_from_investing + cf.cash_from_financing,
|
||
cf.net_change_in_cash
|
||
);
|
||
}
|
||
|
||
/// Gross lines net only within one entry: an entry that both debits and
|
||
/// credits a financing account by the same amount moves no cash and adds
|
||
/// no line.
|
||
#[test]
|
||
fn an_entry_that_nets_to_nothing_on_an_account_adds_no_line() {
|
||
let (mut l, params) = roles();
|
||
let loan = l.chart().find_by_code("2500").unwrap().id;
|
||
let e = l.create_entry(d(3, 30), "reclass within the loan");
|
||
l.add_line(e, loan, Side::Debit, usd(50)).unwrap();
|
||
l.add_line(e, loan, Side::Credit, usd(50)).unwrap();
|
||
l.post_entry(e).unwrap();
|
||
let cf = cash_flow(&l, ¶ms).unwrap();
|
||
assert_eq!(cf.cash_from_financing, dec(600));
|
||
assert_eq!(cf.financing.len(), 4);
|
||
}
|
||
|
||
mod chaining {
|
||
use super::*;
|
||
use ledger_core::{CloseTo, MemberClose};
|
||
use proptest::prelude::*;
|
||
|
||
/// One month of activity: (member, contribution cents, distribution
|
||
/// cents, revenue cents, expense cents).
|
||
type Month = (usize, i64, i64, i64, i64);
|
||
|
||
fn month() -> impl Strategy<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"
|
||
);
|
||
}
|
||
|
||
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);
|
||
}
|
||
}
|
||
}
|
||
|
||
/// A run is closes with the same members and shares, back to back: a
|
||
/// close to anything else (or with other shares) ends it, and the next
|
||
/// members close starts afresh.
|
||
#[test]
|
||
fn a_different_close_ends_the_run() {
|
||
let mut l = Ledger::new(Currency::USD);
|
||
let mut add = |code: &str, t| l.add_account(code, code, t, None, false).unwrap();
|
||
let cash = add("1000", AccountType::Asset);
|
||
let caps = [
|
||
add("3000", AccountType::Equity),
|
||
add("3001", AccountType::Equity),
|
||
];
|
||
let rev = add("4000", AccountType::Revenue);
|
||
let cent = |l: &mut Ledger, m| {
|
||
let e = l.create_entry(d(m, 10), "a cent");
|
||
l.add_line(
|
||
e,
|
||
cash,
|
||
Side::Debit,
|
||
Money::new(cents("0.01"), Currency::USD),
|
||
)
|
||
.unwrap();
|
||
l.add_line(
|
||
e,
|
||
rev,
|
||
Side::Credit,
|
||
Money::new(cents("0.01"), Currency::USD),
|
||
)
|
||
.unwrap();
|
||
l.post_entry(e).unwrap();
|
||
};
|
||
let close = |l: &mut Ledger, m: u32, to: &CloseTo| {
|
||
let end = d(m + 1, 1).pred_opt().unwrap();
|
||
let p = l
|
||
.create_period(&format!("2026-{m:02}"), d(m, 1), end)
|
||
.unwrap();
|
||
l.close_period_to(p, to).unwrap();
|
||
};
|
||
// Month 1 to members (A gets the cent), month 2 to retained
|
||
// earnings (ends the run), month 3 to members again: a new run, so
|
||
// A gets that cent too.
|
||
cent(&mut l, 1);
|
||
close(&mut l, 1, &CloseTo::Members(half(&caps)));
|
||
cent(&mut l, 2);
|
||
close(&mut l, 2, &CloseTo::RetainedEarnings { sweep: vec![] });
|
||
cent(&mut l, 3);
|
||
close(&mut l, 3, &CloseTo::Members(half(&caps)));
|
||
assert_eq!(l.account_balance(caps[0]).unwrap(), cents("0.02"));
|
||
assert_eq!(l.account_balance(caps[1]).unwrap(), cents("0"));
|
||
}
|
||
}
|