P3: odd cents split the same running way everywhere (defect 17, D-38)
check / check (push) Canceled after 0s

Members' shares of earnings are split cumulatively by largest remainder in
the closes, the members' equity statement, the K-1 worksheet and the
viewer, so a member's capital on a given day never moves by a cent when its
month is later closed. Old closes replay as they were; the first P3 close
carries any true-up. Feature members_cumulative. Kani and Lean proofs of
the bounded drift; bookspec examples.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01T3gXMVt7XJPi6ens1or9Dj
This commit is contained in:
swaitsandClaude Opus 5.5 committed 2026-10-20 21:35:00 -06:00
1 parent 2390dc6553
commit a7f3cc09a9
44 files changed
+6636 -195

No files matched your search

+40 -2
View File
@@ -5957,7 +5957,7 @@ impl BooksServer {
#[tool(
name = "books.close_period",
title = "Close a period",
description = "Close an accounting period. The closing entry, dated at period end, zeroes revenue and expense balances as of that date into Retained Earnings, or, for a multi-member LLC (members in the settings), into each member's capital account by share. Periods close in order. Without confirmed=true, returns the exact closing entry it would post and closes nothing. With confirmed=true, posts it and marks the period closed. Closed periods reject new entries.",
description = "Close an accounting period. The closing entry, dated at period end, zeroes revenue and expense balances into Retained Earnings or, for a multi-member LLC (members in the settings), into each member's capital account by share, cumulatively: each close gives a member their share of everything closed to the members so far, less what earlier closes gave them (odd cents by largest remainder), so no member is ever a cent off their exact share; a close may carry a one-time correction of earlier rounding. Periods close in order. Without confirmed=true, returns the exact closing entry and members' split, closing nothing; with confirmed=true, posts it and closes the period to new entries.",
annotations(
read_only_hint = false,
destructive_hint = true,
@@ -5992,6 +5992,34 @@ impl BooksServer {
})
.collect()
};
// The members' split, from the closed ledger (rules.md B-13).
let split = |ledger: &Ledger| -> Option<MembersCloseOut> {
let (since_close, parts) = ledger.member_close_split(p.period_id)?;
let names = self.member_capitals(ledger).unwrap_or_default();
let members = parts
.iter()
.map(|m| MemberCloseOut {
name: names
.iter()
.find(|c| c.capital_account == m.capital)
.map(|c| c.name.clone())
.unwrap_or_default(),
capital_account: ledger
.chart()
.get(m.capital)
.map(|a| a.code.clone())
.unwrap_or_default(),
share: ledger_package::share_text(m.share),
this_close: money(m.part),
running: money(m.running),
exact: money(m.exact.normalize()),
})
.collect();
Some(MembersCloseOut {
since_close,
members,
})
};
if !p.confirmed {
// Preview: run the real close on a copy of the ledger, so the
// preview is exactly what confirming would post.
@@ -6004,6 +6032,7 @@ impl BooksServer {
period_id: p.period_id,
closing_entry: None,
lines: describe(&scratch, closing),
members: split(&scratch),
}));
}
let closing = store
@@ -6015,13 +6044,14 @@ impl BooksServer {
period_id: p.period_id,
closing_entry: closing,
lines,
members: split(store.ledger()),
}))
}
#[tool(
name = "books.list_periods",
title = "List periods",
description = "List all accounting periods with their status.",
description = "List all accounting periods with their status, and for a period closed to the members, the rounding rule it was closed under.",
annotations(read_only_hint = true, open_world_hint = false)
)]
async fn list_periods(&self) -> Result<Json<ListPeriodsOut>, String> {
@@ -6040,6 +6070,14 @@ impl BooksServer {
PeriodStatus::Closed => "closed".to_string(),
},
closing_entry: p.closing_entry,
close_rule: p.members_closed.as_ref().map(|m| match m.rule {
ledger_core::MemberRule::Cumulative { since_seq } => {
format!("members_cumulative_since:{since_seq}")
}
ledger_core::MemberRule::Run => "members_run".to_string(),
ledger_core::MemberRule::PerClose => "members_per_close".to_string(),
ledger_core::MemberRule::LastAbsorbs => "members_last_absorbs".to_string(),
}),
})
.collect();
Ok(Json(ListPeriodsOut { periods }))
+47
View File
@@ -2162,6 +2162,46 @@ pub struct ClosePeriodOut {
pub closing_entry: Option<u64>,
/// Human-readable summary of the closing lines.
pub lines: Vec<String>,
/// For a close to the members: how it splits among them (the same in
/// the preview and once confirmed).
#[serde(default)]
pub members: Option<MembersCloseOut>,
}
/// How a close splits the net among the members: cumulatively, each
/// member's share of everything closed to these members and shares since
/// `since_close`, to the minor unit, less what earlier closes gave them.
#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
pub struct MembersCloseOut {
/// The close the count starts at: the first members close with these
/// members and shares (1 is the books' first close of all).
pub since_close: u64,
/// Each member, in the settings' order.
pub members: Vec<MemberCloseOut>,
}
/// One member's part of a close (`books.close_period`).
#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
pub struct MemberCloseOut {
/// The member's name, as in the settings.
pub name: String,
/// Their capital account's code.
pub capital_account: String,
/// Their share, as a decimal fraction ("0.50").
pub share: String,
/// What this close credits their capital (negative: debits), a true-up
/// of earlier rounding included. It can run against the period's
/// result by a cent.
pub this_close: String,
/// What the count's closes have credited them in all, this one
/// included.
pub running: String,
/// Their exact share of everything the count has closed (share ×
/// total, unrounded): `running` is within one minor unit of it once a
/// close of the count has posted under the cumulative rule (before
/// that, a close that posted nothing still carries the rounding of
/// closes made under an earlier rule).
pub exact: String,
}
/// One period in `books.list_periods`.
@@ -2179,6 +2219,13 @@ pub struct PeriodOut {
pub status: String,
/// Closing entry id, if closed with one.
pub closing_entry: Option<u64>,
/// For a period closed to the members, the rounding rule it was closed
/// under: "members_cumulative_since:N" (counted from close N, the rule
/// since P3), "members_run" (2026-10-13 to P3), "members_per_close"
/// (2026-10-13) or "members_last_absorbs" (before 2026-10-13). Absent
/// (null) otherwise.
#[serde(default)]
pub close_rule: Option<String>,
}
/// Output of `books.list_periods`.