members' equity: split undistributed earnings cumulatively

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 fbe496566b
commit 45982f702d
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, owner 2026-10-13).
/// 3. Undistributed earnings MUST be split by share, to the cent, by
/// largest remainder (DECISION, owner 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),