P3: odd cents split the same running way everywhere (defect 17, D-38)
check / check (push) Canceled after 0s
check / check (push) Canceled after 0s
Members' shares of earnings are split cumulatively by largest remainder in the closes, the members' equity statement, the K-1 worksheet and the viewer, so a member's capital on a given day never moves by a cent when its month is later closed. Old closes replay as they were; the first P3 close carries any true-up. Feature members_cumulative. Kani and Lean proofs of the bounded drift; bookspec examples. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01T3gXMVt7XJPi6ens1or9Dj
This commit is contained in:
44 files changed
+6636
-195
No files matched your search
@@ -32,7 +32,8 @@
|
||||
//! with the MacBook read as cash.)
|
||||
//! 4. Within one entry, the property (at most the members' credits in
|
||||
//! it) is shared among the members credited in proportion to their
|
||||
//! credits, to the cent, leftover cents by largest remainder
|
||||
//! credits, to the base currency's minor unit (cents, whole yen),
|
||||
//! leftover units by largest remainder
|
||||
//! (kernel::apportion, ties to the earlier member): each takes at
|
||||
//! most its own credit, and the split depends on the order the
|
||||
//! members are given only for a tied leftover cent (never on line
|
||||
@@ -167,6 +168,7 @@ pub fn k1_detail(
|
||||
));
|
||||
}
|
||||
let me = members_equity(ledger, start, end, members)?;
|
||||
let places = ledger.base_currency().allocation_places();
|
||||
let receivables = ledger.role_account(&crate::RECEIVABLES);
|
||||
let closings = ledger.closing_entry_ids();
|
||||
let cancelled = crate::reports::cancelled_in_span(ledger, start, end);
|
||||
@@ -219,7 +221,7 @@ pub fn k1_detail(
|
||||
.iter()
|
||||
.map(|m| sum(e, Side::Credit, m.capital_account))
|
||||
.collect();
|
||||
let takes = share_property(pool, &credits)?;
|
||||
let takes = share_property(pool, &credits, places)?;
|
||||
for (i, m) in members.iter().enumerate() {
|
||||
let (credit, take) = (credits[i], takes[i]);
|
||||
if !(credit - take).is_zero() {
|
||||
@@ -274,11 +276,16 @@ pub fn k1_detail(
|
||||
|
||||
/// One entry's property shared among the members' credits in it
|
||||
/// (INVARIANT 4): at most their total credit, in proportion to each
|
||||
/// credit, rounded toward zero to the cent, the leftover cents by largest
|
||||
/// remainder. Each part is at most its own credit.
|
||||
/// credit, rounded toward zero to `places` (the base currency's
|
||||
/// Currency::allocation_places: cents, whole yen), the leftover units by
|
||||
/// largest remainder. Each part is at most its own credit.
|
||||
/// Refused when whole × credit is beyond exact arithmetic (credits near
|
||||
/// the line limit; P0 review round 3, S1).
|
||||
fn share_property(pool: Decimal, credits: &[Decimal]) -> Result<Vec<Decimal>, LedgerError> {
|
||||
fn share_property(
|
||||
pool: Decimal,
|
||||
credits: &[Decimal],
|
||||
places: u32,
|
||||
) -> Result<Vec<Decimal>, LedgerError> {
|
||||
use rust_decimal::RoundingStrategy::ToZero;
|
||||
let too_large =
|
||||
|| LedgerError::TooLargeToCompute("a member's share of contributed property".into());
|
||||
@@ -291,7 +298,7 @@ fn share_property(pool: Decimal, credits: &[Decimal]) -> Result<Vec<Decimal>, Le
|
||||
return Ok(vec![Decimal::ZERO; credits.len()]);
|
||||
}
|
||||
// whole × credit is exact; ÷ total is exact whenever the share is a
|
||||
// whole number of cents, so truncating never loses a cent it is owed.
|
||||
// whole number of units, so truncating never loses a unit it is owed.
|
||||
let exact: Vec<Decimal> = credits
|
||||
.iter()
|
||||
.map(|c| whole.checked_mul(*c).and_then(|x| x.checked_div(total)))
|
||||
@@ -299,11 +306,11 @@ fn share_property(pool: Decimal, credits: &[Decimal]) -> Result<Vec<Decimal>, Le
|
||||
.ok_or_else(too_large)?;
|
||||
let mut parts: Vec<Decimal> = exact
|
||||
.iter()
|
||||
.map(|x| x.round_dp_with_strategy(2, ToZero))
|
||||
.map(|x| x.round_dp_with_strategy(places, ToZero))
|
||||
.collect();
|
||||
let remainders: Vec<Decimal> = exact.iter().zip(&parts).map(|(x, p)| x - p).collect();
|
||||
crate::kernel::apportion(whole, &mut parts, &remainders, Decimal::new(1, 2))
|
||||
.expect("each part is short by under a cent, so the shortfall is under a cent per member");
|
||||
crate::kernel::apportion(whole, &mut parts, &remainders, Decimal::new(1, places))
|
||||
.expect("each part is short by under a unit, so the shortfall is under a unit per member");
|
||||
Ok(parts)
|
||||
}
|
||||
|
||||
@@ -840,20 +847,20 @@ mod tests {
|
||||
flipped.reverse();
|
||||
assert_eq!(split(&bk, &flipped), vec![each, each]);
|
||||
assert_eq!(
|
||||
share_property(d("401"), &[d("750"), d("250")]).unwrap(),
|
||||
share_property(d("401"), &[d("750"), d("250")], 2).unwrap(),
|
||||
vec![d("300.75"), d("100.25")]
|
||||
);
|
||||
assert_eq!(
|
||||
share_property(d("0.01"), &[d("5"), d("5")]).unwrap(),
|
||||
share_property(d("0.01"), &[d("5"), d("5")], 2).unwrap(),
|
||||
vec![d("0.01"), d("0.00")]
|
||||
);
|
||||
// More property than credit (part financed): at most the credits.
|
||||
assert_eq!(
|
||||
share_property(d("900"), &[d("300"), d("100")]).unwrap(),
|
||||
share_property(d("900"), &[d("300"), d("100")], 2).unwrap(),
|
||||
vec![d("300"), d("100")]
|
||||
);
|
||||
assert_eq!(
|
||||
share_property(d("50"), &[d("0"), d("0")]).unwrap(),
|
||||
share_property(d("50"), &[d("0"), d("0")], 2).unwrap(),
|
||||
vec![d("0"), d("0")]
|
||||
);
|
||||
// Credits near the line limit: whole x credit is past exact
|
||||
@@ -861,18 +868,29 @@ mod tests {
|
||||
// round 3, S1); at a quarter of it, it still splits exactly.
|
||||
let near = d("900000000000000");
|
||||
assert!(matches!(
|
||||
share_property(near, &[near, near]),
|
||||
share_property(near, &[near, near], 2),
|
||||
Err(LedgerError::TooLargeToCompute(_))
|
||||
));
|
||||
assert!(matches!(
|
||||
share_property(near, &[Decimal::MAX, Decimal::MAX]),
|
||||
share_property(near, &[Decimal::MAX, Decimal::MAX], 2),
|
||||
Err(LedgerError::TooLargeToCompute(_))
|
||||
));
|
||||
let quarter = d("250000000000");
|
||||
assert_eq!(
|
||||
share_property(quarter, &[quarter, quarter]).unwrap(),
|
||||
share_property(quarter, &[quarter, quarter], 2).unwrap(),
|
||||
vec![d("125000000000"), d("125000000000")]
|
||||
);
|
||||
// To the base currency's minor unit (allocation_places): in whole
|
||||
// yen, 101 of property by credits of 51 each is 50.5 each, the
|
||||
// tied yen to the first; to cents it would be 50.50 each.
|
||||
assert_eq!(
|
||||
share_property(d("101"), &[d("51"), d("51")], 0).unwrap(),
|
||||
vec![d("51"), d("50")]
|
||||
);
|
||||
assert_eq!(
|
||||
share_property(d("101"), &[d("51"), d("51")], 2).unwrap(),
|
||||
vec![d("50.50"), d("50.50")]
|
||||
);
|
||||
}
|
||||
|
||||
/// member_party doc: the party of that name with the member role only.
|
||||
|
||||
@@ -26,7 +26,10 @@
|
||||
//! columns differ by the net of all movements (B-6);
|
||||
//! * a closing line zeroes the account it closes (B-11);
|
||||
//! * an allocation's parts sum to the whole, to the last unit, by largest
|
||||
//! remainder (B-13).
|
||||
//! remainder (B-13);
|
||||
//! * a cumulative allocation hands out exactly the new amount, and leaves
|
||||
//! each running total one allocation of everything allocated, so it never
|
||||
//! drifts a unit from its exact share (B-13).
|
||||
|
||||
use crate::{AccountType, Side};
|
||||
use rust_decimal::Decimal;
|
||||
@@ -352,6 +355,60 @@ pub fn apportion<T: Amount>(whole: T, parts: &mut [T], remainders: &[T], unit: T
|
||||
Some(())
|
||||
}
|
||||
|
||||
/// Cumulative apportionment: what one close gives each part when the
|
||||
/// running `total` is apportioned afresh ([`apportion`]) and what earlier
|
||||
/// closes gave each part (`given`) is taken back. `parts` and
|
||||
/// `remainders` are the rounded-down shares of `total` and what rounding
|
||||
/// took from each, as for [`apportion`].
|
||||
///
|
||||
/// ```text
|
||||
/// GOAL: members' running totals that never drift a unit from their
|
||||
/// exact shares, whatever earlier closes did.
|
||||
/// SOURCE: docs/spec/rules.md B-13 (shares: 26 U.S.C. §704(a); capital
|
||||
/// accounts carry the allocations: Treas. Reg.
|
||||
/// §1.704-1(b)(2)(iv)(b); cent rounding: DECISION, owner
|
||||
/// 2026-10-13; counted from the first close: D-10, 2026-10-16).
|
||||
/// INVARIANTS:
|
||||
/// 1. MUST make the parts sum to `total − Σ given` exactly.
|
||||
/// 2. MUST leave given[i] + parts[i] equal to what `apportion(total, …)`
|
||||
/// gives part i: a running total is one apportionment, so it carries
|
||||
/// one apportionment's rounding, never a sum of them.
|
||||
/// 3. On None, `parts` MUST be unchanged.
|
||||
/// SCENARIOS:
|
||||
/// 1. IF the lengths differ, the unit isn't positive, `total`, the
|
||||
/// unit, a part or a `given` is out of line range, or apportion
|
||||
/// refuses: SHOULD return None, leaving `parts` unchanged
|
||||
/// BECAUSE in range, the subtraction can't overflow, so a refusal is
|
||||
/// known before anything changes.
|
||||
/// 2. IF 0.03 was closed as A 0.03 / B 0 and the running total is now
|
||||
/// 0.04 (50/50, floors 0.02 / 0.02): SHOULD give A −0.01, B +0.02
|
||||
/// BECAUSE 0.04 apportions 0.02 each.
|
||||
/// ```
|
||||
///
|
||||
/// Allocation-free, with no bulk copy (docs/verification.md, "Trusting
|
||||
/// the checker"), so the model checker can prove it outright.
|
||||
pub fn apportion_cumulative<T: Amount>(
|
||||
total: T,
|
||||
parts: &mut [T],
|
||||
remainders: &[T],
|
||||
unit: T,
|
||||
given: &[T],
|
||||
) -> Option<()> {
|
||||
if parts.len() != given.len() || !total.in_line_range() || !unit.in_line_range() {
|
||||
return None;
|
||||
}
|
||||
if parts.iter().chain(given).any(|a| !a.in_line_range()) {
|
||||
return None;
|
||||
}
|
||||
apportion(total, parts, remainders, unit)?;
|
||||
// Each part is now under three line ranges and each `given` within
|
||||
// one: the subtraction can't fail (Kani apportion_cumulative_is_exact).
|
||||
for (part, &g) in parts.iter_mut().zip(given) {
|
||||
*part = part.checked_sub(g)?;
|
||||
}
|
||||
Some(())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
@@ -423,6 +480,51 @@ mod tests {
|
||||
assert_eq!(apportion(0, &mut none, &[], -1), None);
|
||||
}
|
||||
|
||||
/// apportion_cumulative scenario 1: mismatched lengths, a bad unit, an
|
||||
/// input out of line range, or a shortfall apportion refuses: None,
|
||||
/// and the parts untouched.
|
||||
#[test]
|
||||
fn apportion_cumulative_refuses_mismatched_or_out_of_range_inputs() {
|
||||
let big = (1i64 << 40) + 1;
|
||||
for (total, parts, rems, unit, given) in [
|
||||
(4i64, [2i64, 2], [0i64, 0], 1i64, &[0i64][..]),
|
||||
(4, [2, 2], [0, 0], 0, &[0, 0][..]),
|
||||
(4, [2, 2], [0, 0], big, &[0, 0][..]),
|
||||
(big, [2, 2], [0, 0], 1, &[0, 0][..]),
|
||||
(4, [big, 2], [0, 0], 1, &[0, 0][..]),
|
||||
(4, [2, 2], [0, 0], 1, &[big, 0][..]),
|
||||
// Three units short of two parts: not rounding.
|
||||
(7, [2, 2], [0, 0], 1, &[0, 0][..]),
|
||||
] {
|
||||
let mut p = parts;
|
||||
assert_eq!(
|
||||
apportion_cumulative(total, &mut p, &rems, unit, given),
|
||||
None,
|
||||
"{total} {parts:?} {unit} {given:?}"
|
||||
);
|
||||
assert_eq!(p, parts);
|
||||
}
|
||||
// Remainders that don't pair with the parts: apportion's refusal.
|
||||
let mut p = [2i64, 2];
|
||||
assert_eq!(apportion_cumulative(4, &mut p, &[0], 1, &[0, 0]), None);
|
||||
assert_eq!(p, [2, 2]);
|
||||
}
|
||||
|
||||
/// apportion_cumulative scenario 2 (cents, i64): three cents closed
|
||||
/// one at a time gave A all three; the fourth cent's close apportions
|
||||
/// the running 4 cents (2 / 2) and takes back what A and B had.
|
||||
#[test]
|
||||
fn apportion_cumulative_trues_up_a_legacy_split() {
|
||||
let mut parts = [2i64, 2];
|
||||
apportion_cumulative(4, &mut parts, &[0, 0], 1, &[3, 0]).unwrap();
|
||||
assert_eq!(parts, [-1, 2]);
|
||||
// A running total of 5: floors 2 / 2 with equal remainders, so the
|
||||
// tied unit goes to the first (3 / 2), less what each had (3 / 1).
|
||||
let mut parts = [2i64, 2];
|
||||
apportion_cumulative(5, &mut parts, &[5, 5], 1, &[3, 1]).unwrap();
|
||||
assert_eq!(parts, [0, 1]);
|
||||
}
|
||||
|
||||
/// With no parts, only a zero whole is fully apportioned.
|
||||
#[test]
|
||||
fn apportion_to_no_parts_accepts_only_zero() {
|
||||
|
||||
@@ -2127,6 +2127,7 @@ impl Ledger {
|
||||
closing_entry: None,
|
||||
close_seq: None,
|
||||
member_close: None,
|
||||
members_closed: None,
|
||||
},
|
||||
);
|
||||
Ok(id)
|
||||
@@ -2173,9 +2174,17 @@ impl Ledger {
|
||||
/// - `RetainedEarnings`: account `3999`, created if missing.
|
||||
/// - `Account`: one equity account (a sole proprietor's capital).
|
||||
/// - `Members`: each member's capital account gets their share of
|
||||
/// the net, to the cent (odd cents by largest remainder; closes
|
||||
/// journaled before 2026-10-13 replay with the last member
|
||||
/// absorbing them), and their own distribution account.
|
||||
/// everything closed to these members and shares so far, to the
|
||||
/// currency's minor unit (odd units by largest remainder, ties to
|
||||
/// the member listed first), less what earlier closes gave them
|
||||
/// (rules.md B-13; D-10), and their own distribution account. The
|
||||
/// count starts at the first members close, or at the first since
|
||||
/// the members or shares last changed (`Ledger::members_regime`);
|
||||
/// closes to anything else are skipped. A close that posts an
|
||||
/// entry may carry a one-time true-up of rounding by closes made
|
||||
/// under an earlier rule (the journaled `MembersRun`,
|
||||
/// `MembersPerClose` and `MembersLastAbsorbs`, which replay as
|
||||
/// they were made).
|
||||
/// 3. Marks the period closed.
|
||||
///
|
||||
/// Returns the closing entry's id, or `None` if there was nothing to
|
||||
@@ -2191,12 +2200,20 @@ impl Ledger {
|
||||
/// balance as of the period's last day.
|
||||
/// 2. The closing entry MUST balance, be dated the period's last day, and
|
||||
/// credit equity with exactly the net income through that day (split
|
||||
/// among members by share, to the cent, by largest remainder).
|
||||
/// among members by share, to the currency's minor unit, by largest
|
||||
/// remainder, cumulatively).
|
||||
/// 3. Entries dated after the period MUST stay untouched (their income
|
||||
/// belongs to their own period).
|
||||
/// 4. A sweep account (draws, distributions, dividends) MUST end at zero,
|
||||
/// its balance moved into its target.
|
||||
/// 5. NEVER post a closing entry when there is nothing to close.
|
||||
/// 6. After a members close that posts, each member's total from the
|
||||
/// count's closes MUST be one largest-remainder split of everything
|
||||
/// they closed: under one minor unit from their exact share. A
|
||||
/// members close that posts nothing credits no one (any true-up
|
||||
/// waits for the next close that posts).
|
||||
/// 7. NEVER changes a posted close: earlier closes replay under the
|
||||
/// rule they were journaled with.
|
||||
/// SCENARIOS:
|
||||
/// 1. IF the period is unknown or already closed: SHOULD refuse.
|
||||
/// 2. IF a draft is dated inside it: SHOULD refuse (DraftsInPeriod).
|
||||
@@ -2205,6 +2222,13 @@ impl Ledger {
|
||||
/// member shares aren't positive and summing to 1: SHOULD refuse.
|
||||
/// 5. IF closing to retained earnings and 3999 exists but isn't a
|
||||
/// postable equity account: SHOULD refuse (ControlAccountMismatch).
|
||||
/// 6. IF a journaled MembersCumulative names a start other than the
|
||||
/// one the books derive: SHOULD refuse (InvalidMembers)
|
||||
/// BECAUSE replay would split differently from the journal.
|
||||
/// 7. IF 50/50 members closed −399.23 in January (A −199.62, B
|
||||
/// −199.61), February closed to retained earnings, and March closes
|
||||
/// −399.23 to the members: SHOULD give A −199.61, B −199.62, so
|
||||
/// each has −399.23 (the count skips February's close).
|
||||
/// ```
|
||||
pub fn close_period_to(
|
||||
&mut self,
|
||||
@@ -2223,11 +2247,13 @@ impl Ledger {
|
||||
self.preview_close_period_to(id, to)?;
|
||||
let period = self.periods.get(&id).expect("previewed").clone();
|
||||
let (closings, net) = self.income_closings(period.end);
|
||||
let close_seq = self.next_close_seq();
|
||||
|
||||
// (account, amount to credit; negative debits): the net to its
|
||||
// targets, and each sweep account's balance into its target.
|
||||
let mut equity: Vec<(AccountId, Decimal)> = Vec::new();
|
||||
let mut sweep_lines: Vec<(AccountId, AccountId, Decimal)> = Vec::new();
|
||||
let mut members_closed = None;
|
||||
let mut sweep = |from: AccountId, into: AccountId, this: &Self| {
|
||||
// An equity balance (credit-normal) is −movement; zeroing it
|
||||
// moves exactly that much into the target.
|
||||
@@ -2259,30 +2285,27 @@ impl Ledger {
|
||||
sweep(*a, *into, self);
|
||||
}
|
||||
}
|
||||
periods::CloseTo::Members(members)
|
||||
| periods::CloseTo::MembersPerClose(members)
|
||||
| periods::CloseTo::MembersLastAbsorbs(members) => {
|
||||
let shares: Vec<Decimal> = members.iter().map(|m| m.share).collect();
|
||||
let parts = match to {
|
||||
periods::CloseTo::MembersLastAbsorbs(_) => {
|
||||
periods::allocate_last_absorbs(net, &shares)
|
||||
}
|
||||
periods::CloseTo::MembersPerClose(_) => periods::allocate(net, &shares),
|
||||
_ => {
|
||||
// Cumulative: what the run gives each member with
|
||||
// this close, less what it gave them before.
|
||||
let before = self.member_close_run(&member_key(members));
|
||||
let after = periods::allocate(before + net, &shares);
|
||||
let prior = periods::allocate(before, &shares);
|
||||
after.iter().zip(&prior).map(|(a, p)| a - p).collect()
|
||||
}
|
||||
};
|
||||
for (m, part) in members.iter().zip(parts) {
|
||||
equity.push((m.capital, part));
|
||||
_ => {
|
||||
let members = to.members().expect("every other target is members");
|
||||
// The sweeps first: whether an entry posts decides whether
|
||||
// the parts may carry a true-up. Their lines still follow
|
||||
// the parts in the entry.
|
||||
for m in members {
|
||||
if let Some(d) = m.distributions {
|
||||
sweep(d, m.capital, self);
|
||||
}
|
||||
}
|
||||
let posts = !closings.is_empty() || !sweep_lines.is_empty();
|
||||
let (parts, rule) = self.members_split(to, net, posts, close_seq)?;
|
||||
for (m, part) in members.iter().zip(&parts) {
|
||||
equity.push((m.capital, *part));
|
||||
}
|
||||
members_closed = Some(periods::MembersClosed {
|
||||
members: member_key(members),
|
||||
net,
|
||||
parts,
|
||||
rule,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2314,26 +2337,201 @@ impl Ledger {
|
||||
Some(entry)
|
||||
};
|
||||
|
||||
let close_seq = self
|
||||
.periods
|
||||
.values()
|
||||
.filter(|p| p.status == PeriodStatus::Closed)
|
||||
.count() as u64
|
||||
+ 1;
|
||||
let p = self.periods.get_mut(&id).expect("period exists");
|
||||
p.status = PeriodStatus::Closed;
|
||||
p.closing_entry = closing_entry;
|
||||
p.close_seq = Some(close_seq);
|
||||
p.member_close = match to {
|
||||
periods::CloseTo::Members(members) => Some(periods::MemberCloseRecord {
|
||||
periods::CloseTo::MembersRun(members) => Some(periods::MemberCloseRecord {
|
||||
members: member_key(members),
|
||||
net,
|
||||
}),
|
||||
_ => None,
|
||||
};
|
||||
p.members_closed = members_closed;
|
||||
Ok(closing_entry)
|
||||
}
|
||||
|
||||
/// The number the next close takes ([`Period::close_seq`]): one more
|
||||
/// than the closes so far.
|
||||
fn next_close_seq(&self) -> u64 {
|
||||
self.periods
|
||||
.values()
|
||||
.filter(|p| p.status == PeriodStatus::Closed)
|
||||
.count() as u64
|
||||
+ 1
|
||||
}
|
||||
|
||||
/// What a members close (`to`, closing `net`; `posts` when its entry
|
||||
/// posts; numbered `close_seq`) credits each member, in `to`'s order,
|
||||
/// and the rule it is made under: the rule journaled for replayed
|
||||
/// closes, else the cumulative rule (rules.md B-13): each member's
|
||||
/// split of the count's running total, to the base currency's minor
|
||||
/// unit, less what the count's earlier closes gave them. A close that
|
||||
/// posts nothing credits nothing, so a true-up of earlier rounding
|
||||
/// rides only on an entry that posts (INVARIANT 5 stays true).
|
||||
fn members_split(
|
||||
&self,
|
||||
to: &periods::CloseTo,
|
||||
net: Decimal,
|
||||
posts: bool,
|
||||
close_seq: u64,
|
||||
) -> Result<(Vec<Decimal>, periods::MemberRule), LedgerError> {
|
||||
use periods::{CloseTo, MemberRule};
|
||||
let members = to.members().expect("a members close");
|
||||
let shares: Vec<Decimal> = members.iter().map(|m| m.share).collect();
|
||||
Ok(match to {
|
||||
CloseTo::MembersLastAbsorbs(_) => (
|
||||
periods::allocate_last_absorbs(net, &shares),
|
||||
MemberRule::LastAbsorbs,
|
||||
),
|
||||
CloseTo::MembersPerClose(_) => (periods::allocate(net, &shares), MemberRule::PerClose),
|
||||
CloseTo::MembersRun(_) => {
|
||||
// The run rule: what the run gives each member with this
|
||||
// close, less what it gave them before.
|
||||
let before = self.member_close_run(&member_key(members));
|
||||
let after = periods::allocate(before + net, &shares);
|
||||
let prior = periods::allocate(before, &shares);
|
||||
let parts = after.iter().zip(&prior).map(|(a, p)| a - p).collect();
|
||||
(parts, MemberRule::Run)
|
||||
}
|
||||
_ => {
|
||||
let key = member_key(members);
|
||||
let regime = self.members_regime(&key, None);
|
||||
let since_seq = regime.since_seq.unwrap_or(close_seq);
|
||||
let parts = if posts {
|
||||
regime.allocate(
|
||||
&key,
|
||||
regime.closed + net,
|
||||
®ime.given,
|
||||
self.base_currency.allocation_places(),
|
||||
)?
|
||||
} else {
|
||||
vec![Decimal::ZERO; members.len()]
|
||||
};
|
||||
(parts, MemberRule::Cumulative { since_seq })
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
/// The members regime a close with `members` ((capital account, share)
|
||||
/// pairs) belongs to (rules.md B-13), counting only closes whose
|
||||
/// period ends on or before `through` (all when `None`): walking the
|
||||
/// closes newest first by [`Period::close_seq`], members closes under
|
||||
/// any rule with the same SET of (capital, share) pairs (order and
|
||||
/// distribution accounts ignored; 0.5 is 0.50), skipping closes to
|
||||
/// anything else (they allocate nothing to members), and stopping at
|
||||
/// the first members close with another set. A change of members or
|
||||
/// shares starts a new count (26 U.S.C. §706(d)(1); Treas. Reg.
|
||||
/// §1.706-4(a)(3)(vii): each segment its own distributive share
|
||||
/// period); income closed under the old shares is never re-split.
|
||||
///
|
||||
/// The date filter keeps a prefix of the closes in close order because
|
||||
/// period ends rise with close order: a close is refused while a later
|
||||
/// period is closed (CloseOutOfOrder) or when its end lies in a closed
|
||||
/// period (`preview_close_period_to`), pinned by
|
||||
/// `period_ends_rise_with_close_order`.
|
||||
///
|
||||
/// As of a day no count with `members` holds yet, the earnings not
|
||||
/// closed by then go to the count the next members close begins, when
|
||||
/// it is these members': its first close's order is the regime's
|
||||
/// [`Regime::order`], so their tied units go as they will once it
|
||||
/// counts them, and a reorder of the members in the settings since
|
||||
/// moves no cent on those days either.
|
||||
pub(crate) fn members_regime(
|
||||
&self,
|
||||
members: &[(AccountId, Decimal)],
|
||||
through: Option<NaiveDate>,
|
||||
) -> Regime {
|
||||
let mut regime = self.regime_where(members, |p| through.is_none_or(|d| p.end <= d));
|
||||
if let Some(day) = through
|
||||
&& regime.order.is_empty()
|
||||
{
|
||||
let next = self
|
||||
.periods
|
||||
.values()
|
||||
.filter(|p| p.status == PeriodStatus::Closed && p.end > day)
|
||||
.filter_map(|p| Some((p.close_seq, p.members_closed.as_ref()?)))
|
||||
.min_by_key(|&(seq, _)| seq);
|
||||
if let Some((_, m)) = next
|
||||
&& same_member_set(&m.members, members)
|
||||
{
|
||||
regime.order.clone_from(&m.members);
|
||||
}
|
||||
}
|
||||
regime
|
||||
}
|
||||
|
||||
/// [`Self::members_regime`] over the closed periods `include` keeps.
|
||||
fn regime_where(
|
||||
&self,
|
||||
members: &[(AccountId, Decimal)],
|
||||
include: impl Fn(&Period) -> bool,
|
||||
) -> Regime {
|
||||
let mut closed: Vec<&Period> = self
|
||||
.periods
|
||||
.values()
|
||||
.filter(|p| p.status == PeriodStatus::Closed && include(p))
|
||||
.collect();
|
||||
closed.sort_by_key(|p| std::cmp::Reverse(p.close_seq));
|
||||
let mut regime = Regime {
|
||||
since_seq: None,
|
||||
closed: Decimal::ZERO,
|
||||
given: vec![Decimal::ZERO; members.len()],
|
||||
order: Vec::new(),
|
||||
};
|
||||
for p in closed {
|
||||
let Some(m) = &p.members_closed else {
|
||||
continue;
|
||||
};
|
||||
if !same_member_set(&m.members, members) {
|
||||
break;
|
||||
}
|
||||
regime.since_seq = p.close_seq;
|
||||
regime.order.clone_from(&m.members);
|
||||
regime.closed += m.net;
|
||||
for ((capital, _), part) in m.members.iter().zip(&m.parts) {
|
||||
if let Some(i) = members.iter().position(|(c, _)| c == capital) {
|
||||
regime.given[i] += *part;
|
||||
}
|
||||
}
|
||||
}
|
||||
regime
|
||||
}
|
||||
|
||||
/// A closed members period's split under the cumulative rule
|
||||
/// (books.close_period's `members`): the close its count starts at,
|
||||
/// and per member, in the close's order, what this close credited
|
||||
/// them, their running total from the count's closes through it, and
|
||||
/// their exact share of everything the count closed through it. `None`
|
||||
/// for a period not closed to members, or closed under an earlier
|
||||
/// rule.
|
||||
pub fn member_close_split(&self, id: PeriodId) -> Option<(u64, Vec<periods::MemberSplit>)> {
|
||||
let p = self.periods.get(&id)?;
|
||||
let m = p.members_closed.as_ref()?;
|
||||
let periods::MemberRule::Cumulative { since_seq } = m.rule else {
|
||||
return None;
|
||||
};
|
||||
let seq = p.close_seq?;
|
||||
let regime = self.regime_where(&m.members, |q| q.close_seq <= Some(seq));
|
||||
let split = m
|
||||
.members
|
||||
.iter()
|
||||
.zip(&m.parts)
|
||||
.zip(®ime.given)
|
||||
.map(
|
||||
|((&(capital, share), &part), &running)| periods::MemberSplit {
|
||||
capital,
|
||||
share,
|
||||
part,
|
||||
running,
|
||||
exact: share * regime.closed,
|
||||
},
|
||||
)
|
||||
.collect();
|
||||
Some((since_seq, split))
|
||||
}
|
||||
|
||||
/// The net closed by the run of cumulative members closes, with exactly
|
||||
/// these members and shares, that ends with the latest close: zero if
|
||||
/// the latest close was anything else (or there is none).
|
||||
@@ -2408,9 +2606,13 @@ impl Ledger {
|
||||
accounts.push(*into);
|
||||
accounts.extend(sweep);
|
||||
}
|
||||
periods::CloseTo::Members(members)
|
||||
| periods::CloseTo::MembersPerClose(members)
|
||||
| periods::CloseTo::MembersLastAbsorbs(members) => {
|
||||
_ => {
|
||||
let members = to.members().expect("every other target is members");
|
||||
if let periods::CloseTo::MembersCumulative { since_seq: 0, .. } = to {
|
||||
return Err(LedgerError::InvalidMembers(
|
||||
"a count of members closes starts at close 1 or later, not 0".into(),
|
||||
));
|
||||
}
|
||||
let total: Decimal = members.iter().map(|m| m.share).sum();
|
||||
if members.is_empty()
|
||||
|| total != Decimal::ONE
|
||||
@@ -2467,7 +2669,9 @@ impl Ledger {
|
||||
/// [`Self::close_period_to`] would succeed — the period exists and is
|
||||
/// open, no draft entries are dated inside it, no later period is
|
||||
/// closed, the closing date isn't inside another closed period, and the
|
||||
/// target is valid — and returns the id the closing entry will receive,
|
||||
/// target is valid (for members: their split computes exactly, and a
|
||||
/// journaled start is the one the books derive) — and returns the id
|
||||
/// the closing entry will receive,
|
||||
/// or `None` when the close posts no entry. Stores call this before
|
||||
/// journaling so a failed close leaves no journal line behind.
|
||||
pub fn preview_close_period_to(
|
||||
@@ -2510,20 +2714,36 @@ impl Ledger {
|
||||
// An entry posts exactly when there is income to close or a sweep
|
||||
// account with a balance. Account creation (Retained Earnings) does
|
||||
// not consume entry ids.
|
||||
let (closings, _) = self.income_closings(period.end);
|
||||
let (closings, net) = self.income_closings(period.end);
|
||||
let sweeps: Vec<AccountId> = match to {
|
||||
periods::CloseTo::RetainedEarnings { sweep }
|
||||
| periods::CloseTo::Account { sweep, .. } => sweep.clone(),
|
||||
periods::CloseTo::Members(members)
|
||||
| periods::CloseTo::MembersPerClose(members)
|
||||
| periods::CloseTo::MembersLastAbsorbs(members) => {
|
||||
members.iter().filter_map(|m| m.distributions).collect()
|
||||
}
|
||||
_ => to
|
||||
.members()
|
||||
.expect("every other target is members")
|
||||
.iter()
|
||||
.filter_map(|m| m.distributions)
|
||||
.collect(),
|
||||
};
|
||||
let any = !closings.is_empty()
|
||||
|| sweeps
|
||||
.iter()
|
||||
.any(|a| !self.movement_through(*a, period.end).is_zero());
|
||||
if to.members().is_some() {
|
||||
// The journaled start must be the derived one, and the split
|
||||
// must be computable: a close this refuses journals nothing.
|
||||
let (_, rule) = self.members_split(to, net, any, self.next_close_seq())?;
|
||||
if let (
|
||||
periods::CloseTo::MembersCumulative { since_seq, .. },
|
||||
periods::MemberRule::Cumulative { since_seq: derived },
|
||||
) = (to, rule)
|
||||
&& *since_seq != derived
|
||||
{
|
||||
return Err(LedgerError::InvalidMembers(format!(
|
||||
"this close was journaled counting the members' closes from close {since_seq}, but the books count them from close {derived}"
|
||||
)));
|
||||
}
|
||||
}
|
||||
Ok(any.then_some(self.next_entry_id))
|
||||
}
|
||||
|
||||
@@ -5267,7 +5487,80 @@ fn check_line_size(amount: Decimal) -> Result<(), LedgerError> {
|
||||
}
|
||||
|
||||
/// A members close's (capital account, share) list, in order: what a run
|
||||
/// of cumulative closes must share.
|
||||
/// of run-rule closes must share in that order, and a count of cumulative
|
||||
/// closes as a set ([`same_member_set`]).
|
||||
fn member_key(members: &[periods::MemberClose]) -> Vec<(AccountId, Decimal)> {
|
||||
members.iter().map(|m| (m.capital, m.share)).collect()
|
||||
}
|
||||
|
||||
/// Two members closes' (capital account, share) lists name the same
|
||||
/// members with the same shares, in any order (rules.md B-13: a reorder of
|
||||
/// the members is not a change of members). Shares compare by value, so
|
||||
/// 0.5 is 0.50.
|
||||
fn same_member_set(a: &[(AccountId, Decimal)], b: &[(AccountId, Decimal)]) -> bool {
|
||||
let sorted = |v: &[(AccountId, Decimal)]| {
|
||||
let mut v = v.to_vec();
|
||||
v.sort_unstable();
|
||||
v
|
||||
};
|
||||
a.len() == b.len() && sorted(a) == sorted(b)
|
||||
}
|
||||
|
||||
/// A members regime ([`Ledger::members_regime`]): the members closes with
|
||||
/// one set of members and shares since the last close with another.
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub(crate) struct Regime {
|
||||
/// The close the count starts at (its [`Period::close_seq`]); `None`
|
||||
/// when no members close with this set is counted yet.
|
||||
pub since_seq: Option<u64>,
|
||||
/// The net those closes closed, together (negative for a loss).
|
||||
pub closed: Decimal,
|
||||
/// What those closes credited each member, in the order asked for, by
|
||||
/// capital account.
|
||||
pub given: Vec<Decimal>,
|
||||
/// The members as the count's first close listed them: the order its
|
||||
/// tied odd units go in. As of a day before any close of a count, the
|
||||
/// count the next members close begins, when it is these members'
|
||||
/// ([`Ledger::members_regime`]). Empty when there is none.
|
||||
pub order: Vec<(AccountId, Decimal)>,
|
||||
}
|
||||
|
||||
impl Regime {
|
||||
/// [`periods::allocate_cumulative`] of `total` among `members` against
|
||||
/// `given` (both in `members`' order), the tied odd units to the member
|
||||
/// the count's first close listed first ([`Regime::order`]; `members`'
|
||||
/// own order when no close has begun the count), the parts back in `members`'
|
||||
/// order. So a count's ties go to the same member whatever order later
|
||||
/// closes, statements or the settings list the members in, and a
|
||||
/// reorder of the members moves no cent (rules.md B-13, D-38).
|
||||
pub(crate) fn allocate(
|
||||
&self,
|
||||
members: &[(AccountId, Decimal)],
|
||||
total: Decimal,
|
||||
given: &[Decimal],
|
||||
places: u32,
|
||||
) -> Result<Vec<Decimal>, LedgerError> {
|
||||
let order = if self.order.is_empty() {
|
||||
members
|
||||
} else {
|
||||
&self.order
|
||||
};
|
||||
let index: Vec<usize> = order
|
||||
.iter()
|
||||
.map(|(capital, _)| {
|
||||
members
|
||||
.iter()
|
||||
.position(|(c, _)| c == capital)
|
||||
.expect("a count's members are the members asked for")
|
||||
})
|
||||
.collect();
|
||||
let shares: Vec<Decimal> = index.iter().map(|&i| members[i].1).collect();
|
||||
let given: Vec<Decimal> = index.iter().map(|&i| given[i]).collect();
|
||||
let split = periods::allocate_cumulative(total, &shares, &given, places)?;
|
||||
let mut parts = vec![Decimal::ZERO; members.len()];
|
||||
for (&i, part) in index.iter().zip(split) {
|
||||
parts[i] = part;
|
||||
}
|
||||
Ok(parts)
|
||||
}
|
||||
}
|
||||
@@ -69,7 +69,8 @@ pub use parties::{Party, PartyField, PartyId, Role, TinFacts, TinType, W4Facts,
|
||||
pub use payouts::{Payout, PayoutAdjustment, PayoutLine, PayoutTotals, payout_plan};
|
||||
pub use payroll::{PayrollLine, PayrollRun, PayrollRunId};
|
||||
pub use periods::{
|
||||
CloseTo, MemberClose, MemberCloseRecord, Period, PeriodId, PeriodStatus, RETAINED_EARNINGS_CODE,
|
||||
CloseTo, MemberClose, MemberCloseRecord, MemberRule, MemberSplit, MembersClosed, Period,
|
||||
PeriodId, PeriodStatus, RETAINED_EARNINGS_CODE,
|
||||
};
|
||||
pub use reports::{
|
||||
AssetRegister, AssetRegisterRow, BalanceSheet, BalanceSheetStatement, CashFlowLine,
|
||||
|
||||
@@ -57,6 +57,18 @@ impl Currency {
|
||||
}
|
||||
}
|
||||
|
||||
/// The decimal places a share of an amount in this currency is split
|
||||
/// to (members' shares of earnings, periods::allocate_cumulative): its
|
||||
/// minor units (cents, whole yen, thousandths of a dinar), or, for a
|
||||
/// code ISO 4217 gives none (gold, SDRs), the most a line may carry
|
||||
/// (kernel::MAX_LINE_SCALE). Every posted amount is then a whole number
|
||||
/// of the unit split to, so no member's running share is ever a unit
|
||||
/// from exact (rules.md B-13); split to cents, a finer amount's
|
||||
/// leftover fraction would all go to one member.
|
||||
pub fn allocation_places(&self) -> u32 {
|
||||
self.minor_units().unwrap_or(crate::kernel::MAX_LINE_SCALE)
|
||||
}
|
||||
|
||||
/// The three-letter code, e.g. `"USD"`.
|
||||
pub fn code(&self) -> &str {
|
||||
std::str::from_utf8(&self.0).expect("currency codes are ASCII by construction")
|
||||
|
||||
@@ -51,11 +51,30 @@ pub enum CloseTo {
|
||||
/// Members' capital accounts by share (shares sum to 1): the
|
||||
/// partnership convention, where each member's capital account carries
|
||||
/// their allocated earnings and absorbs their distributions. Odd cents
|
||||
/// go by largest remainder ([`allocate`]), cumulatively: a run of
|
||||
/// member closes with the same members and shares gives each member,
|
||||
/// in total, their share of the run's total to the cent, so rounding
|
||||
/// never piles up close after close (rules.md B-13; since 2026-10-13).
|
||||
/// go by largest remainder, cumulatively from the first members close
|
||||
/// with these members and shares ([`allocate_cumulative`]): each close
|
||||
/// gives a member their share of everything closed to the members so
|
||||
/// far, to the currency's minor unit, less what earlier closes gave
|
||||
/// them, so no member is ever a minor unit from their exact share
|
||||
/// (rules.md B-13; D-10). What new closes are made with; the books
|
||||
/// journal it as [`CloseTo::MembersCumulative`].
|
||||
Members(Vec<MemberClose>),
|
||||
/// [`CloseTo::Members`] as journaled: the count of members closes
|
||||
/// starts at the close numbered `since_seq` (its
|
||||
/// [`Period::close_seq`]). Replay produces it and checks the start is
|
||||
/// the one the books derive.
|
||||
MembersCumulative {
|
||||
/// The members and shares.
|
||||
members: Vec<MemberClose>,
|
||||
/// The close the count starts at: the first members close with
|
||||
/// these members and shares since the last with others.
|
||||
since_seq: u64,
|
||||
},
|
||||
/// The run rule of 2026-10-13 to P3 (journaled `"cumulative"`): a
|
||||
/// back-to-back run of members closes with the same members and shares
|
||||
/// in the same order splits cumulatively, and any other close ends the
|
||||
/// run. Only replay of those journals produces it.
|
||||
MembersRun(Vec<MemberClose>),
|
||||
/// [`CloseTo::Members`] rounding each close on its own (largest
|
||||
/// remainder), the rule for closes journaled on 2026-10-13 before the
|
||||
/// cumulative one. Only replay of those journals produces it.
|
||||
@@ -66,6 +85,21 @@ pub enum CloseTo {
|
||||
MembersLastAbsorbs(Vec<MemberClose>),
|
||||
}
|
||||
|
||||
impl CloseTo {
|
||||
/// The members a close splits the net among, under whichever rule;
|
||||
/// `None` for a close to retained earnings or one account.
|
||||
pub fn members(&self) -> Option<&[MemberClose]> {
|
||||
match self {
|
||||
CloseTo::RetainedEarnings { .. } | CloseTo::Account { .. } => None,
|
||||
CloseTo::Members(members)
|
||||
| CloseTo::MembersCumulative { members, .. }
|
||||
| CloseTo::MembersRun(members)
|
||||
| CloseTo::MembersPerClose(members)
|
||||
| CloseTo::MembersLastAbsorbs(members) => Some(members),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// One member in a partnership close.
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub struct MemberClose {
|
||||
@@ -84,6 +118,8 @@ pub struct MemberClose {
|
||||
/// ties to the earlier member ([`crate::kernel::apportion`]). The parts
|
||||
/// always sum to `amount` exactly, and no member is favored by position
|
||||
/// (owner, 2026-10-13). A loss splits as the negation of the same gain.
|
||||
/// Closes journaled under the rules of 2026-10-13 replay with it, in cents
|
||||
/// whatever the currency; new closes split with [`allocate_cumulative`].
|
||||
pub fn allocate(
|
||||
amount: rust_decimal::Decimal,
|
||||
shares: &[rust_decimal::Decimal],
|
||||
@@ -122,6 +158,104 @@ pub fn allocate_last_absorbs(
|
||||
parts
|
||||
}
|
||||
|
||||
/// What one members close gives each member: [`allocate`]'s split of the
|
||||
/// running `total` (everything closed to these members and shares,
|
||||
/// this close included), to `places` decimal places
|
||||
/// ([`crate::Currency::allocation_places`]) instead of cents, less what
|
||||
/// earlier closes gave each member (`given`, in the order of `shares`).
|
||||
/// Each member's running total is then one split of the running total
|
||||
/// (rules.md B-13; [`crate::kernel::apportion_cumulative`]). A loss is
|
||||
/// the negation of the same gain: it splits |total| against `given`
|
||||
/// negated, then negates.
|
||||
///
|
||||
/// ```text
|
||||
/// GOAL: no member's running share of earnings is ever a minor unit from
|
||||
/// their exact share, whatever earlier closes gave them.
|
||||
/// SOURCE: docs/spec/rules.md B-13 (26 U.S.C. §704(a); Treas. Reg.
|
||||
/// §1.704-1(b)(2)(iv)(b); largest remainder: DECISION, owner
|
||||
/// 2026-10-13; from the first close: D-10); B-3 (the minor unit).
|
||||
/// INVARIANTS:
|
||||
/// 1. The parts MUST sum to total − Σ given exactly.
|
||||
/// 2. given[i] + part[i] MUST be member i's largest-remainder split of
|
||||
/// total to `places` (ties to the earlier member): one split.
|
||||
/// 3. So when total is a whole number of units of `places` (every posted
|
||||
/// line is at its currency's allocation_places: its minor unit, B-3,
|
||||
/// or for a currency without one the finest a line carries),
|
||||
/// |given[i] + part[i] − total × share[i]| is under one unit (Lean
|
||||
/// allocW_drift, cumulative_drift; Kani
|
||||
/// cumulative_close_never_drifts_a_unit_at_*).
|
||||
/// 4. NEVER rounds a product: if |total| × share isn't exact in Decimal
|
||||
/// (a share of many decimal places times a large total), refuse
|
||||
/// (TooLargeToCompute): a rounded product could rank the remainders
|
||||
/// wrong, and the bound above assumes exact ones (P3 review R-5).
|
||||
/// SCENARIOS:
|
||||
/// 1. IF 101 yen splits 0.5 / 0.25 / 0.25 with nothing given: SHOULD be
|
||||
/// 51 / 25 / 25 (exact 50.5 / 25.25 / 25.25; the yen to the largest
|
||||
/// remainder), never fractions of a yen.
|
||||
/// 2. IF 0.03 was given as A 0.03 / B 0.00 (50/50) and the total is now
|
||||
/// 0.04: SHOULD be A −0.01, B +0.02.
|
||||
/// 3. IF the total is a loss: SHOULD be the negation of the same gain
|
||||
/// against the negated given.
|
||||
/// 4. IF the shares and given don't pair up, or places exceeds what a
|
||||
/// line may carry: SHOULD refuse.
|
||||
/// ```
|
||||
pub fn allocate_cumulative(
|
||||
total: rust_decimal::Decimal,
|
||||
shares: &[rust_decimal::Decimal],
|
||||
given: &[rust_decimal::Decimal],
|
||||
places: u32,
|
||||
) -> Result<Vec<rust_decimal::Decimal>, LedgerError> {
|
||||
use rust_decimal::{Decimal, RoundingStrategy::ToZero};
|
||||
if shares.len() != given.len() {
|
||||
return Err(LedgerError::InvalidMembers(format!(
|
||||
"{} shares but {} earlier parts",
|
||||
shares.len(),
|
||||
given.len()
|
||||
)));
|
||||
}
|
||||
if places > crate::kernel::MAX_LINE_SCALE {
|
||||
return Err(LedgerError::TooLargeToCompute(format!(
|
||||
"a split to {places} decimal places"
|
||||
)));
|
||||
}
|
||||
let loss = total < Decimal::ZERO;
|
||||
let magnitude = total.abs().normalize();
|
||||
let given: Vec<Decimal> = given.iter().map(|&g| if loss { -g } else { g }).collect();
|
||||
let mut parts = Vec::with_capacity(shares.len());
|
||||
let mut remainders = Vec::with_capacity(shares.len());
|
||||
for share in shares {
|
||||
let share = share.normalize();
|
||||
// Exact exactly when Decimal kept every digit: a product it had to
|
||||
// round comes back with fewer places than its factors carry (a
|
||||
// zero product, which it returns without places, is exact).
|
||||
let exact = magnitude
|
||||
.checked_mul(share)
|
||||
.filter(|e| e.is_zero() || e.scale() == magnitude.scale() + share.scale())
|
||||
.ok_or_else(|| {
|
||||
LedgerError::TooLargeToCompute(format!(
|
||||
"a member's share {share} of {magnitude}, to its last digit,"
|
||||
))
|
||||
})?;
|
||||
let floor = exact.round_dp_with_strategy(places, ToZero);
|
||||
parts.push(floor);
|
||||
remainders.push(exact - floor);
|
||||
}
|
||||
crate::kernel::apportion_cumulative(
|
||||
magnitude,
|
||||
&mut parts,
|
||||
&remainders,
|
||||
Decimal::new(1, places),
|
||||
&given,
|
||||
)
|
||||
.ok_or_else(|| LedgerError::TooLargeToCompute(format!("the members' shares of {total}")))?;
|
||||
if loss {
|
||||
for p in parts.iter_mut().filter(|p| !p.is_zero()) {
|
||||
*p = -*p;
|
||||
}
|
||||
}
|
||||
Ok(parts)
|
||||
}
|
||||
|
||||
/// Code of the Retained Earnings account that closing entries credit (or
|
||||
/// debit), created on the first close if the chart lacks it.
|
||||
pub const RETAINED_EARNINGS_CODE: &str = "3999";
|
||||
@@ -145,13 +279,23 @@ pub struct Period {
|
||||
/// closed.
|
||||
#[serde(default)]
|
||||
pub close_seq: Option<u64>,
|
||||
/// What a cumulative members close allocated, once closed that way.
|
||||
/// What a close under the run rule ([`CloseTo::MembersRun`], journaled
|
||||
/// `"cumulative"`) allocated, once closed that way. Only those closes
|
||||
/// have one: its serialized form is what earlier versions wrote, and
|
||||
/// stays so (rules.md B-28). Every members close's split, under any
|
||||
/// rule, is [`Period::members_closed`].
|
||||
#[serde(default)]
|
||||
pub member_close: Option<MemberCloseRecord>,
|
||||
/// What a members close (any rule) split, once closed to the members.
|
||||
/// Derived on replay; never serialized, so a period reads as earlier
|
||||
/// versions wrote it.
|
||||
#[serde(skip)]
|
||||
pub members_closed: Option<MembersClosed>,
|
||||
}
|
||||
|
||||
/// A cumulative members close ([`CloseTo::Members`]): who shared, and the
|
||||
/// net it closed. The next close with the same members continues the run.
|
||||
/// A run-rule members close ([`CloseTo::MembersRun`]): who shared, and the
|
||||
/// net it closed. The next run-rule close with the same members continues
|
||||
/// the run.
|
||||
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]
|
||||
pub struct MemberCloseRecord {
|
||||
/// (capital account, share), in the order given.
|
||||
@@ -160,6 +304,63 @@ pub struct MemberCloseRecord {
|
||||
pub net: rust_decimal::Decimal,
|
||||
}
|
||||
|
||||
/// The rounding rule a members close was made under, as journaled.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub enum MemberRule {
|
||||
/// The last member absorbed the rounding (closes before 2026-10-13;
|
||||
/// [`CloseTo::MembersLastAbsorbs`]).
|
||||
LastAbsorbs,
|
||||
/// Largest remainder, each close on its own (2026-10-13;
|
||||
/// [`CloseTo::MembersPerClose`]).
|
||||
PerClose,
|
||||
/// Cumulative over a back-to-back run (2026-10-13 to P3;
|
||||
/// [`CloseTo::MembersRun`]).
|
||||
Run,
|
||||
/// Cumulative from the members close numbered `since_seq`
|
||||
/// ([`CloseTo::Members`], [`CloseTo::MembersCumulative`]).
|
||||
Cumulative {
|
||||
/// The close the count starts at.
|
||||
since_seq: u64,
|
||||
},
|
||||
}
|
||||
|
||||
/// A members close's split, under any rule ([`Period::members_closed`]).
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub struct MembersClosed {
|
||||
/// (capital account, share), in the close's order.
|
||||
pub members: Vec<(crate::AccountId, rust_decimal::Decimal)>,
|
||||
/// The net income this close allocated (negative for a loss).
|
||||
pub net: rust_decimal::Decimal,
|
||||
/// The income it credited each member's capital account (debited for
|
||||
/// a negative part), in the same order: their share of `net` plus any
|
||||
/// true-up. All zero when the close posted no entry.
|
||||
pub parts: Vec<rust_decimal::Decimal>,
|
||||
/// The rule it was made under.
|
||||
pub rule: MemberRule,
|
||||
}
|
||||
|
||||
/// One member's part of a close under the cumulative rule
|
||||
/// ([`crate::Ledger::member_close_split`]).
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub struct MemberSplit {
|
||||
/// The member's capital account.
|
||||
pub capital: crate::AccountId,
|
||||
/// Their share.
|
||||
pub share: rust_decimal::Decimal,
|
||||
/// What this close credited them (negative: debited), any true-up of
|
||||
/// earlier rounding included.
|
||||
pub part: rust_decimal::Decimal,
|
||||
/// What the count's closes have credited them in all, this one
|
||||
/// included.
|
||||
pub running: rust_decimal::Decimal,
|
||||
/// Their exact share of everything the count has closed, this close
|
||||
/// included (share × total, unrounded): `running` is within one minor
|
||||
/// unit of it once a close of the count has posted under the
|
||||
/// cumulative rule (until then it still carries the rounding of closes
|
||||
/// made under an earlier rule, which that close trues up).
|
||||
pub exact: rust_decimal::Decimal,
|
||||
}
|
||||
|
||||
impl Period {
|
||||
/// True when `date` falls inside the period (inclusive).
|
||||
pub fn contains(&self, date: NaiveDate) -> bool {
|
||||
@@ -193,6 +394,101 @@ pub fn validate_period_order(start: NaiveDate, end: NaiveDate) -> Result<(), Led
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::{Currency, Ledger};
|
||||
use rust_decimal::Decimal;
|
||||
|
||||
fn dec(s: &str) -> Decimal {
|
||||
s.parse().unwrap()
|
||||
}
|
||||
|
||||
fn decs(v: &[&str]) -> Vec<Decimal> {
|
||||
v.iter().map(|s| dec(s)).collect()
|
||||
}
|
||||
|
||||
/// allocate_cumulative scenario 1: 101 yen at 0.5 / 0.25 / 0.25 is
|
||||
/// exactly 50.5 / 25.25 / 25.25; floors 50 / 25 / 25 leave one yen,
|
||||
/// which goes to the largest remainder (0.5): 51 / 25 / 25. Never a
|
||||
/// fraction of a yen. A dinar splits to thousandths.
|
||||
#[test]
|
||||
fn allocate_cumulative_splits_to_the_currencys_minor_unit() {
|
||||
let shares = decs(&["0.5", "0.25", "0.25"]);
|
||||
let none = decs(&["0", "0", "0"]);
|
||||
assert_eq!(
|
||||
allocate_cumulative(dec("101"), &shares, &none, 0).unwrap(),
|
||||
decs(&["51", "25", "25"])
|
||||
);
|
||||
// KWD: 0.101 at 50/50 is 0.0505 each; floors 0.050 / 0.050 and
|
||||
// the tied thousandth to the first.
|
||||
let half = decs(&["0.5", "0.5"]);
|
||||
assert_eq!(
|
||||
allocate_cumulative(dec("0.101"), &half, &decs(&["0", "0"]), 3).unwrap(),
|
||||
decs(&["0.051", "0.050"])
|
||||
);
|
||||
// Cents, as allocate splits them.
|
||||
assert_eq!(
|
||||
allocate_cumulative(dec("0.07"), &half, &decs(&["0", "0"]), 2).unwrap(),
|
||||
allocate(dec("0.07"), &half)
|
||||
);
|
||||
}
|
||||
|
||||
/// allocate_cumulative scenarios 2 and 3: three cents given A 0.03 /
|
||||
/// B 0.00 and a running 0.04 give A −0.01, B +0.02; a loss of the
|
||||
/// same is the negation against the negated given.
|
||||
#[test]
|
||||
fn allocate_cumulative_trues_up_and_mirrors_a_loss() {
|
||||
let half = decs(&["0.5", "0.5"]);
|
||||
assert_eq!(
|
||||
allocate_cumulative(dec("0.04"), &half, &decs(&["0.03", "0"]), 2).unwrap(),
|
||||
decs(&["-0.01", "0.02"])
|
||||
);
|
||||
assert_eq!(
|
||||
allocate_cumulative(dec("-0.04"), &half, &decs(&["-0.03", "0"]), 2).unwrap(),
|
||||
decs(&["0.01", "-0.02"])
|
||||
);
|
||||
// Given that sums to the earlier total: the parts sum to the new
|
||||
// amount (0.05 − 0.03 = 0.02); running totals 0.03 / 0.02.
|
||||
let parts = allocate_cumulative(dec("0.05"), &half, &decs(&["0.01", "0.02"]), 2).unwrap();
|
||||
assert_eq!(parts, decs(&["0.02", "0.00"]));
|
||||
assert_eq!(parts.iter().sum::<Decimal>(), dec("0.02"));
|
||||
}
|
||||
|
||||
/// allocate_cumulative INVARIANT 4 (P3 review R-5): a share of 28
|
||||
/// decimal places times 100.00 has 30, more than Decimal keeps, so the
|
||||
/// product would be rounded: refused, never split from a rounded
|
||||
/// product. Four places (33.33%) are exact at any line amount.
|
||||
#[test]
|
||||
fn allocate_cumulative_refuses_a_product_it_cannot_compute_exactly() {
|
||||
let third = dec("0.3333333333333333333333333333");
|
||||
let last = Decimal::ONE - third - third;
|
||||
let shares = [third, third, last];
|
||||
let none = decs(&["0", "0", "0"]);
|
||||
assert!(matches!(
|
||||
allocate_cumulative(dec("100.00"), &shares, &none, 2),
|
||||
Err(LedgerError::TooLargeToCompute(_))
|
||||
));
|
||||
let shares = decs(&["0.3333", "0.3333", "0.3334"]);
|
||||
let max = crate::kernel::MAX_LINE_AMOUNT - dec("0.01");
|
||||
let parts = allocate_cumulative(max, &shares, &none, 2).unwrap();
|
||||
assert_eq!(parts.iter().sum::<Decimal>(), max);
|
||||
// Trailing zeros are not digits: "0.50" and "0.5" split alike.
|
||||
assert_eq!(
|
||||
allocate_cumulative(dec("0.01"), &decs(&["0.50", "0.50"]), &decs(&["0", "0"]), 2)
|
||||
.unwrap(),
|
||||
decs(&["0.01", "0.00"])
|
||||
);
|
||||
}
|
||||
|
||||
/// allocate_cumulative scenario 4.
|
||||
#[test]
|
||||
fn allocate_cumulative_refuses_unpaired_inputs() {
|
||||
assert!(matches!(
|
||||
allocate_cumulative(dec("1"), &decs(&["1"]), &[], 2),
|
||||
Err(LedgerError::InvalidMembers(_))
|
||||
));
|
||||
assert!(matches!(
|
||||
allocate_cumulative(dec("1"), &decs(&["1"]), &decs(&["0"]), 11),
|
||||
Err(LedgerError::TooLargeToCompute(_))
|
||||
));
|
||||
}
|
||||
|
||||
fn period_ledger() -> Ledger {
|
||||
let mut l = Ledger::new(Currency::USD);
|
||||
|
||||
@@ -543,6 +543,176 @@ fn apportion_is_fair() {
|
||||
);
|
||||
}
|
||||
|
||||
/// `kernel::apportion_cumulative` is `kernel::apportion` less what was
|
||||
/// given: it succeeds exactly when `apportion` would on the same inputs and
|
||||
/// the running total is in line range (the parts, unit and `given` are,
|
||||
/// here), and then each part is apportion's less its `given`, so each
|
||||
/// running total (given + part) is apportion's. With `apportion_is_exact`,
|
||||
/// the parts sum to the running total less what was given, and each
|
||||
/// running total is nothing or one unit above its rounded-down part (one
|
||||
/// of them may also take the sub-unit fraction). A refusal leaves the
|
||||
/// parts as they were.
|
||||
#[kani::proof]
|
||||
#[kani::unwind(5)]
|
||||
fn apportion_cumulative_is_exact() {
|
||||
let (n, before, rems, unit, _, whole) = any_apportionment();
|
||||
let given: [i64; AP] = core::array::from_fn(|_| any_amount());
|
||||
let mut reference = before;
|
||||
let apportioned = kernel::apportion(whole, &mut reference[..n], &rems[..n], unit);
|
||||
let mut parts = before;
|
||||
let ok = kernel::apportion_cumulative(whole, &mut parts[..n], &rems[..n], unit, &given[..n]);
|
||||
assert_eq!(ok.is_some(), apportioned.is_some() && whole.in_line_range());
|
||||
let mut against = false;
|
||||
for i in 0..n {
|
||||
if ok.is_some() {
|
||||
assert_eq!(parts[i] + given[i], reference[i]);
|
||||
against |= parts[i] < 0;
|
||||
} else {
|
||||
assert_eq!(parts[i], before[i]);
|
||||
}
|
||||
}
|
||||
kani::cover!(
|
||||
ok.is_some() && n == AP && against && whole > given[0] + given[1] + given[2],
|
||||
"a true-up against the net's sign"
|
||||
);
|
||||
}
|
||||
|
||||
/// Bound on the amounts the drift harnesses explore (symbolic multiply and
|
||||
/// divide are costly for the solver; Lean's `allocW_drift` and
|
||||
/// `cumulative_drift` carry any weights and any amounts).
|
||||
const DRIFT: i64 = 1 << 12;
|
||||
|
||||
/// **Bounded drift** (rules.md B-13) for members with these integer
|
||||
/// weights (shares w_i / W, W their sum): a cumulative members close,
|
||||
/// modelled as `periods::allocate_cumulative` runs it at whole minor units
|
||||
/// (unit 1), hands out exactly its net, and leaves every member's running
|
||||
/// total, given + part, within one unit of their exact share of
|
||||
/// everything closed, whatever earlier closes gave them (any `given`
|
||||
/// summing to what they closed: legacy rounding included). Scaled by W:
|
||||
/// |(given_i + part_i)·W − T·w_i| < W.
|
||||
fn drift_is_under_a_unit(weights: &[i64]) {
|
||||
let n = weights.len();
|
||||
let mut total_weight = 0i64;
|
||||
for w in weights {
|
||||
total_weight += w;
|
||||
}
|
||||
let closed: i64 = kani::any();
|
||||
let net: i64 = kani::any();
|
||||
kani::assume((-DRIFT..=DRIFT).contains(&closed) && (-DRIFT..=DRIFT).contains(&net));
|
||||
// What earlier closes gave: anything, as long as it adds up.
|
||||
let mut given = [0i64; AP];
|
||||
let mut others = 0i64;
|
||||
for g in given.iter_mut().take(n - 1) {
|
||||
*g = kani::any();
|
||||
kani::assume((-DRIFT..=DRIFT).contains(g));
|
||||
others += *g;
|
||||
}
|
||||
given[n - 1] = closed - others;
|
||||
// allocate_cumulative: |T| split by exact share, rounded toward zero,
|
||||
// the remainders ranked; a loss runs on |T| against given negated.
|
||||
let total = closed + net;
|
||||
let magnitude = total.abs();
|
||||
let loss = total < 0;
|
||||
let mut parts = [0i64; AP];
|
||||
let mut rems = [0i64; AP];
|
||||
let mut against = [0i64; AP];
|
||||
for i in 0..n {
|
||||
parts[i] = magnitude * weights[i] / total_weight;
|
||||
rems[i] = magnitude * weights[i] % total_weight;
|
||||
against[i] = if loss { -given[i] } else { given[i] };
|
||||
}
|
||||
kernel::apportion_cumulative(magnitude, &mut parts[..n], &rems[..n], 1, &against[..n])
|
||||
.expect("exact shares are fewer units short than there are members");
|
||||
let mut sum = 0i64;
|
||||
for i in 0..n {
|
||||
if loss {
|
||||
parts[i] = -parts[i];
|
||||
}
|
||||
sum += parts[i];
|
||||
let drift = (given[i] + parts[i]) * total_weight - total * weights[i];
|
||||
assert!(-total_weight < drift && drift < total_weight);
|
||||
}
|
||||
assert_eq!(sum, net);
|
||||
kani::cover!(
|
||||
loss && given[0] * total_weight != closed * weights[0],
|
||||
"a loss, after an earlier split off the exact share"
|
||||
);
|
||||
}
|
||||
|
||||
/// [`drift_is_under_a_unit`] for two members at half each: every odd cent
|
||||
/// a tie.
|
||||
#[kani::proof]
|
||||
#[kani::unwind(4)]
|
||||
fn cumulative_close_never_drifts_a_unit_at_halves() {
|
||||
drift_is_under_a_unit(&[1, 1]);
|
||||
}
|
||||
|
||||
/// [`drift_is_under_a_unit`] for three members at 20% / 30% / 50%:
|
||||
/// remainders that differ.
|
||||
#[kani::proof]
|
||||
#[kani::unwind(4)]
|
||||
fn cumulative_close_never_drifts_a_unit_at_tenths() {
|
||||
drift_is_under_a_unit(&[2, 3, 5]);
|
||||
}
|
||||
|
||||
/// [`drift_is_under_a_unit`] for three members at 33.33% / 33.33% /
|
||||
/// 33.34%, the nearest decimal shares to a third each.
|
||||
#[kani::proof]
|
||||
#[kani::unwind(4)]
|
||||
fn cumulative_close_never_drifts_a_unit_at_near_thirds() {
|
||||
drift_is_under_a_unit(&[3333, 3333, 3334]);
|
||||
}
|
||||
|
||||
/// A cumulative members close balances: the nominal accounts' closing
|
||||
/// lines and each member's part of the running split (any earlier split
|
||||
/// of what was closed before) net to zero, whatever the true-up.
|
||||
#[kani::proof]
|
||||
#[kani::unwind(6)]
|
||||
fn cumulative_closing_entry_balances() {
|
||||
const NOMINAL: usize = 3;
|
||||
let nominal: [i64; NOMINAL] = core::array::from_fn(|_| {
|
||||
let a = any_amount();
|
||||
kani::assume((-(1 << 38)..=1 << 38).contains(&a));
|
||||
a
|
||||
});
|
||||
let mut entry = [(Side::Debit, 0i64); NOMINAL + 2];
|
||||
let mut len = 0;
|
||||
for &m in &nominal {
|
||||
if let Some(line) = kernel::closing_line(m) {
|
||||
entry[len] = line;
|
||||
len += 1;
|
||||
}
|
||||
}
|
||||
let net = kernel::net(entry[..len].iter().copied()).expect("no overflow");
|
||||
// Two members at half each; earlier closes gave them any split of
|
||||
// what they closed.
|
||||
let closed: i64 = kani::any();
|
||||
kani::assume((-(1 << 38)..=1 << 38).contains(&closed));
|
||||
let first = any_amount();
|
||||
let given = [first, closed - first];
|
||||
let total = closed + net;
|
||||
let (magnitude, loss) = (total.abs(), total < 0);
|
||||
let mut parts = [magnitude / 2, magnitude / 2];
|
||||
let rems = [magnitude % 2, magnitude % 2];
|
||||
let against = if loss { [-given[0], -given[1]] } else { given };
|
||||
if kernel::apportion_cumulative(magnitude, &mut parts, &rems, 1, &against).is_none() {
|
||||
// Only an earlier split out of line range is refused here.
|
||||
assert!(!given[1].in_line_range());
|
||||
return;
|
||||
}
|
||||
let mut true_up = false;
|
||||
for p in parts {
|
||||
let p = if loss { -p } else { p };
|
||||
if let Some(line) = kernel::credit_line(p) {
|
||||
entry[len] = line;
|
||||
len += 1;
|
||||
true_up |= net != 0 && (p > 0) != (net > 0);
|
||||
}
|
||||
}
|
||||
assert_eq!(wide_net(&entry[..len]), 0);
|
||||
kani::cover!(true_up, "a true-up line on a member");
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// The checker itself
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
@@ -4,7 +4,6 @@
|
||||
//! (`docs/oracle.md`) diffs: the same fixture books through two engines must
|
||||
//! produce byte-identical [`snapshot`] output.
|
||||
|
||||
use crate::periods::allocate;
|
||||
use crate::{AccountType, Class, ClassId, JournalEntry, JournalLine, Ledger, LedgerError};
|
||||
use chrono::NaiveDate;
|
||||
use rust_decimal::Decimal;
|
||||
@@ -842,32 +841,45 @@ pub fn cancelled_in_span(
|
||||
/// distributions (debits). Earnings are partnership earnings: net income
|
||||
/// for the period, and earnings from before it not yet distributed (retained
|
||||
/// earnings plus unclosed income), are allocated by share. Allocation is to
|
||||
/// the cent, odd cents by largest remainder, so every row's total is exact
|
||||
/// and the ending total equals balance-sheet equity.
|
||||
/// the currency's minor unit, odd units by largest remainder, the same
|
||||
/// running way the members closes split them, so every row's total is
|
||||
/// exact and the ending total equals balance-sheet equity.
|
||||
///
|
||||
/// ```text
|
||||
/// GOAL: each member's capital roll-forward for a partnership over a
|
||||
/// period: beginning, contributions, allocated net income, distributions,
|
||||
/// ending; with a total.
|
||||
/// SOURCE: docs/spec/rules.md B-13 (26 U.S.C. §704(a); Form 1065 Schedule
|
||||
/// M-2), B-11; R-9c.
|
||||
/// SOURCE: docs/spec/rules.md B-13 (26 U.S.C. §704(a); Treas. Reg.
|
||||
/// §1.704-1(b)(2)(iv)(b); Form 1065 Schedule M-2; D-38), B-11;
|
||||
/// R-9c.
|
||||
/// INVARIANTS:
|
||||
/// 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. 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`.
|
||||
/// 3. Undistributed earnings U MUST be split by share, to the base
|
||||
/// currency's minor unit, by largest remainder (DECISION, owner
|
||||
/// 2026-10-13), as of each date d, the way the next members close
|
||||
/// would split them: allocate(C + U) − allocate(C), where C is what
|
||||
/// the members regime's closes dated on or before d closed
|
||||
/// (Ledger::members_regime; D-38, P3 review R-1). A member's
|
||||
/// beginning is their capital and share as of the day before
|
||||
/// `start`, their ending as of `end`. Tied units, "other"'s
|
||||
/// included, go to the member the count's first close listed first
|
||||
/// (Regime::allocate; on a day before it, the count the next members
|
||||
/// close begins), so the order `members` lists them in moves no unit
|
||||
/// once a close has begun the count.
|
||||
/// 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").
|
||||
/// the next, exactly (a capital account is one running balance of
|
||||
/// contributions, allocations and distributions: Treas. Reg.
|
||||
/// §1.704-1(b)(2)(iv)(b)).
|
||||
/// 3b. A member's income is the roll-forward's remainder: their share of
|
||||
/// the period's income, off by less than two cents (under one cent of
|
||||
/// rounding at each end of the cumulative split); the members'
|
||||
/// incomes MUST sum to the period's net income. (Earnings a close has
|
||||
/// already moved into capital were rounded by that close, B-13.)
|
||||
/// the period's income, off by less than two minor units (under one
|
||||
/// at each end of the cumulative split); the members' incomes MUST
|
||||
/// sum to the period's net income.
|
||||
/// 3c. A member's capital as of a date MUST NOT change when a period
|
||||
/// ending on or before it is later closed to the members, except by a
|
||||
/// true-up of rounding by closes made under an earlier rule, which
|
||||
/// shows only once a close posts it (D-38; R-1).
|
||||
/// 4. NEVER hide an unexplained change: it goes in "other".
|
||||
/// 5. An entry dated `start` is period activity, not beginning capital.
|
||||
/// 6. An entry and its reversal both dated in the span cancel
|
||||
@@ -981,8 +993,8 @@ pub fn members_equity(
|
||||
// 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).
|
||||
// next one's beginning, to the minor unit (a capital account is one
|
||||
// running balance: Treas. Reg. §1.704-1(b)(2)(iv)(b)).
|
||||
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) {
|
||||
@@ -1003,19 +1015,45 @@ pub fn members_equity(
|
||||
.map_or(Decimal::ZERO, |d| capital_through(d, end))
|
||||
})
|
||||
.collect();
|
||||
let begin_alloc = allocate(
|
||||
// As of a date, a member's share of the earnings not yet in capital is
|
||||
// what the next members close would give them of it: their split of
|
||||
// everything closed to these members and shares (the regime's closes
|
||||
// through that date) plus it, less their split of the closed part
|
||||
// alone. Closing a period moves earnings from here into capital
|
||||
// without changing the sum, so a member's capital on a date is the
|
||||
// same before and after its period closes (D-38); a true-up of
|
||||
// rounding by closes made under an earlier rule shows only once a
|
||||
// close posts it (P3 review R-1).
|
||||
let places = ledger.base_currency().allocation_places();
|
||||
let key: Vec<(crate::AccountId, Decimal)> = members
|
||||
.iter()
|
||||
.map(|m| (m.capital_account, m.share))
|
||||
.collect();
|
||||
let nothing = vec![Decimal::ZERO; members.len()];
|
||||
let unclosed_share = |undistributed: Decimal, regime: &crate::ledger::Regime| {
|
||||
let closed = regime.allocate(&key, regime.closed, ¬hing, places)?;
|
||||
regime.allocate(&key, regime.closed + undistributed, &closed, places)
|
||||
};
|
||||
let end_regime = ledger.members_regime(&key, Some(end));
|
||||
let begin_alloc = unclosed_share(
|
||||
begin_bs.total_equity - capital.iter().sum::<Decimal>(),
|
||||
&shares,
|
||||
);
|
||||
let end_alloc = allocate(
|
||||
&ledger.members_regime(&key, Some(day_before)),
|
||||
)?;
|
||||
let end_alloc = unclosed_share(
|
||||
end_bs.total_equity - capital_end.iter().sum::<Decimal>(),
|
||||
&shares,
|
||||
);
|
||||
&end_regime,
|
||||
)?;
|
||||
let explained = flow.iter().map(|(c, d)| c - d).sum::<Decimal>() + pnl.net_income();
|
||||
let other_alloc = allocate(
|
||||
// "Other" is split anew on each statement, its tied unit to the member
|
||||
// the count as of `end` lists first, as the ending's are: a reorder of
|
||||
// the members in the settings moves no cent between their other (and
|
||||
// so their income) either.
|
||||
let other_alloc = end_regime.allocate(
|
||||
&key,
|
||||
end_bs.total_equity - begin_bs.total_equity - explained,
|
||||
&shares,
|
||||
);
|
||||
¬hing,
|
||||
places,
|
||||
)?;
|
||||
|
||||
let mut cols = Vec::with_capacity(members.len());
|
||||
for (i, m) in members.iter().enumerate() {
|
||||
|
||||
Reference in new issue
Block a user