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
@@ -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")),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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}");
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user