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
@@ -3,3 +3,4 @@
|
||||
- Reports are pure functions of the posted entries. When the human questions a figure, trace it to its entries with the tools: the report (books.trial_balance, books.profit_and_loss, books.balance_sheet, books.cash_flow), then books.search_entries and books.get_entry for the entries behind it, and books.show to put them on the human's screen.
|
||||
- Never guess, and never look for the books' files: the tools are the evidence.
|
||||
- books.verify checks the books end to end when a number looks wrong or a human asks whether the books are intact.
|
||||
- A member's odd cent: closes, the statement of members' equity, the K-1 worksheet and the viewer split earnings among the members one running way. Each close gives a member their share of everything closed to the same members and shares so far, to the cent (odd cents by largest remainder, ties to the member listed first when that count began), less what earlier closes gave them, so no member is ever a cent from their exact share. Earnings not yet closed are split the way their close will split them, so a member's capital on a day never changes when its month is closed. books.close_period's members shows each member's part, running total and exact share; the first close that posts may carry a one-time correction of the rounding left by closes made under an earlier rule (a cent or so per member; each member's this_close shows it) (books.list_periods' close_rule names each close's rule). A change of members or shares starts a new count.
|
||||
@@ -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`.
|
||||
|
||||
@@ -1690,6 +1690,243 @@ fn mcp_partnership_from_template_over_stdio() {
|
||||
);
|
||||
}
|
||||
|
||||
/// Contract (rules.md B-13, D-10; docs/mcp-interface.md close_period and
|
||||
/// list_periods): a close to the members shows each member's running
|
||||
/// split, the same in the preview as once confirmed, and the periods say
|
||||
/// which rule they closed under. Two members at 50% each, hand-computed:
|
||||
/// January's cent is 0.005 each exactly, the tie to Ann (listed first):
|
||||
/// Ann 0.01, Bo 0.00. February's cent makes the running 0.02, 0.01 each,
|
||||
/// so February gives Bo 0.01. After a restart, both closes count from
|
||||
/// close 1, the books verify, and the statement of members' equity shows
|
||||
/// 0.01 each.
|
||||
#[test]
|
||||
fn close_period_shows_each_members_running_split() {
|
||||
let dir = test_dir("members-split");
|
||||
let mut scratch = Mcp::spawn(&dir.join("scratch"));
|
||||
init(&mut scratch);
|
||||
let t = scratch.call(
|
||||
"books.chart_template",
|
||||
json!({"entity_type": "multi-member LLC", "owners": ["Ann", "Bo"]}),
|
||||
);
|
||||
let settings = t["settings_toml"].as_str().unwrap().to_string();
|
||||
let toml = dir.join("wanderbooks.toml");
|
||||
std::fs::write(
|
||||
&toml,
|
||||
format!("{settings}\n[books]\nreceipt_threshold = \"10000\"\n"),
|
||||
)
|
||||
.unwrap();
|
||||
let spawn = || {
|
||||
let mut mcp = Mcp::spawn_with(
|
||||
&dir.join("books-data"),
|
||||
&[std::ffi::OsStr::new("--books-toml"), toml.as_os_str()],
|
||||
);
|
||||
init(&mut mcp);
|
||||
mcp
|
||||
};
|
||||
let mut mcp = spawn();
|
||||
mcp.call("books.import_chart_csv", json!({"csv_text": t["csv"]}));
|
||||
let sale = |mcp: &mut Mcp, date: &str| {
|
||||
let e = mcp.call(
|
||||
"books.draft_entry",
|
||||
json!({"date": date, "description": "a cent"}),
|
||||
);
|
||||
let id = e["entry_id"].as_u64().unwrap();
|
||||
for (account, side) in [("1000", "debit"), ("4000", "credit")] {
|
||||
mcp.call(
|
||||
"books.add_line",
|
||||
json!({"entry_id": id, "account": account, "side": side, "amount": "0.01"}),
|
||||
);
|
||||
}
|
||||
mcp.call(
|
||||
"books.post_entry",
|
||||
json!({"entry_id": id, "confirmed": true}),
|
||||
);
|
||||
};
|
||||
let member = |name: &str, code: &str, this: &str, running: &str, exact: &str| {
|
||||
json!({"name": name, "capital_account": code, "share": "0.50",
|
||||
"this_close": this, "running": running, "exact": exact})
|
||||
};
|
||||
|
||||
sale(&mut mcp, "2026-01-10");
|
||||
let jan = mcp.call(
|
||||
"books.create_period",
|
||||
json!({"name": "2026-01", "start": "2026-01-01", "end": "2026-01-31"}),
|
||||
);
|
||||
let preview = mcp.call(
|
||||
"books.close_period",
|
||||
json!({"period_id": jan["period_id"], "confirmed": false}),
|
||||
);
|
||||
assert_eq!(
|
||||
preview["members"],
|
||||
json!({"since_close": 1, "members": [
|
||||
member("Ann", "3000", "0.01", "0.01", "0.005"),
|
||||
member("Bo", "3001", "0.00", "0.00", "0.005"),
|
||||
]})
|
||||
);
|
||||
assert_eq!(
|
||||
preview["lines"],
|
||||
json!(["Debit 4000 0.01", "Credit 3000 0.01"])
|
||||
);
|
||||
let closed = mcp.call(
|
||||
"books.close_period",
|
||||
json!({"period_id": jan["period_id"], "confirmed": true}),
|
||||
);
|
||||
assert_eq!(closed["members"], preview["members"]);
|
||||
assert_eq!(closed["lines"], preview["lines"]);
|
||||
|
||||
sale(&mut mcp, "2026-02-10");
|
||||
let feb = mcp.call(
|
||||
"books.create_period",
|
||||
json!({"name": "2026-02", "start": "2026-02-01", "end": "2026-02-28"}),
|
||||
);
|
||||
let closed = mcp.call(
|
||||
"books.close_period",
|
||||
json!({"period_id": feb["period_id"], "confirmed": true}),
|
||||
);
|
||||
assert_eq!(
|
||||
closed["members"],
|
||||
json!({"since_close": 1, "members": [
|
||||
member("Ann", "3000", "0.00", "0.01", "0.01"),
|
||||
member("Bo", "3001", "0.01", "0.01", "0.01"),
|
||||
]})
|
||||
);
|
||||
assert_eq!(
|
||||
closed["lines"],
|
||||
json!(["Debit 4000 0.01", "Credit 3001 0.01"])
|
||||
);
|
||||
drop(mcp);
|
||||
|
||||
let mut mcp = spawn();
|
||||
assert_eq!(mcp.call("books.verify", json!({}))["status"], "ok");
|
||||
let periods = mcp.call("books.list_periods", json!({}));
|
||||
let rules: Vec<&Value> = periods["periods"]
|
||||
.as_array()
|
||||
.unwrap()
|
||||
.iter()
|
||||
.map(|p| &p["close_rule"])
|
||||
.collect();
|
||||
assert_eq!(
|
||||
rules,
|
||||
[
|
||||
&json!("members_cumulative_since:1"),
|
||||
&json!("members_cumulative_since:1")
|
||||
]
|
||||
);
|
||||
let me = mcp.call(
|
||||
"books.export",
|
||||
json!({"format": "json", "report": "members_equity", "start": "2026-01-01", "end": "2026-02-28"}),
|
||||
);
|
||||
let me: Value = serde_json::from_str(me["content"].as_str().unwrap()).unwrap();
|
||||
assert_eq!(me["members"][0]["ending"], "0.01");
|
||||
assert_eq!(me["members"][1]["ending"], "0.01");
|
||||
}
|
||||
|
||||
/// Contract (rules.md B-13, D-10; docs/mcp-interface.md list_periods and
|
||||
/// close_period) on books an earlier version wrote: ledger-store's
|
||||
/// old-books fixture x_member_closes (two members at 50%, A with a
|
||||
/// distributions account; three closes untagged, three
|
||||
/// "largest_remainder", then "cumulative" broken by a close to retained
|
||||
/// earnings), opened by the server. list_periods names each close's rule;
|
||||
/// December's first close under the rule since P3 counts from close 1
|
||||
/// and carries the true-up, the same in the preview as once confirmed.
|
||||
/// By hand (the fixture's writer): the members closed 0.12, A 0.07 and B
|
||||
/// 0.05 of it (exact 0.06 each). December earns 0.02: the running 0.14
|
||||
/// splits 0.07 each, less 0.07 / 0.05 given, so Ann gets 0.00 and Bo
|
||||
/// 0.02, both at their exact 0.07.
|
||||
#[test]
|
||||
fn old_books_name_each_closes_rule_and_true_up_at_the_next_close() {
|
||||
let dir = test_dir("members-old");
|
||||
let fixture = std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
|
||||
.join("../ledger-store/tests/fixtures/old-books/x_member_closes/journal.jsonl");
|
||||
let books = dir.join("books-data");
|
||||
std::fs::create_dir_all(&books).unwrap();
|
||||
std::fs::copy(&fixture, books.join("journal.jsonl")).unwrap();
|
||||
let toml = dir.join("wanderbooks.toml");
|
||||
std::fs::write(
|
||||
&toml,
|
||||
"[company]\ntax_form = \"1065\"\n\n\
|
||||
[[company.members]]\nname = \"Ann\"\nshare = \"50%\"\ncapital_account = \"3000\"\n\
|
||||
distribution_account = \"3100\"\n\n\
|
||||
[[company.members]]\nname = \"Bo\"\nshare = \"50%\"\ncapital_account = \"3001\"\n\n\
|
||||
[books]\nreceipt_threshold = \"10000\"\n",
|
||||
)
|
||||
.unwrap();
|
||||
let mut mcp = Mcp::spawn_with(
|
||||
&books,
|
||||
&[std::ffi::OsStr::new("--books-toml"), toml.as_os_str()],
|
||||
);
|
||||
init(&mut mcp);
|
||||
let rules = |mcp: &mut Mcp| -> Vec<Value> {
|
||||
mcp.call("books.list_periods", json!({}))["periods"]
|
||||
.as_array()
|
||||
.unwrap()
|
||||
.iter()
|
||||
.map(|p| p["close_rule"].clone())
|
||||
.collect()
|
||||
};
|
||||
let old = [
|
||||
"members_last_absorbs",
|
||||
"members_last_absorbs",
|
||||
"members_last_absorbs",
|
||||
"members_per_close",
|
||||
"members_per_close",
|
||||
"members_per_close",
|
||||
"members_run",
|
||||
"members_run",
|
||||
];
|
||||
let mut want: Vec<Value> = old.iter().map(|r| json!(r)).collect();
|
||||
want.extend([Value::Null, json!("members_run"), json!("members_run")]);
|
||||
assert_eq!(rules(&mut mcp), want);
|
||||
|
||||
let e = mcp.call(
|
||||
"books.draft_entry",
|
||||
json!({"date": "2026-12-10", "description": "two cents"}),
|
||||
);
|
||||
let id = e["entry_id"].as_u64().unwrap();
|
||||
for (account, side) in [("1000", "debit"), ("4000", "credit")] {
|
||||
mcp.call(
|
||||
"books.add_line",
|
||||
json!({"entry_id": id, "account": account, "side": side, "amount": "0.02"}),
|
||||
);
|
||||
}
|
||||
mcp.call(
|
||||
"books.post_entry",
|
||||
json!({"entry_id": id, "confirmed": true}),
|
||||
);
|
||||
let dec = mcp.call(
|
||||
"books.create_period",
|
||||
json!({"name": "2026-12", "start": "2026-12-01", "end": "2026-12-31"}),
|
||||
);
|
||||
let preview = mcp.call(
|
||||
"books.close_period",
|
||||
json!({"period_id": dec["period_id"], "confirmed": false}),
|
||||
);
|
||||
let member = |name: &str, code: &str, this: &str| {
|
||||
json!({"name": name, "capital_account": code, "share": "0.50",
|
||||
"this_close": this, "running": "0.07", "exact": "0.07"})
|
||||
};
|
||||
assert_eq!(
|
||||
preview["members"],
|
||||
json!({"since_close": 1, "members": [
|
||||
member("Ann", "3000", "0.00"),
|
||||
member("Bo", "3001", "0.02"),
|
||||
]})
|
||||
);
|
||||
assert_eq!(
|
||||
preview["lines"],
|
||||
json!(["Debit 4000 0.02", "Credit 3001 0.02"])
|
||||
);
|
||||
let closed = mcp.call(
|
||||
"books.close_period",
|
||||
json!({"period_id": dec["period_id"], "confirmed": true}),
|
||||
);
|
||||
assert_eq!(closed["members"], preview["members"]);
|
||||
assert_eq!(closed["lines"], preview["lines"]);
|
||||
want.push(json!("members_cumulative_since:1"));
|
||||
assert_eq!(rules(&mut mcp), want);
|
||||
assert_eq!(mcp.call("books.verify", json!({}))["status"], "ok");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mcp_schedules_and_recurring_over_stdio() {
|
||||
let dir = test_dir("schedules");
|
||||
|
||||
Reference in new issue
Block a user