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:
swaitsandClaude Opus 5.5 committed 2026-10-11 20:39:17 -07:00
1 parent f591264533
commit 8164abb2b8
15 files changed
+713 -217

No files matched your search

+4
View File
@@ -166,6 +166,10 @@ pub enum LedgerError {
#[error("cannot close period {0}: draft entries exist with dates in the period")]
DraftsInPeriod(u64),
/// A period can't close while a later period is already closed.
#[error("period {0} can't close: later period {1} is already closed (close in order)")]
CloseOutOfOrder(u64, u64),
/// Entries cannot be created or posted in a closed period.
#[error("period {0} is closed; entries dated {1} are rejected")]
ClosedPeriod(u64, String),
+142 -86
View File
@@ -57,7 +57,7 @@ pub struct ReconciliationReport {
}
/// The books. Single-currency (see R-5); owns id allocation.
#[derive(Debug)]
#[derive(Clone, Debug)]
pub struct Ledger {
base_currency: Currency,
chart: ChartOfAccounts,
@@ -587,114 +587,161 @@ impl Ledger {
.collect()
}
/// Close a period into Retained Earnings: see [`Self::close_period_to`].
pub fn close_period(&mut self, id: PeriodId) -> Result<Option<EntryId>, LedgerError> {
self.close_period_to(id, &periods::CloseTo::RetainedEarnings)
}
/// Close a period. This:
/// 1. Rejects if the period is already closed.
/// 2. Rejects if any draft entries have dates inside the period.
/// 3. Posts a closing entry dated at period end: revenue accounts are
/// debited to zero, expense accounts are credited to zero, and the
/// net goes to Retained Earnings (account `3999`, created if missing).
/// 4. Marks the period closed.
/// 1. Rejects if the period is already closed, if any draft is dated
/// inside it, or if a later period is already closed (closing out of
/// order would sweep the same income twice).
/// 2. Posts a closing entry dated at period end that zeroes every revenue
/// and expense account's balance *as of the period end* (entries
/// dated after it stay open for their own period): revenue debited,
/// expense credited, and the net to `to` — Retained Earnings (account
/// `3999`, created if missing), or members' capital accounts by
/// share for a partnership, to the cent with the last member
/// absorbing rounding.
/// 3. Marks the period closed.
///
/// Returns the closing entry's id, or `None` if there was nothing to
/// close (no revenue/expense movement).
pub fn close_period(&mut self, id: PeriodId) -> Result<Option<EntryId>, LedgerError> {
let period = self
.periods
.get(&id)
.ok_or(LedgerError::UnknownPeriod(id))?
.clone();
if period.status == PeriodStatus::Closed {
return Err(LedgerError::PeriodAlreadyClosed(id));
}
// No drafts may have dates inside the period.
let has_drafts = self
.entries
.values()
.any(|e| e.status() == EntryStatus::Draft && period.contains(e.date()));
if has_drafts {
return Err(LedgerError::DraftsInPeriod(id));
/// close (no revenue/expense balance at period end).
pub fn close_period_to(
&mut self,
id: PeriodId,
to: &periods::CloseTo,
) -> Result<Option<EntryId>, LedgerError> {
self.preview_close_period(id)?;
if let periods::CloseTo::Members(members) = to {
self.validate_close_members(members)?;
}
let period = self.periods.get(&id).expect("previewed").clone();
// Collect revenue and expense accounts with non-zero movement.
// Revenue and expense balances as of the period end.
let mut closings: Vec<(AccountId, Side, Decimal)> = Vec::new();
let mut accounts: Vec<_> = self.chart.all().collect();
accounts.sort_by(|a, b| a.code.cmp(&b.code));
for account in accounts {
match account.account_type {
AccountType::Revenue | AccountType::Expense => {
let movement = self.account_movement(account.id);
if movement.is_zero() {
continue;
}
// To zero the account: post the opposite of its net movement.
let (side, amount) = if movement > Decimal::ZERO {
(Side::Credit, movement)
} else {
(Side::Debit, -movement)
};
closings.push((account.id, side, amount));
}
_ => {}
if !matches!(
account.account_type,
AccountType::Revenue | AccountType::Expense
) {
continue;
}
let movement = self.movement_through(account.id, period.end);
if movement.is_zero() {
continue;
}
// To zero the account: post the opposite of its net movement.
let (side, amount) = if movement > Decimal::ZERO {
(Side::Credit, movement)
} else {
(Side::Debit, -movement)
};
closings.push((account.id, side, amount));
}
let closing_entry = if closings.is_empty() {
None
} else {
// Ensure Retained Earnings exists.
let re_id = match self.chart.find_by_code(periods::RETAINED_EARNINGS_CODE) {
Some(a) => a.id,
None => self.add_account(
periods::RETAINED_EARNINGS_CODE,
"Retained Earnings",
AccountType::Equity,
None,
false,
)?,
};
// Sum the closing lines to find the net for RE.
let mut debits = Decimal::ZERO;
let mut credits = Decimal::ZERO;
for (_, side, amount) in &closings {
match side {
Side::Debit => debits += *amount,
Side::Credit => credits += *amount,
// Net income: the closing debits (revenue) minus credits (expense).
let net: Decimal = closings
.iter()
.map(|(_, side, amount)| match side {
Side::Debit => *amount,
Side::Credit => -*amount,
})
.sum();
let targets: Vec<(AccountId, Decimal)> = match to {
periods::CloseTo::RetainedEarnings => {
let re_id = match self.chart.find_by_code(periods::RETAINED_EARNINGS_CODE) {
Some(a) => a.id,
None => self.add_account(
periods::RETAINED_EARNINGS_CODE,
"Retained Earnings",
AccountType::Equity,
None,
false,
)?,
};
vec![(re_id, net)]
}
}
periods::CloseTo::Members(members) => {
let shares: Vec<Decimal> = members.iter().map(|(_, s)| *s).collect();
members
.iter()
.map(|(a, _)| *a)
.zip(periods::allocate(net, &shares))
.collect()
}
};
let entry = self.create_entry(period.end, &format!("Close period {}", period.name));
for (account, side, amount) in closings {
self.add_line(entry, account, side, Money::new(amount, self.base_currency))?;
}
// Balance with Retained Earnings.
if debits > credits {
// Profit: credit RE.
self.add_line(
entry,
re_id,
Side::Credit,
Money::new(debits - credits, self.base_currency),
)?;
} else if credits > debits {
// Loss: debit RE.
self.add_line(
entry,
re_id,
Side::Debit,
Money::new(credits - debits, self.base_currency),
)?;
// Profit credits equity; a loss debits it.
for (account, share) in targets {
if share.is_zero() {
continue;
}
let (side, amount) = if share > Decimal::ZERO {
(Side::Credit, share)
} else {
(Side::Debit, -share)
};
self.add_line(entry, account, side, Money::new(amount, self.base_currency))?;
}
self.post_entry(entry)?;
Some(entry)
};
self.periods.get_mut(&id).expect("period exists").status = PeriodStatus::Closed;
self.periods
.get_mut(&id)
.expect("period exists")
.closing_entry = closing_entry;
let p = self.periods.get_mut(&id).expect("period exists");
p.status = PeriodStatus::Closed;
p.closing_entry = closing_entry;
Ok(closing_entry)
}
/// Members for a close: shares positive and summing to 1, capital
/// accounts distinct equity accounts.
fn validate_close_members(&self, members: &[(AccountId, Decimal)]) -> Result<(), LedgerError> {
let total: Decimal = members.iter().map(|(_, s)| *s).sum();
if members.is_empty()
|| total != Decimal::ONE
|| members.iter().any(|(_, s)| *s <= Decimal::ZERO)
{
return Err(LedgerError::InvalidMembers(format!(
"member shares must be positive and sum to 1, got {total}"
)));
}
let mut seen = std::collections::BTreeSet::new();
for (a, _) in members {
let account = self.chart.get(*a).ok_or(LedgerError::UnknownAccount(*a))?;
if account.account_type != AccountType::Equity || !seen.insert(*a) {
return Err(LedgerError::InvalidMembers(format!(
"capital account {} must be a distinct equity account",
account.code
)));
}
}
Ok(())
}
/// Debits minus credits on an account across posted entries dated on or
/// before `end`.
fn movement_through(&self, account: AccountId, end: NaiveDate) -> Decimal {
let mut total = Decimal::ZERO;
for entry in self.posted_entries().filter(|e| e.date() <= end) {
for line in entry.lines().iter().filter(|l| l.account == account) {
match line.side {
Side::Debit => total += line.amount.amount,
Side::Credit => total -= line.amount.amount,
}
}
}
total
}
/// Pure pre-close check for journaling stores: verifies that
/// [`Self::close_period`] would succeed — the period exists and is open,
/// no draft entries are dated inside it, and the closing entry's date
@@ -724,14 +771,23 @@ impl Ledger {
{
return Err(LedgerError::ClosedPeriod(other, period.end.to_string()));
}
// Periods close in order: a later closed period already swept this
// one's income, so closing it now would sweep it again.
if let Some(later) = self
.periods
.values()
.find(|p| p.status == PeriodStatus::Closed && p.end > period.end)
{
return Err(LedgerError::CloseOutOfOrder(id, later.id));
}
// The close posts an entry exactly when some revenue or expense
// account has non-zero movement — the same predicate close_period
// uses to decide. The entry id is the next one the allocator hands
// out; account creation (e.g. Retained Earnings) does not consume
// entry ids.
// account has a balance at period end — the same predicate
// close_period_to uses to decide. The entry id is the next one the
// allocator hands out; account creation (e.g. Retained Earnings)
// does not consume entry ids.
let any_movement = self.chart.all().any(|a| {
matches!(a.account_type, AccountType::Revenue | AccountType::Expense)
&& !self.account_movement(a.id).is_zero()
&& !self.movement_through(a.id, period.end).is_zero()
});
Ok(any_movement.then_some(self.next_entry_id))
}
+1 -1
View File
@@ -43,7 +43,7 @@ pub use journal::{EntryId, EntryStatus, JournalEntry, JournalLine};
pub use ledger::{Ledger, ReconciliationReport, UnclearedLine};
pub use money::{Currency, Money};
pub use payroll::{PayrollLine, PayrollRun, PayrollRunId};
pub use periods::{Period, PeriodId, PeriodStatus, RETAINED_EARNINGS_CODE};
pub use periods::{CloseTo, Period, PeriodId, PeriodStatus, RETAINED_EARNINGS_CODE};
pub use reports::{
BalanceSheet, BalanceSheetStatement, CashFlowLine, CashFlowParams, CashFlowStatement,
ClassProfitAndLoss, GeneralLedger, LedgerAccount, LedgerPosting, MemberCapital,
+29
View File
@@ -29,6 +29,35 @@ pub enum PeriodStatus {
Closed,
}
/// Where a period close puts the period's net income.
#[derive(Clone, Debug, PartialEq)]
pub enum CloseTo {
/// One Retained Earnings account ([`RETAINED_EARNINGS_CODE`]): the
/// corporate convention.
RetainedEarnings,
/// Members' capital accounts, by share (shares sum to 1): the
/// partnership convention, where each member's capital account carries
/// their allocated earnings.
Members(Vec<(crate::AccountId, rust_decimal::Decimal)>),
}
/// Split `amount` by `shares` to the cent; the last share absorbs rounding,
/// so the parts always sum to `amount` exactly.
pub fn allocate(
amount: rust_decimal::Decimal,
shares: &[rust_decimal::Decimal],
) -> Vec<rust_decimal::Decimal> {
use rust_decimal::RoundingStrategy::MidpointAwayFromZero;
let mut parts: Vec<_> = shares
.iter()
.map(|s| (amount * s).round_dp_with_strategy(2, MidpointAwayFromZero))
.collect();
if let Some((last, rest)) = parts.split_last_mut() {
*last = amount - rest.iter().copied().sum::<rust_decimal::Decimal>();
}
parts
}
/// Code of the Retained Earnings account that closing entries credit (or
/// debit), created on the first close if the chart lacks it.
pub const RETAINED_EARNINGS_CODE: &str = "3999";
+5 -15
View File
@@ -4,6 +4,7 @@
//! (`docs/oracle.md`) diffs: the same fixture books through two engines must
//! produce byte-identical [`snapshot`] output.
use crate::periods::allocate;
use crate::{AccountType, Class, ClassId, JournalEntry, JournalLine, Ledger, LedgerError};
use chrono::NaiveDate;
use rust_decimal::Decimal;
@@ -711,20 +712,6 @@ pub struct MembersEquityStatement {
pub total: MemberEquityColumn,
}
/// Split `amount` by `shares` to the cent; the last share absorbs rounding,
/// so the parts always sum to `amount` exactly.
fn allocate(amount: Decimal, shares: &[Decimal]) -> Vec<Decimal> {
use rust_decimal::RoundingStrategy::MidpointAwayFromZero;
let mut parts: Vec<Decimal> = shares
.iter()
.map(|s| (amount * s).round_dp_with_strategy(2, MidpointAwayFromZero))
.collect();
if let Some((last, rest)) = parts.split_last_mut() {
*last = amount - rest.iter().sum::<Decimal>();
}
parts
}
/// Build the statement of changes in members' equity for `[start, end]`.
///
/// Each member's capital account supplies contributions (credits) and
@@ -783,11 +770,14 @@ pub fn members_equity(
}
m
};
// Contributions and distributions move a capital account; a closing
// entry allocating earnings into it is income, already counted below.
let closings = ledger.closing_entry_ids();
let flows = |id: crate::AccountId| -> (Decimal, Decimal) {
let (mut credits, mut debits) = (Decimal::ZERO, Decimal::ZERO);
for e in ledger
.posted_entries()
.filter(|e| e.date() >= start && e.date() <= end)
.filter(|e| e.date() >= start && e.date() <= end && !closings.contains(&e.id()))
{
for l in e.lines().iter().filter(|l| l.account == id) {
match l.side {
+132
View File
@@ -533,3 +533,135 @@ fn cash_flow_lines_name_their_account() {
let accounts: Vec<Option<&str>> = cf.financing.iter().map(|f| f.account.as_deref()).collect();
assert_eq!(accounts, [Some("3000"), Some("3001")]);
}
#[test]
fn a_close_sweeps_only_through_the_period_end() {
// February is already posted when January closes: the close must take
// January's 2,500 revenue and 800 rent (net 1,700) and leave
// February's 1,000 revenue and 120 software open.
let mut b = books(false);
let jan = b
.ledger
.create_period("2026-01", d(1, 1), d(1, 31))
.unwrap();
let closing = b.ledger.close_period(jan).unwrap().unwrap();
let lines: Vec<(String, Side, Decimal)> = b
.ledger
.get_entry(closing)
.unwrap()
.lines()
.iter()
.map(|l| {
(
b.ledger.chart().get(l.account).unwrap().code.clone(),
l.side,
l.amount.amount,
)
})
.collect();
assert_eq!(
lines,
[
("4000".to_string(), Side::Debit, dec(2_500)),
("6100".to_string(), Side::Credit, dec(800)),
("3999".to_string(), Side::Credit, dec(1_700)),
]
);
let feb = profit_and_loss_for_period(&b.ledger, d(2, 1), d(2, 28), None).unwrap();
assert_eq!(feb.net_income(), dec(880));
let bs = balance_sheet_as_of(&b.ledger, d(2, 28)).unwrap();
assert_eq!(bs.current_earnings, dec(880));
assert_eq!(
bs.equity.last().unwrap(),
&line("3999", "Retained Earnings", 1_700)
);
assert!(bs.balanced());
}
#[test]
fn periods_close_in_order() {
let mut b = books(false);
let jan = b
.ledger
.create_period("2026-01", d(1, 1), d(1, 31))
.unwrap();
let feb = b
.ledger
.create_period("2026-02", d(2, 1), d(2, 28))
.unwrap();
b.ledger.close_period(feb).unwrap();
// February swept everything through Feb 28, January included.
assert!(matches!(
b.ledger.close_period(jan),
Err(LedgerError::CloseOutOfOrder(j, f)) if j == jan && f == feb
));
}
#[test]
fn a_partnership_closes_into_members_capital() {
let (mut l, members) = partnership(false);
let to = ledger_core::CloseTo::Members(
members
.iter()
.map(|m| (m.capital_account, m.share))
.collect(),
);
let jan = l.create_period("2026-01", d(1, 1), d(1, 31)).unwrap();
l.close_period_to(jan, &to).unwrap();
// January earned 666.67: Alice's capital gets 333.34, Bob's 333.33,
// and there is no Retained Earnings account at all.
assert!(l.chart().find_by_code("3999").is_none());
let bs = balance_sheet_as_of(&l, d(1, 31)).unwrap();
let capital: Vec<Decimal> = bs.equity.iter().map(|e| e.amount).collect();
assert_eq!(capital, [cents("933.34"), cents("733.33")]);
assert_eq!(bs.current_earnings, Decimal::ZERO);
// The statement still reads: contributions are contributions (the
// close is income, not a contribution), and February starts where
// January ended.
let row = |c: &ledger_core::MemberEquityColumn| {
(
c.beginning,
c.contributions,
c.net_income,
c.distributions,
c.ending,
)
};
let jan_me = members_equity(&l, d(1, 1), d(1, 31), &members).unwrap();
assert_eq!(
row(&jan_me.members[0]),
(
cents("0"),
cents("600"),
cents("333.34"),
cents("0"),
cents("933.34")
)
);
assert_eq!(
row(&jan_me.members[1]),
(
cents("0"),
cents("400"),
cents("333.33"),
cents("0"),
cents("733.33")
)
);
let feb_me = members_equity(&l, d(2, 1), d(2, 28), &members).unwrap();
assert_eq!(feb_me.members[0].beginning, cents("933.34"));
assert_eq!(feb_me.members[1].ending, cents("683.33"));
assert_eq!(
feb_me.total.ending,
balance_sheet_as_of(&l, d(2, 28)).unwrap().total_equity
);
// Shares must still add up.
let feb = l.create_period("2026-02", d(2, 1), d(2, 28)).unwrap();
let bad = ledger_core::CloseTo::Members(vec![(members[0].capital_account, cents("0.5"))]);
assert!(matches!(
l.close_period_to(feb, &bad),
Err(LedgerError::InvalidMembers(_))
));
}
+1 -1
View File
@@ -20,5 +20,5 @@ pub use bank_csv::{BankTransaction, parse_bank_csv};
pub use chart_csv::{ChartRow, parse_chart_csv};
pub use iif::{IifEntry, journal_to_iif};
pub use pdf::{PdfError, statement_pdf};
pub use report::{Member, ReportMeta, Row, RowKind, Statement, format_amount};
pub use report::{Member, ReportMeta, Row, RowKind, Statement, YearEarnings, format_amount};
pub use xlsx::{XlsxReport, report_to_xlsx};
+133 -45
View File
@@ -175,14 +175,29 @@ pub fn profit_and_loss(pl: &ProfitAndLossStatement) -> Statement {
)
}
/// Lay out a balance sheet. `net_income_ytd` is the fiscal year to date's
/// net income (loss): it shows as its own equity line, and earnings from
/// before the fiscal year — the Retained Earnings account plus any not yet
/// closed — show as "Retained earnings". With `members`, equity is
/// "Members' equity".
/// The fiscal year's earnings, as the balance sheet presents them.
#[derive(Clone, Copy, Debug)]
pub struct YearEarnings {
/// Net income (loss) for the fiscal year through the as-of date.
pub net_income_ytd: Decimal,
/// The part of it not yet closed into equity accounts.
pub unclosed: Decimal,
}
/// Lay out a balance sheet.
///
/// Without members (corporate convention), equity ends with "Retained
/// earnings" — the Retained Earnings account plus earlier years' earnings
/// not yet closed — and the fiscal year's "Net income (loss)".
///
/// With members (a partnership), each member's capital account already
/// carries the earnings closed into it, so equity ends with only what
/// hasn't been allocated: "Undistributed earnings, prior years" (if any)
/// and this year's unclosed "Net income (loss)". Equity is "Members'
/// equity".
pub fn balance_sheet(
bs: &BalanceSheetStatement,
net_income_ytd: Decimal,
earnings: YearEarnings,
members: &[Member],
) -> Statement {
let equity_name = if members.is_empty() {
@@ -203,21 +218,39 @@ pub fn balance_sheet(
));
rows.push(spacer());
rows.push(section(equity_name));
let (retained, other): (Vec<_>, Vec<_>) = bs
.equity
.iter()
.partition(|l| l.code == RETAINED_EARNINGS_CODE);
rows.extend(
other
if members.is_empty() {
let (retained, other): (Vec<_>, Vec<_>) = bs
.equity
.iter()
.map(|l| one(RowKind::Line, format!("{} {}", l.code, l.name), l.amount)),
);
let retained: Decimal =
retained.iter().map(|l| l.amount).sum::<Decimal>() + bs.current_earnings - net_income_ytd;
if !retained.is_zero() {
rows.push(one(RowKind::Line, "Retained earnings", retained));
.partition(|l| l.code == RETAINED_EARNINGS_CODE);
rows.extend(
other
.iter()
.map(|l| one(RowKind::Line, format!("{} {}", l.code, l.name), l.amount)),
);
let retained: Decimal = retained.iter().map(|l| l.amount).sum::<Decimal>()
+ bs.current_earnings
- earnings.net_income_ytd;
if !retained.is_zero() {
rows.push(one(RowKind::Line, "Retained earnings", retained));
}
rows.push(one(
RowKind::Line,
"Net income (loss)",
earnings.net_income_ytd,
));
} else {
rows.extend(account_lines(&bs.equity));
let prior = bs.current_earnings - earnings.unclosed;
if !prior.is_zero() {
rows.push(one(
RowKind::Line,
"Undistributed earnings, prior years",
prior,
));
}
rows.push(one(RowKind::Line, "Net income (loss)", earnings.unclosed));
}
rows.push(one(RowKind::Line, "Net income (loss)", net_income_ytd));
rows.push(one(
RowKind::Subtotal,
format!("Total {}", equity_name.to_lowercase()),
@@ -474,50 +507,105 @@ mod tests {
);
}
#[test]
fn balance_sheet_splits_retained_earnings_from_this_year() {
let line = |code: &str, name: &str, a: &str| StatementLine {
fn sl(code: &str, name: &str, a: &str) -> StatementLine {
StatementLine {
code: code.into(),
name: name.into(),
amount: dec(a),
};
}
}
fn equity_rows(st: &Statement, from: &str) -> Vec<(String, Option<Decimal>)> {
st.rows
.iter()
.skip_while(|r| r.label != from)
.map(|r| (r.label.clone(), r.amounts.first().copied().flatten()))
.collect()
}
#[test]
fn corporate_balance_sheet_splits_retained_earnings_from_this_year() {
// Prior years closed 1,000 to Retained Earnings; 200 more unclosed
// from last year; this year's net income is 300 (current earnings
// 500 = 200 + 300).
let bs = BalanceSheetStatement {
as_of: NaiveDate::from_ymd_opt(2027, 3, 31).unwrap(),
assets: vec![line("1000", "Cash", "2500")],
assets: vec![sl("1000", "Cash", "2500")],
liabilities: vec![],
equity: vec![
line("3000", "Capital — Alice", "1000"),
line("3999", "Retained Earnings", "1000"),
sl("3000", "Common Stock", "1000"),
sl("3999", "Retained Earnings", "1000"),
],
current_earnings: dec("500"),
total_assets: dec("2500"),
total_liabilities: dec("0"),
total_equity: dec("2500"),
};
let members = [Member {
name: "Alice".into(),
capital_code: "3000".into(),
}];
let st = balance_sheet(&bs, dec("300"), &members);
let eq: Vec<(&str, Option<Decimal>)> = st
.rows
.iter()
.skip_while(|r| r.label != "Members' equity")
.map(|r| (r.label.as_str(), r.amounts.first().copied().flatten()))
.collect();
let e = YearEarnings {
net_income_ytd: dec("300"),
unclosed: dec("300"),
};
let st = balance_sheet(&bs, e, &[]);
let some = |s: &str| Some(dec(s));
assert_eq!(
eq,
equity_rows(&st, "Equity"),
[
("Members' equity", None),
("3000 Capital — Alice", Some(dec("1000"))),
("Retained earnings", Some(dec("1200"))),
("Net income (loss)", Some(dec("300"))),
("Total members' equity", Some(dec("2500"))),
("", None),
("Total liabilities and members' equity", Some(dec("2500"))),
("Equity".into(), None),
("3000 Common Stock".into(), some("1000")),
("Retained earnings".into(), some("1200")),
("Net income (loss)".into(), some("300")),
("Total equity".into(), some("2500")),
("".into(), None),
("Total liabilities and equity".into(), some("2500")),
]
);
}
#[test]
fn partnership_balance_sheet_shows_only_unallocated_earnings() {
// January (100) was closed into the capital accounts; February and
// March (200) are this year's unclosed income; 50 from last year
// was never closed.
let bs = BalanceSheetStatement {
as_of: NaiveDate::from_ymd_opt(2027, 3, 31).unwrap(),
assets: vec![sl("1000", "Cash", "1350")],
liabilities: vec![],
equity: vec![
sl("3000", "Capital — Alice", "550"),
sl("3001", "Capital — Bob", "550"),
],
current_earnings: dec("250"),
total_assets: dec("1350"),
total_liabilities: dec("0"),
total_equity: dec("1350"),
};
let e = YearEarnings {
net_income_ytd: dec("300"),
unclosed: dec("200"),
};
let members = [
Member {
name: "Alice".into(),
capital_code: "3000".into(),
},
Member {
name: "Bob".into(),
capital_code: "3001".into(),
},
];
let st = balance_sheet(&bs, e, &members);
let some = |s: &str| Some(dec(s));
assert_eq!(
equity_rows(&st, "Members' equity"),
[
("Members' equity".into(), None),
("3000 Capital — Alice".into(), some("550")),
("3001 Capital — Bob".into(), some("550")),
("Undistributed earnings, prior years".into(), some("50")),
("Net income (loss)".into(), some("200")),
("Total members' equity".into(), some("1350")),
("".into(), None),
("Total liabilities and members' equity".into(), some("1350")),
]
);
}
+68 -51
View File
@@ -117,14 +117,31 @@ impl BooksServer {
.collect()
}
/// Net income (loss) for the fiscal year containing `as_of`, through it.
fn net_income_ytd(&self, ledger: &Ledger, as_of: NaiveDate) -> Result<Decimal, String> {
/// The fiscal year's earnings through `as_of`, for the balance sheet:
/// net income (loss) year to date (closing entries excluded), and the
/// part of it still unclosed (closing entries included, so months
/// already closed into equity drop out).
fn year_earnings(
&self,
ledger: &Ledger,
as_of: NaiveDate,
) -> Result<ledger_import::YearEarnings, String> {
let (fy_start, _) = self.company.fiscal_year_containing(as_of);
Ok(
ledger_core::profit_and_loss_for_period(ledger, fy_start, as_of, None)
.map_err(|e| e.to_string())?
.net_income(),
)
let net_income_ytd = ledger_core::profit_and_loss_for_period(ledger, fy_start, as_of, None)
.map_err(|e| e.to_string())?
.net_income();
let mut unclosed = Decimal::ZERO;
for a in ledger.chart().all().filter(|a| !a.placeholder) {
match a.account_type {
AccountType::Revenue => unclosed += movement_in(ledger, a.id, fy_start, as_of),
AccountType::Expense => unclosed -= movement_in(ledger, a.id, fy_start, as_of),
_ => {}
}
}
Ok(ledger_import::YearEarnings {
net_income_ytd,
unclosed,
})
}
fn lock(&self) -> Result<std::sync::MutexGuard<'_, Store>, String> {
@@ -1529,8 +1546,8 @@ impl BooksServer {
)?),
"balance_sheet" => {
let bs = bs_statement(ledger, p.as_of.as_deref())?;
let ytd = self.net_income_ytd(ledger, bs.as_of)?;
report::balance_sheet(&bs, ytd, &members)
let earnings = self.year_earnings(ledger, bs.as_of)?;
report::balance_sheet(&bs, earnings, &members)
}
"cash_flow" => report::cash_flow(
&cash_flow_statement(
@@ -1952,7 +1969,7 @@ impl BooksServer {
#[tool(
name = "books.close_period",
description = "Close an accounting period. Without confirmed=true, returns a preview of the closing entry (revenue/expense zeroed to Retained Earnings) and closes nothing. With confirmed=true, posts the closing entry 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 as of that date into Retained Earnings, or, for a multi-member LLC (members in wanderbooks.toml), 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."
)]
async fn close_period(
&self,
@@ -1964,53 +1981,53 @@ impl BooksServer {
if store.ledger().get_period(p.period_id).is_none() {
return Err(format!("unknown period {}", p.period_id));
}
// A partnership closes into its members' capital accounts by share;
// everyone else into Retained Earnings.
let to = if self.company.members.is_empty() {
ledger_core::CloseTo::RetainedEarnings
} else {
ledger_core::CloseTo::Members(
self.member_capitals(store.ledger())?
.into_iter()
.map(|m| (m.capital_account, m.share))
.collect(),
)
};
let describe = |ledger: &Ledger, eid: Option<ledger_core::EntryId>| -> Vec<String> {
let Some(entry) = eid.and_then(|e| ledger.get_entry(e)) else {
return Vec::new();
};
entry
.lines()
.iter()
.map(|line| {
let code = ledger
.chart()
.get(line.account)
.map(|a| a.code.clone())
.unwrap_or_default();
format!("{:?} {} {}", line.side, code, money(line.amount.amount))
})
.collect()
};
if !p.confirmed {
// Preview: compute what the closing entry would look like
// without mutating. We do this by inspecting revenue/expense
// balances directly.
let mut lines = Vec::new();
let mut accounts: Vec<_> = store.ledger().chart().all().collect();
accounts.sort_by(|a, b| a.code.cmp(&b.code));
for account in accounts {
match account.account_type {
AccountType::Revenue | AccountType::Expense => {
let movement = store.ledger().account_movement(account.id);
if movement.is_zero() {
continue;
}
let (side, amount) = if movement > Decimal::ZERO {
("credit", movement)
} else {
("debit", -movement)
};
lines.push(format!("{} {} {} to zero", side, account.code, amount));
}
_ => {}
}
}
// Preview: run the real close on a copy of the ledger, so the
// preview is exactly what confirming would post.
let mut scratch = store.ledger().clone();
let closing = scratch
.close_period_to(p.period_id, &to)
.map_err(|e| e.to_string())?;
return Ok(Json(ClosePeriodOut {
status: "preview".to_string(),
period_id: p.period_id,
closing_entry: None,
lines,
lines: describe(&scratch, closing),
}));
}
let closing = store.close_period(p.period_id).map_err(|e| e.to_string())?;
// Build line descriptions from the posted closing entry.
let mut lines = Vec::new();
if let Some(eid) = closing
&& let Some(entry) = store.ledger().get_entry(eid)
{
for line in entry.lines() {
let code = store
.ledger()
.chart()
.get(line.account)
.map(|a| a.code.clone())
.unwrap_or_default();
lines.push(format!("{:?} {} {}", line.side, code, line.amount.amount));
}
}
let closing = store
.close_period_to(p.period_id, &to)
.map_err(|e| e.to_string())?;
let lines = describe(store.ledger(), closing);
Ok(Json(ClosePeriodOut {
status: "closed".to_string(),
period_id: p.period_id,
@@ -2644,7 +2661,7 @@ impl BooksServer {
"balance-sheet",
report::balance_sheet(
&bs_statement(ledger, Some(&as_of_s))?,
self.net_income_ytd(ledger, as_of)?,
self.year_earnings(ledger, as_of)?,
&members,
),
),
+33
View File
@@ -1022,4 +1022,37 @@ fn mcp_period_statements_over_stdio() {
json!({"format": "pdf", "report": "members_equity", "end": "2026-02-28"}),
);
assert_eq!(pdf["content_encoding"], "base64");
// Closing January: a one-member LLC closes into the member's capital
// account, not Retained Earnings. The preview is exactly what posts.
let jan = mcp.call(
"books.create_period",
json!({"name": "2026-01", "start": "2026-01-01", "end": "2026-01-31"}),
);
let jan_id = jan["period_id"].as_u64().unwrap();
let preview = mcp.call(
"books.close_period",
json!({"period_id": jan_id, "confirmed": false}),
);
assert_eq!(
preview["lines"],
json!(["Debit 4000 4.00", "Credit 3000 4.00"])
);
let closed = mcp.call(
"books.close_period",
json!({"period_id": jan_id, "confirmed": true}),
);
assert_eq!(closed["lines"], preview["lines"]);
let jan_bs = mcp.call("books.balance_sheet", json!({"as_of": "2026-01-31"}));
assert_eq!(jan_bs["equity_accounts"][0]["amount"], "1004.00");
assert_eq!(jan_bs["current_earnings"], "0.00");
// The close is income, not a contribution.
let me = mcp.call(
"books.export",
json!({"format": "json", "report": "members_equity", "start": "2026-01-01", "end": "2026-01-31"}),
);
let me: serde_json::Value = serde_json::from_str(me["content"].as_str().unwrap()).unwrap();
assert_eq!(me["total"]["contributions"], "1000.00");
assert_eq!(me["total"]["net_income"], "4.00");
assert_eq!(me["total"]["ending"], "1004.00");
}
+69 -12
View File
@@ -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(
+69
View File
@@ -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();
}