diff --git a/crates/ledger-core/src/arap.rs b/crates/ledger-core/src/arap.rs index 8bb2608..e261c30 100644 --- a/crates/ledger-core/src/arap.rs +++ b/crates/ledger-core/src/arap.rs @@ -194,7 +194,26 @@ impl AgingRow { } } -/// Bucket a past-due balance by days overdue. +/// Put an open balance in its aging bucket. +/// +/// ```text +/// GOAL: say how overdue a balance is, as of a date. +/// INVARIANTS: +/// 1. MUST put the whole balance in exactly one bucket; the others are +/// zero. +/// 2. NEVER moves a balance to a younger bucket as `as_of` advances. +/// SCENARIOS: +/// 1. IF `as_of` is on or before the due date: +/// - SHOULD be Current +/// - NEVER past due +/// BECAUSE a document due today is not yet overdue (QuickBooks' +/// convention; Stephen, 2026-10-13). +/// 2. IF it is 1 to 30 days past due: SHOULD be 1-30. +/// 3. IF 31 to 60: SHOULD be 31-60. +/// 4. IF 61 to 90: SHOULD be 61-90. +/// 5. IF more than 90: SHOULD be 90+. +/// BECAUSE these are the standard 30-day aging buckets. +/// ``` pub fn aging_bucket(due_date: NaiveDate, as_of: NaiveDate, balance: Decimal) -> AgingRow { let mut row = AgingRow { name: String::new(), @@ -204,7 +223,7 @@ pub fn aging_bucket(due_date: NaiveDate, as_of: NaiveDate, balance: Decimal) -> days_90: Decimal::ZERO, days_90_plus: Decimal::ZERO, }; - if due_date > as_of { + if due_date >= as_of { row.current = balance; } else { let days = (as_of - due_date).num_days(); @@ -348,6 +367,58 @@ mod tests { assert!(matches!(err, Err(LedgerError::Overpayment(_, _)))); } + #[test] + fn aging_boundaries() { + // Spec scenarios 1-5, at every edge. + let due = NaiveDate::from_ymd_opt(2026, 3, 31).unwrap(); + let b = Decimal::from(7); + let bucket = |days: i64| { + let r = aging_bucket(due, due + chrono::Duration::days(days), b); + [r.current, r.days_30, r.days_60, r.days_90, r.days_90_plus] + .iter() + .position(|x| !x.is_zero()) + .unwrap() + }; + for (days, want) in [ + (-10, 0), + (0, 0), + (1, 1), + (30, 1), + (31, 2), + (60, 2), + (61, 3), + (90, 3), + (91, 4), + (4000, 4), + ] { + assert_eq!(bucket(days), want, "{days} days past due"); + } + } + + proptest::proptest! { + /// Invariants 1 and 2: one bucket holds the whole balance, and + /// time only ever ages it. + #[test] + fn aging_is_exclusive_and_only_ages( + due_off in 0i64..2000, a in 0i64..2000, step in 0i64..400, cents in 1i64..10_000_000, + ) { + let base = NaiveDate::from_ymd_opt(2024, 1, 1).unwrap(); + let due = base + chrono::Duration::days(due_off); + let b = Decimal::new(cents, 2); + let idx = |as_of: NaiveDate| { + let r = aging_bucket(due, as_of, b); + let cells = [r.current, r.days_30, r.days_60, r.days_90, r.days_90_plus]; + let held: Vec = (0..5).filter(|&i| !cells[i].is_zero()).collect(); + proptest::prop_assert_eq!(held.len(), 1); + proptest::prop_assert_eq!(cells[held[0]], b); + Ok(held[0]) + }; + let early = base + chrono::Duration::days(a); + let later = early + chrono::Duration::days(step); + proptest::prop_assert!(idx(early)? <= idx(later)?); + } + } + #[test] fn ar_aging_buckets_correctly() { let mut l = arap_ledger(); diff --git a/crates/ledger-core/src/kernel.rs b/crates/ledger-core/src/kernel.rs index 759bdb7..a77d571 100644 --- a/crates/ledger-core/src/kernel.rs +++ b/crates/ledger-core/src/kernel.rs @@ -268,6 +268,81 @@ pub fn plug_last(whole: T, parts: &mut [T]) -> Option<()> { Some(()) } +/// Largest-remainder apportionment: make rounded-down `parts` sum to +/// `whole`, handing out the shortfall one `unit` at a time, largest +/// `remainders` first (ties to the earlier part). Any final fraction of a +/// unit (when `whole` isn't a whole number of units) goes to the first +/// part in that order. +/// +/// ```text +/// GOAL: split a whole into parts, each a whole number of units where +/// possible, summing to the whole exactly, without favoring anyone. +/// INVARIANTS: +/// 1. MUST make the parts sum to `whole` exactly. +/// 2. MUST change each part by zero or one unit (the first in order +/// may also take the sub-unit fraction). +/// 3. NEVER gives a unit to a part while a part with a strictly larger +/// remainder goes without. +/// SCENARIOS: +/// 1. IF the shortfall is negative, or `n` units or more (the parts +/// weren't rounded down from shares of `whole`): +/// - SHOULD return None, leaving `parts` unchanged +/// BECAUSE apportioning only corrects rounding. +/// 2. IF $100.00 splits three equal ways: +/// - SHOULD be 33.34, 33.33, 33.33 +/// - NEVER the same partner absorbing every odd cent by position +/// BECAUSE the cent goes to the largest remainder, ties to the first +/// (Stephen, 2026-10-13). +/// ``` +/// +/// Allocation-free and O(n^2): it ranks each part by counting the parts +/// ahead of it, so the model checker can prove it outright. +pub fn apportion(whole: T, parts: &mut [T], remainders: &[T], unit: T) -> Option<()> { + if parts.len() != remainders.len() || unit <= T::ZERO { + return None; + } + if parts.is_empty() { + // Nothing to give the whole to. + return (whole == T::ZERO).then_some(()); + } + let mut shortfall = whole; + for &p in parts.iter() { + shortfall = shortfall.checked_sub(p)?; + } + if shortfall < T::ZERO { + return None; + } + // Whole units short, by subtraction: no division in `Amount`. A + // shortfall of a unit per part or more isn't rounding; refuse it. + let mut units = 0; + while shortfall >= unit { + shortfall = shortfall.checked_sub(unit)?; + units += 1; + if units == parts.len() { + return None; + } + } + // A part's rank: the parts ahead of it (larger remainder, or equal and + // earlier). + let rank = |i: usize| { + (0..remainders.len()) + .filter(|&j| remainders[j] > remainders[i] || (remainders[j] == remainders[i] && j < i)) + .count() + }; + for (i, part) in parts.iter_mut().enumerate() { + let r = rank(i); + let mut add = T::ZERO; + if r < units { + add = unit; + } + if r == 0 { + add = add.checked_add(shortfall)?; + } + *part = part.checked_add(add)?; + } + Some(()) +} + #[cfg(test)] mod tests { use super::*; @@ -283,6 +358,23 @@ mod tests { assert!("1.000000000000".parse::().unwrap().in_line_range()); } + /// Regression (Kani `apportion_is_exact`, 2026-10-13): a shortfall of + /// two units and a bit, with one part, was accepted and handed almost + /// two units to that part. Spec scenario 1: more than one unit short + /// per part is refused. + #[test] + fn apportion_refuses_a_shortfall_of_a_unit_per_part_or_more() { + let mut parts = [0i64]; + assert_eq!(apportion(1805, &mut parts, &[0], 903), None); + assert_eq!(parts, [0]); + let mut parts = [0i64, 0]; + assert_eq!(apportion(1806, &mut parts, &[0, 0], 903), None); + // Just under: accepted. + let mut parts = [0i64, 0]; + assert_eq!(apportion(1805, &mut parts, &[1, 0], 903), Some(())); + assert_eq!(parts, [1805, 0]); + } + #[test] fn credit_line_sides() { assert_eq!(credit_line(5i64), Some((Side::Credit, 5))); diff --git a/crates/ledger-core/src/ledger.rs b/crates/ledger-core/src/ledger.rs index 0330247..7474bf7 100644 --- a/crates/ledger-core/src/ledger.rs +++ b/crates/ledger-core/src/ledger.rs @@ -926,9 +926,15 @@ impl Ledger { sweep(*a, *into, self); } } - periods::CloseTo::Members(members) => { + periods::CloseTo::Members(members) | periods::CloseTo::MembersLastAbsorbs(members) => { let shares: Vec = members.iter().map(|m| m.share).collect(); - for (m, part) in members.iter().zip(periods::allocate(net, &shares)) { + let parts = match to { + periods::CloseTo::MembersLastAbsorbs(_) => { + periods::allocate_last_absorbs(net, &shares) + } + _ => periods::allocate(net, &shares), + }; + for (m, part) in members.iter().zip(parts) { equity.push((m.capital, part)); if let Some(d) = m.distributions { sweep(d, m.capital, self); @@ -1022,7 +1028,7 @@ impl Ledger { accounts.push(*into); accounts.extend(sweep); } - periods::CloseTo::Members(members) => { + periods::CloseTo::Members(members) | periods::CloseTo::MembersLastAbsorbs(members) => { let total: Decimal = members.iter().map(|m| m.share).sum(); if members.is_empty() || total != Decimal::ONE @@ -1123,7 +1129,7 @@ impl Ledger { let sweeps: Vec = match to { periods::CloseTo::RetainedEarnings { sweep } | periods::CloseTo::Account { sweep, .. } => sweep.clone(), - periods::CloseTo::Members(members) => { + periods::CloseTo::Members(members) | periods::CloseTo::MembersLastAbsorbs(members) => { members.iter().filter_map(|m| m.distributions).collect() } }; diff --git a/crates/ledger-core/src/periods.rs b/crates/ledger-core/src/periods.rs index 6f54ed4..8f99132 100644 --- a/crates/ledger-core/src/periods.rs +++ b/crates/ledger-core/src/periods.rs @@ -50,8 +50,13 @@ 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. + /// their allocated earnings and absorbs their distributions. Odd cents + /// go by largest remainder ([`allocate`]). Members(Vec), + /// [`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`. + MembersLastAbsorbs(Vec), } /// One member in a partnership close. @@ -66,11 +71,40 @@ pub struct MemberClose { pub distributions: Option, } -/// Split `amount` by `shares` to the cent; the last share absorbs rounding, -/// so the parts always sum to `amount` exactly. +/// Split `amount` by `shares` (which sum to 1) to the cent, by largest +/// remainder: every part is its exact share rounded toward zero to the +/// cent, and the leftover cents go one each to the largest remainders, +/// ties to the earlier member ([`crate::kernel::apportion`]). The parts +/// always sum to `amount` exactly, and no member is favored by position +/// (Stephen, 2026-10-13). A loss splits as the negation of the same gain. pub fn allocate( amount: rust_decimal::Decimal, shares: &[rust_decimal::Decimal], +) -> Vec { + use rust_decimal::{Decimal, RoundingStrategy::ToZero}; + let magnitude = amount.abs(); + let exact: Vec = shares.iter().map(|s| magnitude * s).collect(); + let mut parts: Vec = exact + .iter() + .map(|e| e.round_dp_with_strategy(2, ToZero)) + .collect(); + let remainders: Vec = exact.iter().zip(&parts).map(|(e, p)| e - p).collect(); + crate::kernel::apportion(magnitude, &mut parts, &remainders, Decimal::new(1, 2)) + .expect("shares sum to 1, so the shortfall is under one cent per member"); + if amount.is_sign_negative() { + for p in &mut parts { + *p = -*p; + } + } + parts +} + +/// The rule closes used before 2026-10-13: each share rounded half away +/// from zero to the cent, the last absorbing the rounding. Kept only so +/// closes journaled under it replay exactly ([`CloseTo::MembersLastAbsorbs`]). +pub fn allocate_last_absorbs( + amount: rust_decimal::Decimal, + shares: &[rust_decimal::Decimal], ) -> Vec { use rust_decimal::RoundingStrategy::MidpointAwayFromZero; let mut parts: Vec<_> = shares diff --git a/crates/ledger-core/src/proofs.rs b/crates/ledger-core/src/proofs.rs index 2d0311f..fca0397 100644 --- a/crates/ledger-core/src/proofs.rs +++ b/crates/ledger-core/src/proofs.rs @@ -460,6 +460,89 @@ fn plug_last_sums_exactly() { } } +/// Most parts the apportionment harnesses explore (its ranking is +/// quadratic, so the bound is smaller than `N`). +const AP: usize = 3; + +/// Inputs for `kernel::apportion`: rounded-down parts, remainders, a unit, +/// and a whole some shortfall above the parts' sum. +fn any_apportionment() -> (usize, [i64; AP], [i64; AP], i64, i64, i64) { + let n: usize = kani::any(); + kani::assume(n <= AP); + let before: [i64; AP] = core::array::from_fn(|_| any_amount()); + let rems: [i64; AP] = core::array::from_fn(|_| any_amount()); + let unit: i64 = kani::any(); + kani::assume(unit > 0 && unit <= 1000); + let shortfall: i64 = kani::any(); + kani::assume(shortfall.unsigned_abs() <= 4000); + let mut whole = shortfall; + for &p in &before[..n] { + whole += p; + } + (n, before, rems, unit, shortfall, whole) +} + +/// `kernel::apportion` succeeds exactly when the parts are short of the +/// whole by less than one unit per part; then the parts sum to the whole, +/// and each gains nothing or one unit, except that one part may also take +/// the sub-unit fraction. +#[kani::proof] +#[kani::unwind(5)] +fn apportion_is_exact() { + let (n, before, rems, unit, shortfall, whole) = any_apportionment(); + let mut parts = before; + let ok = kernel::apportion(whole, &mut parts[..n], &rems[..n], unit); + let in_range = if n == 0 { + whole == 0 + } else { + shortfall >= 0 && shortfall < n as i64 * unit + }; + assert_eq!(ok.is_some(), in_range); + if ok.is_none() { + return; + } + let mut sum = 0i64; + let mut fraction_takers = 0; + for i in 0..n { + sum += parts[i]; + let gain = parts[i] - before[i]; + assert!(gain >= 0 && gain < 2 * unit); + if gain != 0 && gain != unit { + fraction_takers += 1; + } + } + assert_eq!(sum, whole); + assert!(fraction_takers <= 1); + kani::cover!( + n == AP && shortfall >= 2 * unit, + "three parts, two units short" + ); +} + +/// `kernel::apportion` never gives a unit to a part while a part with a +/// strictly larger remainder goes without (Stephen, 2026-10-13: no member +/// favored by position). +#[kani::proof] +#[kani::unwind(5)] +fn apportion_is_fair() { + let (n, before, rems, unit, _, whole) = any_apportionment(); + let mut parts = before; + if kernel::apportion(whole, &mut parts[..n], &rems[..n], unit).is_none() { + return; + } + for i in 0..n { + for j in 0..n { + if rems[i] > rems[j] { + assert!(parts[i] - before[i] >= unit || parts[j] - before[j] < unit); + } + } + } + kani::cover!( + n == AP && rems[0] < rems[2], + "a later part with a larger remainder" + ); +} + // --------------------------------------------------------------------------- // The checker itself // --------------------------------------------------------------------------- diff --git a/crates/ledger-core/tests/kernel_bridge.rs b/crates/ledger-core/tests/kernel_bridge.rs index 8ae4e72..4293626 100644 --- a/crates/ledger-core/tests/kernel_bridge.rs +++ b/crates/ledger-core/tests/kernel_bridge.rs @@ -147,6 +147,26 @@ proptest! { prop_assert_eq!(pi.into_iter().map(dec).collect::>(), pd); } + #[test] + fn apportion_agrees( + parts in prop::collection::vec(arb_cents(), 0..5), + rems in prop::collection::vec(0i64..100, 5), + short in 0i64..500, + ) { + let n = parts.len(); + let whole: i64 = parts.iter().sum::() + short; + let mut pi = parts.clone(); + let mut pd: Vec = parts.iter().copied().map(dec).collect(); + let ri = &rems[..n]; + let rd: Vec = ri.iter().copied().map(dec).collect(); + let oi = kernel::apportion(whole, &mut pi, ri, 100); + let od = kernel::apportion(dec(whole), &mut pd, &rd, dec(100)); + prop_assert_eq!(oi.is_some(), od.is_some()); + if oi.is_some() { + prop_assert_eq!(pi.into_iter().map(dec).collect::>(), pd); + } + } + #[test] fn line_range_agrees(cents in arb_cents()) { prop_assert_eq!(cents.in_line_range(), dec(cents).in_line_range()); diff --git a/crates/ledger-core/tests/properties.proptest-regressions b/crates/ledger-core/tests/properties.proptest-regressions new file mode 100644 index 0000000..14c8aaf --- /dev/null +++ b/crates/ledger-core/tests/properties.proptest-regressions @@ -0,0 +1,8 @@ +# Seeds for failure cases proptest has generated in the past. It is +# automatically read and these particular cases re-run before any +# novel cases are generated. +# +# It is recommended to check this file in to source control so that +# everyone who runs the test benefits from these saved cases. +cc 973b72cb286deab3cabcf8a48a797a1aa76e660f1a6f3a7046d02f655ad467a6 # shrinks to cents = 8074248648, shares = [0.0877914951989026063100137174, 0.327846364883401920438957476, 0.2235939643347050754458161866, 0.3607681755829903978052126200] +cc 181075cd6abb81d059781749aadb13e990e59e6658279089e173fe655bd6bcf2 # shrinks to cents = 4209937262, shares = [0.3215789473684210526315789474, 0.2426315789473684210526315789, 0.1663157894736842105263157895, 0.2694736842105263157894736842] diff --git a/crates/ledger-core/tests/properties.rs b/crates/ledger-core/tests/properties.rs index 07bb1a9..483712e 100644 --- a/crates/ledger-core/tests/properties.rs +++ b/crates/ledger-core/tests/properties.rs @@ -319,3 +319,69 @@ proptest! { } } } + +// --------------------------------------------------------------------------- +// Member splits (periods::allocate): conservation, a cent of each exact +// share, fairness, and sign symmetry. Rank 2: laws, not recomputation. +// --------------------------------------------------------------------------- + +/// 1 to 5 positive shares summing to exactly 1 (the last takes the rest). +fn arb_shares() -> impl Strategy> { + prop::collection::vec(1u32..1000, 1..=5).prop_map(|weights| { + let total: u32 = weights.iter().sum(); + let mut shares: Vec = weights + .iter() + .map(|w| Decimal::from(*w) / Decimal::from(total)) + .collect(); + let rest: Decimal = shares[..shares.len() - 1].iter().sum(); + *shares.last_mut().unwrap() = Decimal::ONE - rest; + shares + }) +} + +proptest! { + #![proptest_config(ProptestConfig::with_cases(2000))] + + #[test] + fn prop_split_parts_sum_to_the_whole_within_a_cent_of_each_share( + cents in -10_000_000_000i64..10_000_000_000, shares in arb_shares(), + ) { + let amount = Decimal::new(cents, 2); + let parts = ledger_core::periods::allocate(amount, &shares); + prop_assert_eq!(parts.len(), shares.len()); + prop_assert_eq!(parts.iter().copied().sum::(), amount); + for (p, s) in parts.iter().zip(&shares) { + prop_assert!((*p - amount * s).abs() < Decimal::new(1, 2), "{p} vs share {s}"); + prop_assert!(p.scale() <= 2 || p.normalize().scale() <= 2, "{p} is not whole cents"); + } + } + + #[test] + fn prop_split_never_rounds_up_a_smaller_remainder_over_a_larger( + cents in 0i64..10_000_000_000, shares in arb_shares(), + ) { + let amount = Decimal::new(cents, 2); + let parts = ledger_core::periods::allocate(amount, &shares); + let exact: Vec = shares.iter().map(|s| amount * s).collect(); + let floor = |e: &Decimal| e.round_dp_with_strategy(2, rust_decimal::RoundingStrategy::ToZero); + for i in 0..parts.len() { + for j in 0..parts.len() { + let (ri, rj) = (exact[i] - floor(&exact[i]), exact[j] - floor(&exact[j])); + let (up_i, up_j) = (parts[i] > floor(&exact[i]), parts[j] > floor(&exact[j])); + if ri > rj { + prop_assert!(up_i || !up_j, "member {j} rounded up over member {i}"); + } + } + } + } + + #[test] + fn prop_split_of_a_loss_is_the_negated_split_of_the_gain( + cents in 0i64..10_000_000_000, shares in arb_shares(), + ) { + let amount = Decimal::new(cents, 2); + let gain = ledger_core::periods::allocate(amount, &shares); + let loss = ledger_core::periods::allocate(-amount, &shares); + prop_assert_eq!(loss, gain.iter().map(|p| -*p).collect::>()); + } +} diff --git a/crates/ledger-core/tests/statements.rs b/crates/ledger-core/tests/statements.rs index 6b0c969..4bf3e2c 100644 --- a/crates/ledger-core/tests/statements.rs +++ b/crates/ledger-core/tests/statements.rs @@ -888,3 +888,74 @@ fn partner_distribution_accounts_roll_into_capital() { Err(LedgerError::InvalidMembers(_)) )); } + +// --------------------------------------------------------------------------- +// Splitting odd cents among members: largest remainder (Stephen, +// 2026-10-13). Spec: periods::allocate. +// --------------------------------------------------------------------------- + +fn split(amount: &str, shares: &[&str]) -> Vec { + let shares: Vec = shares.iter().map(|s| cents(s)).collect(); + ledger_core::periods::allocate(cents(amount), &shares) +} + +#[test] +fn an_odd_cent_goes_to_the_largest_remainder_not_the_last_member() { + // Three equal partners: the cent goes to the first (ties to the + // earlier), where the old rule always handed it to the last. + assert_eq!( + split("100.00", &["0.3333333333333333333333333333"; 3]), + [cents("33.34"), cents("33.33"), cents("33.33")] + ); + // Unequal shares: 6.666... has the larger remainder. + assert_eq!( + split( + "10.00", + &[ + "0.6666666666666666666666666667", + "0.3333333333333333333333333333" + ] + ), + [cents("6.67"), cents("3.33")] + ); + // Equal remainders (0.035 each): ties go to the earlier member. + assert_eq!( + split("0.07", &["0.5", "0.5"]), + [cents("0.04"), cents("0.03")] + ); + // 0.015 and 0.035: half a cent each, a tie again. + assert_eq!( + split("0.05", &["0.3", "0.7"]), + [cents("0.02"), cents("0.03")] + ); + // 0.003 and 0.007: the later member's remainder is larger, so the + // cent is theirs. + assert_eq!( + split("0.01", &["0.3", "0.7"]), + [cents("0.00"), cents("0.01")] + ); + // One cent, two ways: someone gets it, nobody gets a fraction. + assert_eq!( + split("0.01", &["0.5", "0.5"]), + [cents("0.01"), cents("0.00")] + ); +} + +#[test] +fn a_loss_splits_as_the_negation_of_the_same_gain() { + assert_eq!( + split("-100.00", &["0.3333333333333333333333333333"; 3]), + [cents("-33.34"), cents("-33.33"), cents("-33.33")] + ); +} + +#[test] +fn closes_journaled_under_the_old_rule_replay_with_it() { + // Contract: a close made before 2026-10-13 (last member absorbs) must + // replay to the same lines forever. + let shares = [cents("0.3333333333333333333333333333"); 3]; + assert_eq!( + ledger_core::periods::allocate_last_absorbs(cents("100.00"), &shares), + [cents("33.33"), cents("33.33"), cents("33.34")] + ); +} diff --git a/crates/ledger-store/src/store.rs b/crates/ledger-store/src/store.rs index 831bc6c..55860a3 100644 --- a/crates/ledger-store/src/store.rs +++ b/crates/ledger-store/src/store.rs @@ -1010,16 +1010,24 @@ 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) => json!({ - "members": members - .iter() - .map(|m| json!({ - "account": m.capital, - "share": m.share.to_string(), - "distributions": m.distributions, - })) - .collect::>(), - }), + CloseTo::Members(members) | CloseTo::MembersLastAbsorbs(members) => { + let mut v = json!({ + "members": members + .iter() + .map(|m| json!({ + "account": m.capital, + "share": m.share.to_string(), + "distributions": m.distributions, + })) + .collect::>(), + }); + // 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"); + } + v + } }; let line = self.append_journal( "period.closed", @@ -3403,20 +3411,25 @@ fn close_to_from_journal(v: Option<&Value>) -> Result { .get("members") .and_then(|m| m.as_array()) .ok_or_else(|| corrupt("unknown target"))?; - Ok(CloseTo::Members( - members - .iter() - .map(|m| { - Ok(ledger_core::MemberClose { - capital: get_u64(m, "account")?, - share: get_str(m, "share")? - .parse() - .map_err(|_| corrupt("member share"))?, - distributions: m.get("distributions").and_then(|d| d.as_u64()), - }) + let members = members + .iter() + .map(|m| { + Ok(ledger_core::MemberClose { + capital: get_u64(m, "account")?, + share: get_str(m, "share")? + .parse() + .map_err(|_| corrupt("member share"))?, + distributions: m.get("distributions").and_then(|d| d.as_u64()), }) - .collect::>()?, - )) + }) + .collect::>()?; + // The rounding rule the close was made under: absent before + // 2026-10-13, when the last member absorbed it. + match v.get("rounding").and_then(|r| r.as_str()) { + None => Ok(CloseTo::MembersLastAbsorbs(members)), + Some("largest_remainder") => Ok(CloseTo::Members(members)), + Some(_) => Err(corrupt("unknown rounding")), + } } /// Record a posted entry that rides inside another event's payload (a diff --git a/docs/requirements.md b/docs/requirements.md index 76d905f..01674b3 100644 --- a/docs/requirements.md +++ b/docs/requirements.md @@ -63,8 +63,9 @@ requirement conflict, the requirement wins. (loss), distributions (debits), ending — plus a total that equals balance-sheet equity. Earnings not yet distributed (retained earnings and unclosed income) and the period's income are allocated by ownership share - to the cent, the last member absorbing rounding, so every row's total is - exact. Any equity change the roll-forward doesn't explain is shown as + to the cent by largest remainder (the odd cent goes to the largest + remainder, ties to the earlier member; Stephen, 2026-10-13), so every + row's total is exact. Any equity change the roll-forward doesn't explain is shown as "other", never hidden. - **R-9d — Tax categories.** Revenue and expense accounts map to stable tax categories (`ledger_core::tax::CATEGORIES`: key, label, income / @@ -126,8 +127,9 @@ requirement conflict, the requirement wins. end** (later-dated entries belong to later periods) and puts the net in `to`: `CloseTo::RetainedEarnings` (`3999`, the corporate convention; `close_period` is shorthand) or `CloseTo::Members` (each member's capital - account by share, to the cent, the last absorbing rounding: the - partnership convention), or `CloseTo::Account` (any equity account, e.g. + account by share, to the cent, odd cents by largest remainder: the + partnership convention; closes journaled before 2026-10-13 replay with + the rule they were made under, the last member absorbing rounding), or `CloseTo::Account` (any equity account, e.g. a sole proprietor's capital). Every target can sweep owner-pay equity accounts — draws, distributions, dividends — into it: a member's distributions account into their own capital. The members' equity