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

+60 -4
View File
@@ -1294,6 +1294,8 @@ impl Ledger {
end, end,
status: PeriodStatus::Open, status: PeriodStatus::Open,
closing_entry: None, closing_entry: None,
close_seq: None,
member_close: None,
}, },
); );
Ok(id) Ok(id)
@@ -1417,13 +1419,23 @@ impl Ledger {
sweep(*a, *into, self); 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 shares: Vec<Decimal> = members.iter().map(|m| m.share).collect();
let parts = match to { let parts = match to {
periods::CloseTo::MembersLastAbsorbs(_) => { periods::CloseTo::MembersLastAbsorbs(_) => {
periods::allocate_last_absorbs(net, &shares) 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) { for (m, part) in members.iter().zip(parts) {
equity.push((m.capital, part)); equity.push((m.capital, part));
@@ -1462,12 +1474,46 @@ impl Ledger {
Some(entry) 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"); let p = self.periods.get_mut(&id).expect("period exists");
p.status = PeriodStatus::Closed; p.status = PeriodStatus::Closed;
p.closing_entry = closing_entry; 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) 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 /// Revenue and expense balances as of `end` as closing lines (code
/// order), and the net income they carry (revenue debits minus expense /// order), and the net income they carry (revenue debits minus expense
/// credits). /// credits).
@@ -1519,7 +1565,9 @@ impl Ledger {
accounts.push(*into); accounts.push(*into);
accounts.extend(sweep); 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(); let total: Decimal = members.iter().map(|m| m.share).sum();
if members.is_empty() if members.is_empty()
|| total != Decimal::ONE || total != Decimal::ONE
@@ -1623,7 +1671,9 @@ impl Ledger {
let sweeps: Vec<AccountId> = match to { let sweeps: Vec<AccountId> = match to {
periods::CloseTo::RetainedEarnings { sweep } periods::CloseTo::RetainedEarnings { sweep }
| periods::CloseTo::Account { sweep, .. } => sweep.clone(), | 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() members.iter().filter_map(|m| m.distributions).collect()
} }
}; };
@@ -2770,3 +2820,9 @@ fn check_minor_units(amount: Money) -> Result<(), LedgerError> {
_ => Ok(()), _ => 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()
}
+3 -1
View File
@@ -57,7 +57,9 @@ pub use ledger::{
pub use money::{Currency, Money}; pub use money::{Currency, Money};
pub use payouts::{Payout, PayoutAdjustment, PayoutLine, PayoutTotals, payout_plan}; pub use payouts::{Payout, PayoutAdjustment, PayoutLine, PayoutTotals, payout_plan};
pub use payroll::{PayrollLine, PayrollRun, PayrollRunId}; 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::{ pub use reports::{
BalanceSheet, BalanceSheetStatement, CashFlowLine, CashFlowParams, CashFlowStatement, BalanceSheet, BalanceSheetStatement, CashFlowLine, CashFlowParams, CashFlowStatement,
ClassProfitAndLoss, GeneralLedger, LedgerAccount, LedgerPosting, MemberCapital, ClassProfitAndLoss, GeneralLedger, LedgerAccount, LedgerPosting, MemberCapital,
+25 -1
View File
@@ -51,8 +51,15 @@ pub enum CloseTo {
/// Members' capital accounts by share (shares sum to 1): the /// Members' capital accounts by share (shares sum to 1): the
/// partnership convention, where each member's capital account carries /// partnership convention, where each member's capital account carries
/// their allocated earnings and absorbs their distributions. Odd cents /// 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>), 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 /// [`CloseTo::Members`] under the rounding closes used before
/// 2026-10-13 ([`allocate_last_absorbs`]). Only replay of those journals /// 2026-10-13 ([`allocate_last_absorbs`]). Only replay of those journals
/// produces it; new closes use `Members`. /// produces it; new closes use `Members`.
@@ -134,6 +141,23 @@ pub struct Period {
pub status: PeriodStatus, pub status: PeriodStatus,
/// The closing entry's id, once closed. /// The closing entry's id, once closed.
pub closing_entry: Option<u64>, 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 { impl Period {
+174
View File
@@ -1522,3 +1522,177 @@ mod chaining {
} }
} }
} }
mod cumulative_closes {
use super::*;
use ledger_core::{CloseTo, MemberClose};
/// Three months of one-cent income, 50/50, each month closed into the
/// members' capital.
fn three_cent_months(to: impl Fn(&[AccountId; 2]) -> CloseTo) -> (Ledger, [AccountId; 2]) {
let mut l = Ledger::new(Currency::USD);
let mut add = |code: &str, t| l.add_account(code, code, t, None, false).unwrap();
let cash = add("1000", AccountType::Asset);
let caps = [
add("3000", AccountType::Equity),
add("3001", AccountType::Equity),
];
let rev = add("4000", AccountType::Revenue);
for m in 1..=3 {
let e = l.create_entry(d(m, 10), "a cent");
l.add_line(
e,
cash,
Side::Debit,
Money::new(cents("0.01"), Currency::USD),
)
.unwrap();
l.add_line(
e,
rev,
Side::Credit,
Money::new(cents("0.01"), Currency::USD),
)
.unwrap();
l.post_entry(e).unwrap();
let end = d(m + 1, 1).pred_opt().unwrap();
let p = l
.create_period(&format!("2026-{m:02}"), d(m, 1), end)
.unwrap();
l.close_period_to(p, &to(&caps)).unwrap();
}
(l, caps)
}
fn half(caps: &[AccountId; 2]) -> Vec<MemberClose> {
caps.iter()
.map(|&capital| MemberClose {
capital,
share: cents("0.5"),
distributions: None,
})
.collect()
}
/// Oracle: rules.md B-13 (cumulative closes; docs/testing.md #12),
/// hand-computed. Closing each cent on its own gives the earlier member
/// every tie: A 0.03, B 0.00 after three months. Cumulatively: after
/// month 1 the run's 0.01 splits A 0.01 / B 0; after month 2, 0.02
/// splits 0.01 / 0.01, so month 2 gives B the cent; after month 3, 0.03
/// splits 0.02 / 0.01, so month 3 gives A the cent. A 0.02, B 0.01.
#[test]
fn a_run_of_closes_never_piles_up_rounding() {
let (l, caps) = three_cent_months(|c| CloseTo::MembersPerClose(half(c)));
let bal = |l: &Ledger, a| l.account_balance(a).unwrap();
assert_eq!(
(bal(&l, caps[0]), bal(&l, caps[1])),
(cents("0.03"), cents("0.00")),
"per close: the earlier member takes every tie"
);
let (l, caps) = three_cent_months(|c| CloseTo::Members(half(c)));
assert_eq!(
(bal(&l, caps[0]), bal(&l, caps[1])),
(cents("0.02"), cents("0.01")),
"cumulative: within a cent of half of 0.03 each"
);
}
proptest::proptest! {
/// Oracle: rules.md B-13, cumulative closes. For any run of monthly
/// profits and losses closed into three members at 33.33% /
/// 33.33% / 33.34%, each member's total from the run is within one
/// cent of their exact share of everything closed.
#[test]
fn each_member_stays_within_a_cent_of_their_share(
nets in proptest::collection::vec(-500_000i64..500_000, 1..12),
) {
let mut l = Ledger::new(Currency::USD);
let mut add = |code: &str, t| l.add_account(code, code, t, None, false).unwrap();
let cash = add("1000", AccountType::Asset);
let caps = [
add("3000", AccountType::Equity),
add("3001", AccountType::Equity),
add("3002", AccountType::Equity),
];
let rev = add("4000", AccountType::Revenue);
let exp = add("6000", AccountType::Expense);
let shares = [cents("0.3333"), cents("0.3333"), cents("0.3334")];
let to = CloseTo::Members(
caps.iter()
.zip(shares)
.map(|(&capital, share)| MemberClose { capital, share, distributions: None })
.collect(),
);
for (i, n) in nets.iter().enumerate() {
let m = i as u32 + 1;
if *n != 0 {
let amount = Money::new(Decimal::new(n.abs(), 2), Currency::USD);
let (dr, cr) = if *n > 0 { (cash, rev) } else { (exp, cash) };
let e = l.create_entry(d(m, 10), "month");
l.add_line(e, dr, Side::Debit, amount).unwrap();
l.add_line(e, cr, Side::Credit, amount).unwrap();
l.post_entry(e).unwrap();
}
let end = if m == 12 { d(12, 31) } else { d(m + 1, 1).pred_opt().unwrap() };
let p = l.create_period(&format!("2026-{m:02}"), d(m, 1), end).unwrap();
l.close_period_to(p, &to).unwrap();
}
let total = Decimal::new(nets.iter().sum::<i64>(), 2);
for (cap, share) in caps.iter().zip(shares) {
let got = l.account_balance(*cap).unwrap();
proptest::prop_assert!((got - total * share).abs() < cents("0.01"), "{got} vs {}", total * share);
}
}
}
/// A run is closes with the same members and shares, back to back: a
/// close to anything else (or with other shares) ends it, and the next
/// members close starts afresh.
#[test]
fn a_different_close_ends_the_run() {
let mut l = Ledger::new(Currency::USD);
let mut add = |code: &str, t| l.add_account(code, code, t, None, false).unwrap();
let cash = add("1000", AccountType::Asset);
let caps = [
add("3000", AccountType::Equity),
add("3001", AccountType::Equity),
];
let rev = add("4000", AccountType::Revenue);
let cent = |l: &mut Ledger, m| {
let e = l.create_entry(d(m, 10), "a cent");
l.add_line(
e,
cash,
Side::Debit,
Money::new(cents("0.01"), Currency::USD),
)
.unwrap();
l.add_line(
e,
rev,
Side::Credit,
Money::new(cents("0.01"), Currency::USD),
)
.unwrap();
l.post_entry(e).unwrap();
};
let close = |l: &mut Ledger, m: u32, to: &CloseTo| {
let end = d(m + 1, 1).pred_opt().unwrap();
let p = l
.create_period(&format!("2026-{m:02}"), d(m, 1), end)
.unwrap();
l.close_period_to(p, to).unwrap();
};
// Month 1 to members (A gets the cent), month 2 to retained
// earnings (ends the run), month 3 to members again: a new run, so
// A gets that cent too.
cent(&mut l, 1);
close(&mut l, 1, &CloseTo::Members(half(&caps)));
cent(&mut l, 2);
close(&mut l, 2, &CloseTo::RetainedEarnings { sweep: vec![] });
cent(&mut l, 3);
close(&mut l, 3, &CloseTo::Members(half(&caps)));
assert_eq!(l.account_balance(caps[0]).unwrap(), cents("0.02"));
assert_eq!(l.account_balance(caps[1]).unwrap(), cents("0"));
}
}
+14 -7
View File
@@ -1099,7 +1099,9 @@ impl Store {
CloseTo::RetainedEarnings { sweep } if sweep.is_empty() => json!("retained_earnings"), CloseTo::RetainedEarnings { sweep } if sweep.is_empty() => json!("retained_earnings"),
CloseTo::RetainedEarnings { sweep } => json!({"retained_earnings": {"sweep": sweep}}), CloseTo::RetainedEarnings { sweep } => json!({"retained_earnings": {"sweep": sweep}}),
CloseTo::Account { into, sweep } => json!({"account": {"into": into, "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!({ let mut v = json!({
"members": members "members": members
.iter() .iter()
@@ -1110,10 +1112,13 @@ impl Store {
})) }))
.collect::<Vec<_>>(), .collect::<Vec<_>>(),
}); });
// Closes journaled before 2026-10-13 carry no rounding and // The rounding rule, so replay closes the same way. Closes
// replay with the last member absorbing it. // journaled before 2026-10-13 carry none (the last member
if matches!(to, CloseTo::Members(_)) { // absorbed it).
v["rounding"] = json!("largest_remainder"); match to {
CloseTo::Members(_) => v["rounding"] = json!("cumulative"),
CloseTo::MembersPerClose(_) => v["rounding"] = json!("largest_remainder"),
_ => {}
} }
v v
} }
@@ -3487,10 +3492,12 @@ fn close_to_from_journal(v: Option<&Value>) -> Result<CloseTo, StoreError> {
}) })
.collect::<Result<_, StoreError>>()?; .collect::<Result<_, StoreError>>()?;
// The rounding rule the close was made under: absent before // 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()) { match v.get("rounding").and_then(|r| r.as_str()) {
None => Ok(CloseTo::MembersLastAbsorbs(members)), 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")), 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 { fn store_add(store: &mut Store, code: &str, t: AccountType) -> ledger_core::AccountId {
store.add_account(code, code, t, None, false).unwrap() 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}");
}
}
+14 -2
View File
@@ -217,6 +217,14 @@ closed period).
- **No authority** says how to round a share to the cent. Largest - **No authority** says how to round a share to the cent. Largest
remainder, ties to the earlier member: DECISION (Stephen, 2026-10-13). remainder, ties to the earlier member: DECISION (Stephen, 2026-10-13).
Closes made before then replay with the last member absorbing it. Closes made before then replay with the last member absorbing it.
- Rounding never piles up: a run of members closes (same members and
shares, back to back) splits cumulatively, each close giving a member
their allocation of the run's total so far less their allocation before
it, so each member's total from the run is within a cent of their exact
share (follows from the running capital account below; found
2026-10-13: closing each month on its own gave every tied cent to the
earlier member). Journaled as `"rounding": "cumulative"`; closes
journaled `"largest_remainder"` replay one at a time.
- A capital account is one running balance: Form 1065 Schedule K-1 - A capital account is one running balance: Form 1065 Schedule K-1
instructions, Item L: "The partnership must report your beginning instructions, Item L: "The partnership must report your beginning
capital account and ending capital account for the year", "based on the capital account and ending capital account for the year", "based on the
@@ -229,8 +237,12 @@ closed period).
**Enforced:** `CloseTo::Members`, `periods::allocate`, **Enforced:** `CloseTo::Members`, `periods::allocate`,
`kernel::apportion`, `members_equity`. **Checked:** Kani `kernel::apportion`, `members_equity`. **Checked:** Kani
`apportion_is_exact`, `apportion_is_fair`; properties `prop_split_*`, `apportion_is_exact`, `apportion_is_fair`; Lean `runPart_telescopes`,
`each_members_capital_chains_across_statements`; `closePart_total`; properties `prop_split_*`,
`each_members_capital_chains_across_statements`,
`each_member_stays_within_a_cent_of_their_share`;
`a_run_of_closes_never_piles_up_rounding`,
`members_closes_replay_under_the_rule_they_were_journaled_with`;
`closes_journaled_under_the_old_rule_replay_with_it`. `closes_journaled_under_the_old_rule_replay_with_it`.
## B-14 Statements: P&L for a period; cash flow by the indirect method ## B-14 Statements: P&L for a period; cash flow by the indirect method
+1 -1
View File
@@ -79,7 +79,7 @@ accounting question), CHANGE (a source says otherwise; work pending).
| 9 | An asset's life ends at purchase + life months, that day not held; day-count proration | CONVENTION | Exactly N months of service; day proration and the mid-month convention are both accepted book policies (ASC 360-10-35-4 not read: login) | | 9 | An asset's life ends at purchase + life months, that day not held; day-count proration | CONVENTION | Exactly N months of service; day proration and the mid-month convention are both accepted book policies (ASC 360-10-35-4 not read: login) |
| 10 | A payroll entry has one line per account total | CONFIRMED | The per-employee detail the IRS requires ("Amounts and dates of all wage ... payments", Employment Tax Recordkeeping) is kept in the run; a summary entry is standard | | 10 | A payroll entry has one line per account total | CONFIRMED | The per-employee detail the IRS requires ("Amounts and dates of all wage ... payments", Employment Tax Recordkeeping) is kept in the run; a summary entry is standard |
| 11 | A period with exactly the dates of an existing one is allowed | DESIGN | Nothing governs duplicate periods; harmless (the second close locks only). Refusing exact duplicates is an option | | 11 | A period with exactly the dates of an existing one is allowed | DESIGN | Nothing governs duplicate periods; harmless (the second close locks only). Refusing exact duplicates is an option |
| 12 | Each close into members' capital rounds on its own (ties to the earlier member) | CHANGE (pending) | Found 2026-10-13 with #7: twelve monthly 50/50 closes can leave one member several cents ahead of half the year's income. Closes should split cumulatively too (year-to-date allocation minus what earlier closes gave); a new journaled rounding rule, older closes replaying as booked | | 12 | Each close into members' capital rounds on its own (ties to the earlier member) | CHANGED 2026-10-13 | Twelve monthly 50/50 closes could leave one member several cents ahead. A run of members closes now splits cumulatively (rules.md B-13; Lean `runPart_telescopes`; property `each_member_stays_within_a_cent_of_their_share` fails on the per-close rule); journaled `"cumulative"`, older closes replay as booked |
Questions are settled from the sources, not by any one company's CPA Questions are settled from the sources, not by any one company's CPA
(wanderbooks is general software). Where the standards leave a real (wanderbooks is general software). Where the standards leave a real
+7
View File
@@ -124,6 +124,13 @@ sequence of commands, of any length**:
other shares were rounded, and only the last share changes. other shares were rounded, and only the last share changes.
- Kani `plug_last_sums_exactly` · Lean `plugLast_total`, - Kani `plug_last_sums_exactly` · Lean `plugLast_total`,
`plugLast_keeps` `plugLast_keeps`
17. **A run of member closes telescopes**: each member's total from a run
of cumulative closes is their allocation of everything the run closed
(so its rounding is one allocation's, never a sum of them), and each
close hands out exactly its net, for any allocation whose parts sum to
the whole (rules.md B-13).
- Lean `runPart_telescopes`, `closePart_total` · property
`each_member_stays_within_a_cent_of_their_share`
## What the proofs found ## What the proofs found
+2
View File
@@ -19,6 +19,8 @@ open Wanderbooks
#print axioms net_closings_is_net_income #print axioms net_closings_is_net_income
#print axioms plugLast_total #print axioms plugLast_total
#print axioms plugLast_keeps #print axioms plugLast_keeps
#print axioms runPart_telescopes
#print axioms closePart_total
#print axioms Reachable.inv #print axioms Reachable.inv
#print axioms Reachable.books_net_zero #print axioms Reachable.books_net_zero
#print axioms Reachable.trial_balance #print axioms Reachable.trial_balance
+50
View File
@@ -317,4 +317,54 @@ theorem plugLast_keeps (whole : Int) (ps : List Int) :
rw [show plugLast whole (p :: q :: rest) = p :: plugLast (whole - p) (q :: rest) from rfl, rw [show plugLast whole (p :: q :: rest) = p :: plugLast (whole - p) (q :: rest) from rfl,
List.dropLast_cons_of_ne_nil hne, ih, List.dropLast_cons_cons] List.dropLast_cons_of_ne_nil hne, ih, List.dropLast_cons_cons]
/-! ## Cumulative member closes
A run of members closes (`CloseTo::Members`, rules.md B-13) gives each
member, at each close, their allocation of the run's total so far minus
their allocation of the total before it. `alloc` is any allocation of an
amount to members (member `i` gets `alloc x i`), such as
`periods::allocate`. -/
/-- What one close gives member `i`: `before` is the run's total before
it, `net` what it closes. -/
def closePart (alloc : Int → Nat → Int) (before net : Int) (i : Nat) : Int :=
alloc (before + net) i - alloc before i
/-- What a run of closes with these nets, starting after `before`, gives
member `i` in all. -/
def runPart (alloc : Int → Nat → Int) : Int → List Int → Nat → Int
| _, [], _ => 0
| before, net :: rest, i => closePart alloc before net i + runPart alloc (before + net) rest i
/-- **A run telescopes**: member `i`'s total from a run is their allocation
of everything the run closed, less their allocation of where it started.
From an empty start (`alloc 0 = 0`), it is exactly their allocation of
the run's total, so its rounding is one allocation's rounding, never a
sum of them. -/
theorem runPart_telescopes (alloc : Int → Nat → Int) (before : Int) (nets : List Int) (i : Nat) :
runPart alloc before nets i = alloc (before + nets.sum) i - alloc before i := by
induction nets generalizing before with
| nil => simp [runPart]
| cons n rest ih =>
simp only [runPart, closePart, ih, List.sum_cons]
rw [show before + n + rest.sum = before + (n + rest.sum) by omega]
omega
/-- A sum of differences is the difference of the sums. -/
theorem sum_map_sub (f g : Nat → Int) (ms : List Nat) :
(ms.map (fun i => f i - g i)).sum = (ms.map f).sum - (ms.map g).sum := by
induction ms with
| nil => simp
| cons m ms ih => simp only [List.map_cons, List.sum_cons, ih]; omega
/-- **Each close hands out exactly its net** when every allocation sums
to its amount (`periods::allocate` does: `kernel::apportion`). -/
theorem closePart_total (alloc : Int → Nat → Int) (members : List Nat)
(sums : ∀ x, (members.map (alloc x)).sum = x) (before net : Int) :
(members.map (closePart alloc before net)).sum = net := by
have h := sum_map_sub (alloc (before + net)) (alloc before) members
unfold closePart
rw [h, sums, sums]
omega
end Wanderbooks end Wanderbooks