fix: period close sweeps only through the period end; partnerships close into members' capital
The close zeroed every revenue and expense account's all-time movement, so closing a period after later entries were posted swept those too: close January in March and February's and March's income landed in January's closing entry. The preview had the same bug and never showed where the net went. Now: - close_period_to(id, to) zeroes balances as of the period end; entries dated later stay open for their own period. Periods must close in order (LedgerError::CloseOutOfOrder): a later close has already swept the earlier period. - CloseTo::Members: for a partnership (multi-member LLC) the net goes to each member's capital account by share, to the cent, the last member absorbing rounding, which is what K-1 capital accounts track. CloseTo::RetainedEarnings (3999) stays the default; close_period is shorthand for it. books.close_period picks Members when wanderbooks.toml lists members. - The journal records the close target; replay re-derives the closing entry and now compares every line with the journal, not just the id, so a change to closing math can never silently rewrite history. - The tool's preview is the real close run on a clone of the ledger (Ledger now derives Clone), so preview and post can't disagree. - Members' equity: closing entries into a capital account are income, not contributions. Balance sheet for a partnership: capital accounts (with allocated earnings), then only unallocated earnings — "Undistributed earnings, prior years" if any, and this year's unclosed net income (loss). periods::allocate is shared by the close and the statement. Tests: January closed after February is posted sweeps exactly Dr 4000 2,500 / Cr 6100 800 / Cr 3999 1,700 (mutation-checked: the old code gives 3,500/120/800/2,580); out-of-order close refused; a partnership close splits 666.67 into 333.34/333.33 with no 3999, and the members' equity statement reads correctly before and after; store replay of a members close (reopen, rebuild, verify); partnership and corporate balance sheet layouts; stdio close preview equals the posted lines. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01MtYEaR6M6Ybdhm6X98BWNu
This commit is contained in:
15 files changed
+713
-217
No files matched your search
@@ -12,9 +12,9 @@ use crate::journal::{JournalLine, JournalWriter, read_and_verify_journal};
|
||||
use crate::schema;
|
||||
use chrono::{Months, NaiveDate};
|
||||
use ledger_core::{
|
||||
AccountId, AccountType, AssetId, AssetStatus, BillId, Class, ClassId, Currency, CustomerId,
|
||||
DocumentLine, EntryId, EntryStatus, InvoiceId, Ledger, LedgerError, Money, NewAsset,
|
||||
PayrollLine, PayrollRun, PayrollRunId, PeriodId, Side, VendorId, arap,
|
||||
AccountId, AccountType, AssetId, AssetStatus, BillId, Class, ClassId, CloseTo, Currency,
|
||||
CustomerId, DocumentLine, EntryId, EntryStatus, InvoiceId, Ledger, LedgerError, Money,
|
||||
NewAsset, PayrollLine, PayrollRun, PayrollRunId, PeriodId, Side, VendorId, arap,
|
||||
payroll::{PayrollAccounts, payroll_entry_plan, payroll_remit_plan},
|
||||
reports,
|
||||
};
|
||||
@@ -955,14 +955,32 @@ impl Store {
|
||||
/// re-runs the deterministic close logic and checks the closing entry
|
||||
/// id against the journal.
|
||||
pub fn close_period(&mut self, id: PeriodId) -> Result<Option<EntryId>, StoreError> {
|
||||
self.close_period_to(id, &CloseTo::RetainedEarnings)
|
||||
}
|
||||
|
||||
/// Close a period with its net income going to `to`: Retained Earnings,
|
||||
/// or members' capital accounts by share (see
|
||||
/// [`ledger_core::Ledger::close_period_to`]). The target rides in the
|
||||
/// journal so replay re-derives the identical closing entry.
|
||||
pub fn close_period_to(
|
||||
&mut self,
|
||||
id: PeriodId,
|
||||
to: &CloseTo,
|
||||
) -> Result<Option<EntryId>, StoreError> {
|
||||
// Pure pre-validation: a doomed close journals nothing, not even
|
||||
// the Retained Earnings account creation below.
|
||||
self.ledger.preview_close_period(id)?;
|
||||
// Retained Earnings must exist before the close so its creation (if
|
||||
// needed) is journaled as account.added; the close's own ensure
|
||||
// then no-ops and replay stays deterministic.
|
||||
self.ensure_control_account("3999", "Retained Earnings", AccountType::Equity)?;
|
||||
let closing_entry = self.ledger.close_period(id)?;
|
||||
if *to == CloseTo::RetainedEarnings {
|
||||
// Retained Earnings must exist before the close so its creation
|
||||
// (if needed) is journaled as account.added; the close's own
|
||||
// ensure then no-ops and replay stays deterministic.
|
||||
self.ensure_control_account(
|
||||
ledger_core::RETAINED_EARNINGS_CODE,
|
||||
"Retained Earnings",
|
||||
AccountType::Equity,
|
||||
)?;
|
||||
}
|
||||
let closing_entry = self.ledger.close_period_to(id, to)?;
|
||||
let entry_detail = match closing_entry {
|
||||
Some(eid) => {
|
||||
let e = self
|
||||
@@ -978,11 +996,21 @@ impl Store {
|
||||
}
|
||||
None => None,
|
||||
};
|
||||
let close_to = match to {
|
||||
CloseTo::RetainedEarnings => json!("retained_earnings"),
|
||||
CloseTo::Members(members) => json!({
|
||||
"members": members
|
||||
.iter()
|
||||
.map(|(account, share)| json!({"account": account, "share": share.to_string()}))
|
||||
.collect::<Vec<_>>(),
|
||||
}),
|
||||
};
|
||||
let line = self.append_journal(
|
||||
"period.closed",
|
||||
json!({
|
||||
"id": id,
|
||||
"closing_entry": closing_entry,
|
||||
"close_to": close_to,
|
||||
"entry": entry_detail,
|
||||
}),
|
||||
)?;
|
||||
@@ -2738,15 +2766,44 @@ fn replay_line_into_ledger(
|
||||
let id = get_u64(p, "id")?;
|
||||
let want = p.get("closing_entry").and_then(|v| v.as_u64());
|
||||
// Deterministic: the close re-derives the closing entry from
|
||||
// the replayed state, so it must match the journaled id. The
|
||||
// journaled entry detail is for the projection; the ledger
|
||||
// re-derives the entry itself.
|
||||
let got = ledger.close_period(id)?;
|
||||
// the replayed state and the journaled target (absent: Retained
|
||||
// Earnings), so it must match the journal's id and every line.
|
||||
let to = match p.get("close_to").and_then(|v| v.get("members")) {
|
||||
None => ledger_core::CloseTo::RetainedEarnings,
|
||||
Some(members) => ledger_core::CloseTo::Members(
|
||||
members
|
||||
.as_array()
|
||||
.ok_or_else(|| StoreError::JournalCorrupt("close_to.members".into()))?
|
||||
.iter()
|
||||
.map(|m| {
|
||||
let share = get_str(m, "share")?.parse().map_err(|_| {
|
||||
StoreError::JournalCorrupt("close_to member share".into())
|
||||
})?;
|
||||
Ok((get_u64(m, "account")?, share))
|
||||
})
|
||||
.collect::<Result<_, StoreError>>()?,
|
||||
),
|
||||
};
|
||||
let got = ledger.close_period_to(id, &to)?;
|
||||
if got != want {
|
||||
return Err(StoreError::ReplayMismatch(format!(
|
||||
"closing entry: journal says {want:?}, replay got {got:?}"
|
||||
)));
|
||||
}
|
||||
if let Some(eid) = got {
|
||||
let replayed: Vec<Value> = ledger
|
||||
.get_entry(eid)
|
||||
.expect("close posted it")
|
||||
.lines()
|
||||
.iter()
|
||||
.map(journal_line_json)
|
||||
.collect();
|
||||
if p["entry"]["lines"].as_array() != Some(&replayed) {
|
||||
return Err(StoreError::ReplayMismatch(format!(
|
||||
"closing entry {eid}: replayed lines differ from the journal"
|
||||
)));
|
||||
}
|
||||
}
|
||||
}
|
||||
"line.cleared" => {
|
||||
ledger.mark_cleared(
|
||||
|
||||
@@ -457,3 +457,72 @@ fn receipt_corrections_are_journaled_and_survive_rebuild() {
|
||||
check(&store);
|
||||
store.verify().unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_close_into_members_capital_replays_exactly() {
|
||||
let dir = test_dir("members-close");
|
||||
let mut store = Store::create(&dir, Currency::USD).unwrap();
|
||||
let cash = store
|
||||
.add_account("1000", "Cash", AccountType::Asset, None, false)
|
||||
.unwrap();
|
||||
let alice = store
|
||||
.add_account("3000", "Capital — Alice", AccountType::Equity, None, false)
|
||||
.unwrap();
|
||||
let bob = store
|
||||
.add_account("3001", "Capital — Bob", AccountType::Equity, None, false)
|
||||
.unwrap();
|
||||
let revenue = store
|
||||
.add_account("4000", "Revenue", AccountType::Revenue, None, false)
|
||||
.unwrap();
|
||||
let jan15 = NaiveDate::from_ymd_opt(2026, 1, 15).unwrap();
|
||||
let e = store.create_entry(jan15, "Sale").unwrap();
|
||||
store
|
||||
.add_line(
|
||||
e,
|
||||
cash,
|
||||
Side::Debit,
|
||||
Money::new(Decimal::new(10001, 2), Currency::USD),
|
||||
)
|
||||
.unwrap();
|
||||
store
|
||||
.add_line(
|
||||
e,
|
||||
revenue,
|
||||
Side::Credit,
|
||||
Money::new(Decimal::new(10001, 2), Currency::USD),
|
||||
)
|
||||
.unwrap();
|
||||
store.post_entry(e).unwrap();
|
||||
let jan = store
|
||||
.create_period(
|
||||
"2026-01",
|
||||
NaiveDate::from_ymd_opt(2026, 1, 1).unwrap(),
|
||||
NaiveDate::from_ymd_opt(2026, 1, 31).unwrap(),
|
||||
)
|
||||
.unwrap();
|
||||
let half = Decimal::new(5, 1);
|
||||
store
|
||||
.close_period_to(
|
||||
jan,
|
||||
&ledger_core::CloseTo::Members(vec![(alice, half), (bob, half)]),
|
||||
)
|
||||
.unwrap();
|
||||
// 100.01 splits 50.01 / 50.00 into the capital accounts.
|
||||
let balances = |s: &Store| {
|
||||
(
|
||||
s.ledger().account_balance(alice).unwrap(),
|
||||
s.ledger().account_balance(bob).unwrap(),
|
||||
)
|
||||
};
|
||||
let want = (Decimal::new(5001, 2), Decimal::new(5000, 2));
|
||||
assert_eq!(balances(&store), want);
|
||||
let journal = std::fs::read_to_string(dir.join("journal.jsonl")).unwrap();
|
||||
assert!(journal.contains("\"close_to\":{\"members\""));
|
||||
|
||||
drop(store);
|
||||
let mut store = Store::open(&dir).unwrap();
|
||||
assert_eq!(balances(&store), want);
|
||||
store.rebuild().unwrap();
|
||||
assert_eq!(balances(&store), want);
|
||||
store.verify().unwrap();
|
||||
}
|
||||
Reference in new issue
Block a user