diff --git a/crates/ledger-core/src/error.rs b/crates/ledger-core/src/error.rs
index 74f686d..55a46ef 100644
--- a/crates/ledger-core/src/error.rs
+++ b/crates/ledger-core/src/error.rs
@@ -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),
diff --git a/crates/ledger-core/src/ledger.rs b/crates/ledger-core/src/ledger.rs
index d84a1a5..9a4a1a9 100644
--- a/crates/ledger-core/src/ledger.rs
+++ b/crates/ledger-core/src/ledger.rs
@@ -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, 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 , 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 , 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 = 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))
}
diff --git a/crates/ledger-core/src/lib.rs b/crates/ledger-core/src/lib.rs
index f02dfdb..8f32cc5 100644
--- a/crates/ledger-core/src/lib.rs
+++ b/crates/ledger-core/src/lib.rs
@@ -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,
diff --git a/crates/ledger-core/src/periods.rs b/crates/ledger-core/src/periods.rs
index fea364c..6cf1c97 100644
--- a/crates/ledger-core/src/periods.rs
+++ b/crates/ledger-core/src/periods.rs
@@ -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 {
+ 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::();
+ }
+ 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";
diff --git a/crates/ledger-core/src/reports.rs b/crates/ledger-core/src/reports.rs
index 1aac603..bfe8ed1 100644
--- a/crates/ledger-core/src/reports.rs
+++ b/crates/ledger-core/src/reports.rs
@@ -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 {
- 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().sum::();
- }
- 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 {
diff --git a/crates/ledger-core/tests/statements.rs b/crates/ledger-core/tests/statements.rs
index ea8eb3d..4066eee 100644
--- a/crates/ledger-core/tests/statements.rs
+++ b/crates/ledger-core/tests/statements.rs
@@ -533,3 +533,135 @@ fn cash_flow_lines_name_their_account() {
let accounts: Vec> = 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 = 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(_))
+ ));
+}
diff --git a/crates/ledger-import/src/lib.rs b/crates/ledger-import/src/lib.rs
index 5612ec5..1c629c6 100644
--- a/crates/ledger-import/src/lib.rs
+++ b/crates/ledger-import/src/lib.rs
@@ -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};
diff --git a/crates/ledger-import/src/report.rs b/crates/ledger-import/src/report.rs
index 0b9ad76..513c592 100644
--- a/crates/ledger-import/src/report.rs
+++ b/crates/ledger-import/src/report.rs
@@ -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::() + 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::()
+ + 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)> {
+ 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)> = 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")),
]
);
}
diff --git a/crates/ledger-mcp/src/server.rs b/crates/ledger-mcp/src/server.rs
index 1568948..53da119 100644
--- a/crates/ledger-mcp/src/server.rs
+++ b/crates/ledger-mcp/src/server.rs
@@ -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 {
+ /// 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 {
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, 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| -> Vec {
+ 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,
),
),
diff --git a/crates/ledger-mcp/tests/mcp_stdio.rs b/crates/ledger-mcp/tests/mcp_stdio.rs
index 6319b1e..8b739b9 100644
--- a/crates/ledger-mcp/tests/mcp_stdio.rs
+++ b/crates/ledger-mcp/tests/mcp_stdio.rs
@@ -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");
}
diff --git a/crates/ledger-store/src/store.rs b/crates/ledger-store/src/store.rs
index 255442a..6ba3f92 100644
--- a/crates/ledger-store/src/store.rs
+++ b/crates/ledger-store/src/store.rs
@@ -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, 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 , 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::>(),
+ }),
+ };
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::>()?,
+ ),
+ };
+ 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 = 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(
diff --git a/crates/ledger-store/tests/store.rs b/crates/ledger-store/tests/store.rs
index b012681..64dc043 100644
--- a/crates/ledger-store/tests/store.rs
+++ b/crates/ledger-store/tests/store.rs
@@ -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();
+}
diff --git a/docs/mcp-interface.md b/docs/mcp-interface.md
index 9c39cbb..3c8c1ae 100644
--- a/docs/mcp-interface.md
+++ b/docs/mcp-interface.md
@@ -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 }] }`.
diff --git a/docs/requirements.md b/docs/requirements.md
index 923eb49..c2f63d6 100644
--- a/docs/requirements.md
+++ b/docs/requirements.md
@@ -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.
diff --git a/plan/STATUS.md b/plan/STATUS.md
index a86f8b5..3344c39 100644
--- a/plan/STATUS.md
+++ b/plan/STATUS.md
@@ -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.