closes: a run of members closes splits cumulatively

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.

The first company'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:
swaitsandClaude Opus 5.5 committed 2026-10-13 18:58:21 -06:00
1 parent ad80171ba2
commit 6fa1b382ae
11 files changed
+416 -16

No files matched your search

+25 -1
View File
@@ -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 {