Each close into members' capital rounded on its own, ties to the earlier member, so twelve monthly 50/50 closes of odd-cent income could leave one member several cents ahead of half the year (docs/testing.md #12, found with the members' equity fix). - CloseTo::Members now splits cumulatively over a run (closes back to back with the same members and shares): each close gives a member allocate(run total so far) - allocate(run total before). Periods record their close order and a cumulative close's members and net (Period::close_seq, member_close) so the next close continues the run and replay recomputes it exactly. A close to anything else, or other shares, ends the run. - Journaled "rounding": "cumulative". The per-close rule is kept as CloseTo::MembersPerClose for closes journaled "largest_remainder"; older closes still replay with the last member absorbing it. - Lean: runPart_telescopes (a member's total from a run is their allocation of the run's total) and closePart_total (each close hands out exactly its net), audited to the standard axioms. - Tests: hand-computed three one-cent months (per close A 0.03 / B 0; cumulative A 0.02 / B 0.01), a run ending, a property that each member stays within a cent of their share (fails on the per-close rule), and a store contract that each rule journals and replays as itself. Wanderland's books have no closes yet, so all of theirs use the new rule. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01GWVo7x3UHw5kwCUaWxiejX
This commit is contained in:
1 parent
f4381f170c
commit
fb3b4ab476
11 files changed
+416
-16
No files matched your search
@@ -1294,6 +1294,8 @@ impl Ledger {
|
||||
end,
|
||||
status: PeriodStatus::Open,
|
||||
closing_entry: None,
|
||||
close_seq: None,
|
||||
member_close: None,
|
||||
},
|
||||
);
|
||||
Ok(id)
|
||||
@@ -1417,13 +1419,23 @@ impl Ledger {
|
||||
sweep(*a, *into, self);
|
||||
}
|
||||
}
|
||||
periods::CloseTo::Members(members) | periods::CloseTo::MembersLastAbsorbs(members) => {
|
||||
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::allocate(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));
|
||||
@@ -1462,12 +1474,46 @@ 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 {
|
||||
members: member_key(members),
|
||||
net,
|
||||
}),
|
||||
_ => None,
|
||||
};
|
||||
Ok(closing_entry)
|
||||
}
|
||||
|
||||
/// 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).
|
||||
fn member_close_run(&self, members: &[(AccountId, Decimal)]) -> Decimal {
|
||||
let mut closed: Vec<&periods::Period> = self
|
||||
.periods
|
||||
.values()
|
||||
.filter(|p| p.status == PeriodStatus::Closed)
|
||||
.collect();
|
||||
closed.sort_by_key(|p| std::cmp::Reverse(p.close_seq));
|
||||
let mut total = Decimal::ZERO;
|
||||
for p in closed {
|
||||
match &p.member_close {
|
||||
Some(r) if r.members == members => total += r.net,
|
||||
_ => break,
|
||||
}
|
||||
}
|
||||
total
|
||||
}
|
||||
|
||||
/// Revenue and expense balances as of `end` as closing lines (code
|
||||
/// order), and the net income they carry (revenue debits minus expense
|
||||
/// credits).
|
||||
@@ -1519,7 +1565,9 @@ impl Ledger {
|
||||
accounts.push(*into);
|
||||
accounts.extend(sweep);
|
||||
}
|
||||
periods::CloseTo::Members(members) | periods::CloseTo::MembersLastAbsorbs(members) => {
|
||||
periods::CloseTo::Members(members)
|
||||
| periods::CloseTo::MembersPerClose(members)
|
||||
| periods::CloseTo::MembersLastAbsorbs(members) => {
|
||||
let total: Decimal = members.iter().map(|m| m.share).sum();
|
||||
if members.is_empty()
|
||||
|| total != Decimal::ONE
|
||||
@@ -1623,7 +1671,9 @@ impl Ledger {
|
||||
let sweeps: Vec<AccountId> = match to {
|
||||
periods::CloseTo::RetainedEarnings { sweep }
|
||||
| periods::CloseTo::Account { sweep, .. } => sweep.clone(),
|
||||
periods::CloseTo::Members(members) | periods::CloseTo::MembersLastAbsorbs(members) => {
|
||||
periods::CloseTo::Members(members)
|
||||
| periods::CloseTo::MembersPerClose(members)
|
||||
| periods::CloseTo::MembersLastAbsorbs(members) => {
|
||||
members.iter().filter_map(|m| m.distributions).collect()
|
||||
}
|
||||
};
|
||||
@@ -2770,3 +2820,9 @@ fn check_minor_units(amount: Money) -> Result<(), LedgerError> {
|
||||
_ => Ok(()),
|
||||
}
|
||||
}
|
||||
|
||||
/// A members close's (capital account, share) list, in order: what a run
|
||||
/// of cumulative closes must share.
|
||||
fn member_key(members: &[periods::MemberClose]) -> Vec<(AccountId, Decimal)> {
|
||||
members.iter().map(|m| (m.capital, m.share)).collect()
|
||||
}
|
||||
@@ -57,7 +57,9 @@ pub use ledger::{
|
||||
pub use money::{Currency, Money};
|
||||
pub use payouts::{Payout, PayoutAdjustment, PayoutLine, PayoutTotals, payout_plan};
|
||||
pub use payroll::{PayrollLine, PayrollRun, PayrollRunId};
|
||||
pub use periods::{CloseTo, MemberClose, Period, PeriodId, PeriodStatus, RETAINED_EARNINGS_CODE};
|
||||
pub use periods::{
|
||||
CloseTo, MemberClose, MemberCloseRecord, Period, PeriodId, PeriodStatus, RETAINED_EARNINGS_CODE,
|
||||
};
|
||||
pub use reports::{
|
||||
BalanceSheet, BalanceSheetStatement, CashFlowLine, CashFlowParams, CashFlowStatement,
|
||||
ClassProfitAndLoss, GeneralLedger, LedgerAccount, LedgerPosting, MemberCapital,
|
||||
|
||||
@@ -51,8 +51,15 @@ 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`]).
|
||||
/// 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).
|
||||
Members(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.
|
||||
MembersPerClose(Vec<MemberClose>),
|
||||
/// [`CloseTo::Members`] under the rounding closes used before
|
||||
/// 2026-10-13 ([`allocate_last_absorbs`]). Only replay of those journals
|
||||
/// produces it; new closes use `Members`.
|
||||
@@ -134,6 +141,23 @@ pub struct Period {
|
||||
pub status: PeriodStatus,
|
||||
/// The closing entry's id, once closed.
|
||||
pub closing_entry: Option<u64>,
|
||||
/// The order it closed in among all closes (1 for the first), once
|
||||
/// closed.
|
||||
#[serde(default)]
|
||||
pub close_seq: Option<u64>,
|
||||
/// What a cumulative members close allocated, once closed that way.
|
||||
#[serde(default)]
|
||||
pub member_close: Option<MemberCloseRecord>,
|
||||
}
|
||||
|
||||
/// A cumulative members close ([`CloseTo::Members`]): who shared, and the
|
||||
/// net it closed. The next close with the same members continues the run.
|
||||
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]
|
||||
pub struct MemberCloseRecord {
|
||||
/// (capital account, share), in the order given.
|
||||
pub members: Vec<(crate::AccountId, rust_decimal::Decimal)>,
|
||||
/// The net income this close allocated (negative for a loss).
|
||||
pub net: rust_decimal::Decimal,
|
||||
}
|
||||
|
||||
impl Period {
|
||||
|
||||
Reference in new issue
Block a user