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

+50 -8
View File
@@ -823,8 +823,17 @@ pub struct MembersEquityStatement {
/// 1. Each row MUST roll forward exactly: beginning + contributions +
/// income - distributions + other = ending.
/// 2. The total ending MUST equal balance-sheet equity at `end`.
/// 3. Unclosed earnings and the period's income MUST be split by share,
/// to the cent, by largest remainder (DECISION, Stephen 2026-10-13).
/// 3. Undistributed earnings MUST be split by share, to the cent, by
/// largest remainder (DECISION, Stephen 2026-10-13), cumulatively as
/// of each date: a member's beginning is their capital and share as
/// of the day before `start`, their ending as of `end`.
/// 3a. A member's ending on one statement MUST equal their beginning on
/// the next, exactly (a capital account is one running balance: Form
/// 1065 Schedule K-1 instructions, Item L, "beginning capital account
/// and ending capital account ... based on the partnership's books").
/// 3b. A member's income is the roll-forward's remainder: their share of
/// the period's income within a rounding cent; the members' incomes
/// MUST sum to the period's net income.
/// 4. NEVER hide an unexplained change: it goes in "other".
/// 5. An entry dated `start` is period activity, not beginning capital.
/// SCENARIOS:
@@ -924,10 +933,39 @@ pub fn members_equity(
.collect();
// Everything in equity that isn't a capital account is partnership
// earnings not yet distributed: allocate it by share.
let undistributed = begin_bs.total_equity - capital.iter().sum::<Decimal>();
let begin_alloc = allocate(undistributed, &shares);
let income_alloc = allocate(pnl.net_income(), &shares);
// earnings not yet distributed. Each member's share of it is allocated
// cumulatively, as of a date, so a member's capital on a date never
// depends on which statement shows it: one statement's ending is the
// next one's beginning, to the cent (a capital account is one running
// balance: Form 1065 Schedule K-1 instructions, Item L).
let capital_through = |id: crate::AccountId, through: NaiveDate| -> Decimal {
let mut m = Decimal::ZERO;
for e in ledger.posted_entries().filter(|e| e.date() <= through) {
for l in e.lines().iter().filter(|l| l.account == id) {
match l.side {
crate::Side::Credit => m += l.amount.amount,
crate::Side::Debit => m -= l.amount.amount,
}
}
}
m
};
let capital_end: Vec<Decimal> = members
.iter()
.map(|m| {
capital_through(m.capital_account, end)
+ m.distribution_account
.map_or(Decimal::ZERO, |d| capital_through(d, end))
})
.collect();
let begin_alloc = allocate(
begin_bs.total_equity - capital.iter().sum::<Decimal>(),
&shares,
);
let end_alloc = allocate(
end_bs.total_equity - capital_end.iter().sum::<Decimal>(),
&shares,
);
let explained = flow.iter().map(|(c, d)| c - d).sum::<Decimal>() + pnl.net_income();
let other_alloc = allocate(
end_bs.total_equity - begin_bs.total_equity - explained,
@@ -937,13 +975,17 @@ pub fn members_equity(
let mut cols = Vec::with_capacity(members.len());
for (i, m) in members.iter().enumerate() {
let beginning = capital[i] + begin_alloc[i];
let ending = capital_end[i] + end_alloc[i];
let (contributions, distributions) = flow[i];
let ending = beginning + contributions - distributions + income_alloc[i] + other_alloc[i];
// The member's income is what the roll-forward leaves: their share
// of the period's income, give or take the rounding cent that keeps
// the running balance exact. The members' incomes sum to net income.
let income = ending - beginning - contributions + distributions - other_alloc[i];
cols.push(MemberEquityColumn {
name: m.name.clone(),
beginning: zero_clean(beginning),
contributions,
net_income: zero_clean(income_alloc[i]),
net_income: zero_clean(income),
distributions,
other: zero_clean(other_alloc[i]),
ending: zero_clean(ending),
+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
);
}
}
}
}
}
+11 -1
View File
@@ -217,10 +217,20 @@ closed period).
- **No authority** says how to round a share to the cent. Largest
remainder, ties to the earlier member: DECISION (Stephen, 2026-10-13).
Closes made before then replay with the last member absorbing it.
- A capital account is one running balance: Form 1065 Schedule K-1
instructions, Item L: "The partnership must report your beginning
capital account and ending capital account for the year", "based on the
partnership's books and records." So a statement of members' equity
splits undistributed earnings cumulatively, as of each date (beginning
as of the day before the period, ending as of its last day), never one
period at a time: a member's ending on one statement is their beginning
on the next, to the cent, and a member's period income is the
roll-forward's remainder (their share within a rounding cent).
**Enforced:** `CloseTo::Members`, `periods::allocate`,
`kernel::apportion`, `members_equity`. **Checked:** Kani
`apportion_is_exact`, `apportion_is_fair`; properties `prop_split_*`;
`apportion_is_exact`, `apportion_is_fair`; properties `prop_split_*`,
`each_members_capital_chains_across_statements`;
`closes_journaled_under_the_old_rule_replay_with_it`.
## B-14 Statements: P&L for a period; cash flow by the indirect method
+7 -10
View File
@@ -73,20 +73,17 @@ accounting question), CHANGE (a source says otherwise; work pending).
| 4 | Closing a period with nothing to close still locks it | CONVENTION | A close is a lock (B-12); a dormant month must still lock and count for close-in-order |
| 5 | Draws and distributions are closed into capital at period end | CONFIRMED | OpenStax 5.1: dividends "are closed to the permanent account"; drawing accounts close to owner's capital. Monthly closes mean the year's K-1 Item L withdrawals sum across months |
| 6 | Reconciliation balances | CONFIRMED | Signed by the account's normal side (a card reads as owed), as statements show it; `reconciling_a_card_reads_its_normal_side_...` |
| 7 | A member's ending on one equity statement may differ by a cent from the next statement's beginning | CHANGE (pending) | A capital account is one running balance (Form 1065 K-1 instructions, Item L: beginning and ending capital "based on the partnership's books and records"). Allocate cumulatively so beginning equals the prior ending per member. CPA question below |
| 8 | Cash flow nets one line per account; investing comes only from registered assets | CHANGE (pending) | ASC 230-10-45-7: gross receipts and payments "more relevant" than net; 45-8 limits netting to quick-turnover items. Financing: contributions apart from distributions, borrowings apart from repayments. ASC 230-10-20/45-13: loans made and investments bought are investing, so classify by account role |
| 7 | A member's ending on one equity statement may differ by a cent from the next statement's beginning | CHANGED 2026-10-13 | A capital account is one running balance (Form 1065 K-1 instructions, Item L). Undistributed earnings now split cumulatively as of each date, so each member's capital chains exactly (`each_members_capital_chains_across_statements`; it fails on the old code); rules.md B-13 |
| 8 | Cash flow nets one line per account; investing comes only from registered assets | CHANGED 2026-10-13 | ASC 230-10-45-7: gross receipts and payments "more relevant" than net; 45-8 limits netting to quick-turnover items. Financing and investing are now gross; loans made and investments bought are investing (230-10-20, 45-13) via `[books] investing_accounts`. Owner-advanced expenses: PwC 6.9.3.1 constructive treatment via `[books] financing_accounts` (rules.md B-14) |
| 9 | An asset's life ends at purchase + life months, that day not held; day-count proration | CONVENTION | Exactly N months of service; day proration and the mid-month convention are both accepted book policies (ASC 360-10-35-4 not read: login) |
| 10 | A payroll entry has one line per account total | CONFIRMED | The per-employee detail the IRS requires ("Amounts and dates of all wage ... payments", Employment Tax Recordkeeping) is kept in the run; a summary entry is standard |
| 11 | A period with exactly the dates of an existing one is allowed | DESIGN | Nothing governs duplicate periods; harmless (the second close locks only). Refusing exact duplicates is an option |
Questions for Krista Baker (CPA):
1. Interim members' equity: is a per-member allocation of unclosed
earnings acceptable on interim statements, or should the books close
monthly so the capital accounts carry it?
2. How should 2100 Due to Shareholders, and expenses the owners paid
personally, appear on the cash flow statement: operating, financing,
or as a non-cash disclosure (ASC 230-10-50-3, not yet read)?
Questions are settled from the sources, not by any one company's CPA
(wanderbooks is general software). Where the standards leave a real
choice, it is a per-company setting with a sourced default, for that
company's accountant to review: the cash flow account roles (rules.md
B-14) are the first.
## Gap list: what isn't covered, and what would bring it in