P3: odd cents split the same running way everywhere (defect 17, D-38)
check / check (push) Canceled after 0s
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:
44 files changed
+6636
-195
No files matched your search
@@ -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 }))
|
||||
|
||||
@@ -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`.
|
||||
|
||||
Reference in new issue
Block a user