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.