closes: a run of members closes splits cumulatively
check / check (push) Canceled after 0s

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:
swaitsandClaude Opus 5.5 committed 2026-10-13 18:58:21 -06:00
1 parent f4381f170c
commit fb3b4ab476
11 files changed
+416 -16

No files matched your search

+14 -7
View File
@@ -1099,7 +1099,9 @@ impl Store {
CloseTo::RetainedEarnings { sweep } if sweep.is_empty() => json!("retained_earnings"),
CloseTo::RetainedEarnings { sweep } => json!({"retained_earnings": {"sweep": sweep}}),
CloseTo::Account { into, sweep } => json!({"account": {"into": into, "sweep": sweep}}),
CloseTo::Members(members) | CloseTo::MembersLastAbsorbs(members) => {
CloseTo::Members(members)
| CloseTo::MembersPerClose(members)
| CloseTo::MembersLastAbsorbs(members) => {
let mut v = json!({
"members": members
.iter()
@@ -1110,10 +1112,13 @@ impl Store {
}))
.collect::<Vec<_>>(),
});
// Closes journaled before 2026-10-13 carry no rounding and
// replay with the last member absorbing it.
if matches!(to, CloseTo::Members(_)) {
v["rounding"] = json!("largest_remainder");
// The rounding rule, so replay closes the same way. Closes
// journaled before 2026-10-13 carry none (the last member
// absorbed it).
match to {
CloseTo::Members(_) => v["rounding"] = json!("cumulative"),
CloseTo::MembersPerClose(_) => v["rounding"] = json!("largest_remainder"),
_ => {}
}
v
}
@@ -3487,10 +3492,12 @@ fn close_to_from_journal(v: Option<&Value>) -> Result<CloseTo, StoreError> {
})
.collect::<Result<_, StoreError>>()?;
// The rounding rule the close was made under: absent before
// 2026-10-13, when the last member absorbed it.
// 2026-10-13, when the last member absorbed it; "largest_remainder"
// per close, then "cumulative" over a run of closes.
match v.get("rounding").and_then(|r| r.as_str()) {
None => Ok(CloseTo::MembersLastAbsorbs(members)),
Some("largest_remainder") => Ok(CloseTo::Members(members)),
Some("largest_remainder") => Ok(CloseTo::MembersPerClose(members)),
Some("cumulative") => Ok(CloseTo::Members(members)),
Some(_) => Err(corrupt("unknown rounding")),
}
}
+66
View File
@@ -1698,3 +1698,69 @@ fn scrapping_an_asset_for_nothing_disposes_it() {
fn store_add(store: &mut Store, code: &str, t: AccountType) -> ledger_core::AccountId {
store.add_account(code, code, t, None, false).unwrap()
}
/// Contract (rules.md B-13): each members close journals its rounding rule
/// and replays under it: "cumulative" for new closes, "largest_remainder"
/// for the per-close rule of earlier on 2026-10-13. Three one-cent months,
/// 50/50: cumulative ends A 0.02 / B 0.01, per close A 0.03 / B 0.00.
#[test]
fn members_closes_replay_under_the_rule_they_were_journaled_with() {
for (cumulative, rule, a, b) in [
(true, "\"rounding\":\"cumulative\"", "0.02", "0.01"),
(false, "\"rounding\":\"largest_remainder\"", "0.03", "0.00"),
] {
let dir = test_dir(&format!("close-rule-{cumulative}"));
let mut store = Store::create(&dir, Currency::USD).unwrap();
let cash = store
.add_account("1000", "Cash", AccountType::Asset, None, false)
.unwrap();
let caps = [
store
.add_account("3000", "A", AccountType::Equity, None, false)
.unwrap(),
store
.add_account("3001", "B", AccountType::Equity, None, false)
.unwrap(),
];
let rev = store
.add_account("4000", "Sales", AccountType::Revenue, None, false)
.unwrap();
let members: Vec<ledger_core::MemberClose> = caps
.iter()
.map(|&capital| ledger_core::MemberClose {
capital,
share: Decimal::new(5, 1),
distributions: None,
})
.collect();
let to = if cumulative {
ledger_core::CloseTo::Members(members)
} else {
ledger_core::CloseTo::MembersPerClose(members)
};
let cent = Money::new(Decimal::new(1, 2), Currency::USD);
for m in 1..=3u32 {
let day = |d| NaiveDate::from_ymd_opt(2026, m, d).unwrap();
let e = store.create_entry(day(10), "a cent").unwrap();
store.add_line(e, cash, Side::Debit, cent).unwrap();
store.add_line(e, rev, Side::Credit, cent).unwrap();
store.post_entry(e).unwrap();
let end = NaiveDate::from_ymd_opt(2026, m + 1, 1)
.unwrap()
.pred_opt()
.unwrap();
let p = store
.create_period(&format!("2026-{m:02}"), day(1), end)
.unwrap();
store.close_period_to(p, &to).unwrap();
}
let journal = std::fs::read_to_string(dir.join(ledger_store::JOURNAL_FILE)).unwrap();
assert_eq!(journal.matches(rule).count(), 3, "{rule}");
drop(store);
let store = Store::open(&dir).unwrap();
store.verify().unwrap();
let bal = |id| store.ledger().account_balance(id).unwrap();
assert_eq!(bal(caps[0]), a.parse::<Decimal>().unwrap(), "{rule}");
assert_eq!(bal(caps[1]), b.parse::<Decimal>().unwrap(), "{rule}");
}
}