members' equity: split undistributed earnings cumulatively
check / check (push) Canceled after 0s

A capital account is one running balance (Form 1065 Schedule K-1
instructions, Item L: beginning and ending capital "based on the
partnership's books and records"), but the statement split prior
earnings and the period's income separately, so a member's ending on one
statement could differ by a cent from their beginning on the next.

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

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

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GWVo7x3UHw5kwCUaWxiejX
This commit is contained in:
swaitsandClaude Opus 5.5 committed 2026-10-13 18:41:37 -06:00
1 parent 819cd09e0d
commit 37824e55d3
6 files changed
+213 -36

No files matched your search

+16 -12
View File
@@ -1004,14 +1004,16 @@ fn me_refuses_bad_shares() {
}
}
/// Oracle: ME INVARIANTS 1-3 — hand-computed roll-forward with odd cents
/// split by largest remainder, ties to the earlier member: prior unclosed
/// earnings 100.01 -> A 50.01, B 50.00; period income 201.01 -> A 100.51,
/// B 100.50. Total ending 2251.02 = BS equity.
///
/// CHANGE PENDING (docs/testing.md #7): the beginning (unclosed prior earnings) and the period's income
/// are each split separately (INVARIANT 3 names them separately), not
/// cumulative-then-differenced.
/// Oracle: ME INVARIANTS 1-3b — hand-computed roll-forward, cumulative
/// allocation (docs/testing.md #7; K-1 Item L: one running capital
/// balance), odd cents by largest remainder, ties to the earlier member.
/// Undistributed before Feb 1: 100.01 -> A 50.01, B 50.00. Through Mar 31:
/// 100.01 + 301.01 - 100.00 = 301.02 -> 150.51 each. Endings: A 950.00 +
/// 150.51 = 1100.51, B 1000.00 + 150.51 = 1150.51. Incomes (the
/// remainder): A 1100.51 - 1050.01 + 50.00 = 100.50, B 100.51; sum 201.01,
/// the period's income. Total ending 2251.02 = BS equity. (Splitting the
/// period's income on its own would give A 150.52 and B 150.50 to date:
/// two cents off half each.)
#[test]
fn me_hand_computed_roll_forward_with_largest_remainder() {
let mut co = company();
@@ -1059,8 +1061,8 @@ fn me_hand_computed_roll_forward_with_largest_remainder() {
assert_eq!(
st.members,
vec![
col("A", 105001, 0, 10051, 5000, 0, 110052),
col("B", 5000, 100000, 10050, 0, 0, 115050),
col("A", 105001, 0, 10050, 5000, 0, 110051),
col("B", 5000, 100000, 10051, 0, 0, 115051),
]
);
assert_eq!(
@@ -1408,7 +1410,8 @@ fn sorted_amounts(lines: &[ledger_core::CashFlowLine]) -> Vec<Decimal> {
/// 400; AR up 300 (-300), AP up 80 (+80); CFO 180; financing loan 300 +
/// contribution 100 = 400; net change 580 = cash 1000 -> 1580.
///
/// CHANGE PENDING (docs/testing.md #8): one working-capital / financing line per account that changed.
/// Working capital is one line per account that changed; financing is gross per
/// account (docs/testing.md #8): here each financing account moved one way only.
#[test]
fn cf_hand_computed_with_designated_debt() {
let co = cf_example_books();
@@ -2424,7 +2427,8 @@ proptest! {
/// NI + depreciation + working capital; with or without a close and a
/// designated loan.
///
/// CHANGE PENDING (docs/testing.md #8): with no registered fixed assets, depreciation and investing
/// With no registered fixed assets and no investing accounts (docs/testing.md #8),
/// depreciation and investing
/// are zero (receivables are current, so working capital).
#[test]
fn prop_cf_sections_sum_to_change_in_cash(gens in arb_books(), (a, _m, e) in three_days(), month in opt_month(), designate in any::<bool>()) {
@@ -0,0 +1,7 @@
# Seeds for failure cases proptest has generated in the past. It is
# automatically read and these particular cases re-run before any
# novel cases are generated.
#
# It is recommended to check this file in to source control so that
# everyone who runs the test benefits from these saved cases.
cc b177aed2b1d69b6a0abb37b170ec5b9efc73d7fae49bace065c15c819ecca711 # shrinks to months = [(0, 0, 0, 507, 37392), (0, 0, 0, 9054, 3952), (0, 0, 0, 34144, 7979)], closes = [false, true, false, false, false, false, false, false, false, false, false, false], split = 14
+122 -5
View File
@@ -471,16 +471,20 @@ fn members_equity_rolls_capital_forward_and_ties_to_the_balance_sheet() {
);
// February begins where January ended, whether or not January was
// closed to retained earnings. 100.01 splits 50.01 / 50.00.
// 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("50.01"),
cents(alice.0),
cents("0"),
cents("983.35")
cents(alice.1)
)
);
assert_eq!(
@@ -488,9 +492,9 @@ fn members_equity_rolls_capital_forward_and_ties_to_the_balance_sheet() {
(
cents("733.33"),
cents("0"),
cents("50.00"),
cents(bob.0),
cents("100"),
cents("683.33")
cents(bob.1)
)
);
assert_eq!(feb.total.ending, cents("1666.68"));
@@ -1405,3 +1409,116 @@ fn an_entry_that_nets_to_nothing_on_an_account_adds_no_line() {
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
);
}
}
}
}
}