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
+682 -186

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),
+124 -68
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,42 +587,49 @@ impl Ledger {
.collect()
}
/// 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.
///
/// Returns the closing entry's id, or `None` if there was nothing to
/// close (no revenue/expense movement).
/// Close a period into Retained Earnings: see [`Self::close_period_to`].
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));
self.close_period_to(id, &periods::CloseTo::RetainedEarnings)
}
// Collect revenue and expense accounts with non-zero movement.
/// Close a period. This:
/// 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 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();
// 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 !matches!(
account.account_type,
AccountType::Revenue | AccountType::Expense
) {
continue;
}
let movement = self.movement_through(account.id, period.end);
if movement.is_zero() {
continue;
}
@@ -634,14 +641,20 @@ impl Ledger {
};
closings.push((account.id, side, amount));
}
_ => {}
}
}
let closing_entry = if closings.is_empty() {
None
} else {
// Ensure Retained Earnings exists.
// 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(
@@ -652,49 +665,83 @@ impl Ledger {
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,
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};
+121 -33
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,6 +218,7 @@ pub fn balance_sheet(
));
rows.push(spacer());
rows.push(section(equity_name));
if members.is_empty() {
let (retained, other): (Vec<_>, Vec<_>) = bs
.equity
.iter()
@@ -212,12 +228,29 @@ pub fn balance_sheet(
.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;
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)", net_income_ytd));
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::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 {
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!(
equity_rows(&st, "Equity"),
[
("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(),
}];
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();
},
Member {
name: "Bob".into(),
capital_code: "3001".into(),
},
];
let st = balance_sheet(&bs, e, &members);
let some = |s: &str| Some(dec(s));
assert_eq!(
eq,
equity_rows(&st, "Members' 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"))),
("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")),
]
);
}
+67 -50
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)
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(),
)
.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));
}
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)
// 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 {
("debit", -movement)
ledger_core::CloseTo::Members(
self.member_capitals(store.ledger())?
.into_iter()
.map(|m| (m.capital_account, m.share))
.collect(),
)
};
lines.push(format!("{} {} {} to zero", side, account.code, amount));
}
_ => {}
}
}
return Ok(Json(ClosePeriodOut {
status: "preview".to_string(),
period_id: p.period_id,
closing_entry: None,
lines,
}));
}
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()
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();
lines.push(format!("{:?} {} {}", line.side, code, line.amount.amount));
}
format!("{:?} {} {}", line.side, code, money(line.amount.amount))
})
.collect()
};
if !p.confirmed {
// 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: describe(&scratch, closing),
}));
}
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();
}
+7 -4
View File
@@ -279,10 +279,13 @@ R-FUTURE-3 and rides the engine roadmap, not the MCP layer.
- `books.create_period { name, start, end }` → `{ period_id }`.
Dates are YYYY-MM-DD, inclusive.
- `books.close_period { period_id, confirmed? }` → without confirmation:
`{ status: "preview", lines: [...] }` showing the closing lines and posts
nothing; with `confirmed: true`: posts the closing entry (revenue debited
to zero, expenses credited to zero, net to `3999` Retained Earnings) and
marks the period closed. Closed periods reject new entries.
`{ status: "preview", lines: [...] }`: the exact closing entry (the close
run on a copy of the ledger), posting nothing; with `confirmed: true`:
posts it and marks the period closed. The entry zeroes revenue and
expense balances as of the period end; the net goes to `3999` Retained
Earnings, or, with `[[company.members]]` in `wanderbooks.toml`, to each
member's capital account by share. Periods close in order. Closed periods
reject new entries.
- `books.list_periods {}` → `{ periods: [{ id, name, start, end, status,
closing_entry }] }`.
+10 -2
View File
@@ -111,8 +111,16 @@ requirement conflict, the requirement wins.
Reports work in base currency. (Full per-account currencies and translation
remain future.)
- **R-? — Periods and close.** Was R-FUTURE-2.
`ledger-core::periods`: `create_period`, `close_period` (posts closing
entry to `3999` Retained Earnings), closed periods reject new entries.
`ledger-core::periods`: `create_period`, `close_period_to(id, to)`; closed
periods reject new entries. The closing entry, dated at period end,
zeroes each revenue and expense account's balance **as of the period
end** (later-dated entries belong to later periods) and puts the net in
`to`: `CloseTo::RetainedEarnings` (`3999`, the corporate convention;
`close_period` is shorthand) or `CloseTo::Members` (each member's capital
account by share, to the cent, the last absorbing rounding: the
partnership convention). Periods close in order (`CloseOutOfOrder`). The
journal records the target, and replay re-derives the closing entry and
checks every line against the journal.
- **R-? — A/R and A/P workflows.** Was R-FUTURE-5.
`ledger-core::arap`: customers, vendors, invoices, bills, payments,
AR/AP aging. MCP tools for the full workflow.
+10
View File
@@ -204,4 +204,14 @@ interface is MCP, not a cloned GUI. The first storage + MCP slice is done
(loss), standard cash-flow wording) and an "Accrual basis · Prepared …"
footer. "Today" is local time, not UTC (evening exports were dated
tomorrow).
- 2026-10-11: Period close fixed and made partnership-aware. The close swept
every revenue/expense posting ever made, so a period closed after later
entries existed swallowed them (closing January in March closed February
and March too); it now zeroes balances as of the period end, refuses to
close out of order, and its preview is the real close run on a copy of
the ledger. A multi-member LLC closes into each member's capital account
by share (CloseTo::Members), the partnership convention, so capital
accounts carry allocated earnings; the members'-equity statement treats
those closes as income, not contributions, and the balance sheet shows
only unallocated earnings. Replay now checks closing lines, not just ids.