diff --git a/crates/ledger-core/src/ledger.rs b/crates/ledger-core/src/ledger.rs index 351ad3f..d1858f1 100644 --- a/crates/ledger-core/src/ledger.rs +++ b/crates/ledger-core/src/ledger.rs @@ -568,6 +568,16 @@ impl Ledger { self.periods.get(&id) } + /// Ids of every period-closing entry. Period statements (P&L, cash + /// flow) exclude them: a close moves earnings into retained earnings; it + /// earns and spends nothing, so counting it would zero out the period. + pub fn closing_entry_ids(&self) -> std::collections::BTreeSet { + self.periods + .values() + .filter_map(|p| p.closing_entry) + .collect() + } + /// All periods, in creation order. pub fn list_periods(&self) -> Vec<&Period> { let mut ids: Vec = self.periods.keys().copied().collect(); diff --git a/crates/ledger-core/src/lib.rs b/crates/ledger-core/src/lib.rs index 3819cd4..3c43e38 100644 --- a/crates/ledger-core/src/lib.rs +++ b/crates/ledger-core/src/lib.rs @@ -45,6 +45,7 @@ pub use money::{Currency, Money}; pub use payroll::{PayrollLine, PayrollRun, PayrollRunId}; pub use periods::{Period, PeriodId, PeriodStatus}; pub use reports::{ - BalanceSheet, CashFlowLine, CashFlowParams, CashFlowStatement, ClassProfitAndLoss, - ProfitAndLoss, TrialBalance, TrialBalanceLine, cash_flow, + BalanceSheet, BalanceSheetStatement, CashFlowLine, CashFlowParams, CashFlowStatement, + ClassProfitAndLoss, ProfitAndLoss, ProfitAndLossStatement, StatementLine, TrialBalance, + TrialBalanceLine, balance_sheet_as_of, cash_flow, profit_and_loss_for_period, }; diff --git a/crates/ledger-core/src/reports.rs b/crates/ledger-core/src/reports.rs index 9976254..b2e0714 100644 --- a/crates/ledger-core/src/reports.rs +++ b/crates/ledger-core/src/reports.rs @@ -4,10 +4,11 @@ //! (`docs/oracle.md`) diffs: the same fixture books through two engines must //! produce byte-identical [`snapshot`] output. -use crate::{AccountType, Class, ClassId, JournalLine, Ledger, LedgerError}; +use crate::{AccountType, Class, ClassId, JournalEntry, JournalLine, Ledger, LedgerError}; +use chrono::NaiveDate; use rust_decimal::Decimal; use serde::{Deserialize, Serialize}; -use std::collections::BTreeSet; +use std::collections::{BTreeMap, BTreeSet}; /// One line of a trial balance. #[derive(Clone, Debug, Serialize, Deserialize)] @@ -78,8 +79,8 @@ pub fn trial_balance(ledger: &Ledger) -> TrialBalance { } } -/// Profit and loss: revenue and expenses for the life of the ledger. -/// (Period filtering is R-FUTURE work; see `docs/requirements.md` R-7.) +/// Profit and loss: revenue and expenses for the life of the ledger. For a +/// date range, with a line per account, use [`profit_and_loss_for_period`]. #[derive(Clone, Debug, Serialize, Deserialize)] pub struct ProfitAndLoss { /// Total revenue (credit-normal balances, reported positive). @@ -265,6 +266,200 @@ pub fn balance_sheet(ledger: &Ledger) -> Result { }) } +/// One account's line on a financial statement. +#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)] +pub struct StatementLine { + /// Account code, e.g. `"6000"`. + pub code: String, + /// Account name. + pub name: String, + /// Amount on the account's normal side: positive is a normal balance + /// (or movement), negative a contra one. + pub amount: Decimal, +} + +/// Profit and loss for a date range, with a line per account. +#[derive(Clone, Debug, Serialize, Deserialize)] +pub struct ProfitAndLossStatement { + /// First date, inclusive. + pub start: NaiveDate, + /// Last date, inclusive. + pub end: NaiveDate, + /// Revenue accounts with movement in the range, by code. + pub revenue: Vec, + /// Expense accounts with movement in the range, by code. + pub expenses: Vec, + /// Sum of the revenue lines. + pub total_revenue: Decimal, + /// Sum of the expense lines. + pub total_expenses: Decimal, +} + +impl ProfitAndLossStatement { + /// Revenue minus expenses. Positive is profit. + pub fn net_income(&self) -> Decimal { + self.total_revenue - self.total_expenses + } +} + +/// Balance sheet as of a date, with a line per account. +#[derive(Clone, Debug, Serialize, Deserialize)] +pub struct BalanceSheetStatement { + /// The as-of date, inclusive. + pub as_of: NaiveDate, + /// Asset accounts with a balance, by code. + pub assets: Vec, + /// Liability accounts with a balance, by code. + pub liabilities: Vec, + /// Equity accounts with a balance, by code (current earnings excluded). + pub equity: Vec, + /// Revenue minus expenses through `as_of` not yet closed to retained + /// earnings. Shown as its own equity line. + pub current_earnings: Decimal, + /// Sum of the asset lines. + pub total_assets: Decimal, + /// Sum of the liability lines. + pub total_liabilities: Decimal, + /// Sum of the equity lines plus current earnings. + pub total_equity: Decimal, +} + +impl BalanceSheetStatement { + /// The accounting equation: assets == liabilities + equity, exactly. + pub fn balanced(&self) -> bool { + self.total_assets == self.total_liabilities + self.total_equity + } +} + +/// Normal-side amount per account over the posted lines accepted by +/// `include(entry, line)`, as statement lines keyed (and so sorted) by +/// code, skipping placeholders and zero amounts. +fn statement_lines( + ledger: &Ledger, + include: &dyn Fn(&JournalEntry, &JournalLine) -> bool, +) -> Result, LedgerError> { + let mut movement: BTreeMap = BTreeMap::new(); + for entry in ledger.posted_entries() { + for line in entry.lines() { + if !include(entry, line) { + continue; + } + let signed = match line.side { + crate::Side::Debit => line.amount.amount, + crate::Side::Credit => -line.amount.amount, + }; + *movement.entry(line.account).or_default() += signed; + } + } + let mut out = BTreeMap::new(); + for (id, m) in movement { + let account = ledger + .chart() + .get(id) + .ok_or(LedgerError::UnknownAccount(id))?; + if account.placeholder || m.is_zero() { + continue; + } + let amount = match account.account_type.normal_side() { + crate::Side::Debit => m, + crate::Side::Credit => -m, + }; + out.insert( + account.code.clone(), + ( + account.account_type, + StatementLine { + code: account.code.clone(), + name: account.name.clone(), + amount, + }, + ), + ); + } + Ok(out) +} + +/// Profit and loss for `[start, end]`, optionally for one class (`None` +/// counts every line). Closing entries are excluded: they move a period's +/// earnings into retained earnings, so counting them would report a closed +/// period as breaking even. +pub fn profit_and_loss_for_period( + ledger: &Ledger, + start: NaiveDate, + end: NaiveDate, + class_id: Option, +) -> Result { + if end < start { + return Err(LedgerError::InvalidDateRange { + start: start.to_string(), + end: end.to_string(), + }); + } + let closings = ledger.closing_entry_ids(); + let lines = statement_lines(ledger, &|entry, line| { + entry.date() >= start + && entry.date() <= end + && !closings.contains(&entry.id()) + && class_id.is_none_or(|c| line.class_id == Some(c)) + })?; + let mut revenue = Vec::new(); + let mut expenses = Vec::new(); + for (typ, line) in lines.into_values() { + match typ { + AccountType::Revenue => revenue.push(line), + AccountType::Expense => expenses.push(line), + _ => {} + } + } + let total_revenue = revenue.iter().map(|l| l.amount).sum(); + let total_expenses = expenses.iter().map(|l| l.amount).sum(); + Ok(ProfitAndLossStatement { + start, + end, + revenue, + expenses, + total_revenue, + total_expenses, + }) +} + +/// Balance sheet as of `as_of`: every posted entry dated on or before it. +/// Revenue and expense not yet closed to retained earnings appear as +/// current earnings inside equity, so the accounting equation holds on +/// every date, before and after closes. +pub fn balance_sheet_as_of( + ledger: &Ledger, + as_of: NaiveDate, +) -> Result { + let lines = statement_lines(ledger, &|entry, _| entry.date() <= as_of)?; + let mut assets = Vec::new(); + let mut liabilities = Vec::new(); + let mut equity = Vec::new(); + let mut current_earnings = Decimal::ZERO; + for (typ, line) in lines.into_values() { + match typ { + AccountType::Asset => assets.push(line), + AccountType::Liability => liabilities.push(line), + AccountType::Equity => equity.push(line), + AccountType::Revenue => current_earnings += line.amount, + AccountType::Expense => current_earnings -= line.amount, + } + } + let total_assets = assets.iter().map(|l| l.amount).sum(); + let total_liabilities = liabilities.iter().map(|l| l.amount).sum(); + let total_equity = equity.iter().map(|l| l.amount).sum::() + current_earnings; + Ok(BalanceSheetStatement { + as_of, + assets, + liabilities, + equity, + current_earnings, + total_assets, + total_liabilities, + total_equity, + }) +} + /// Render the three core reports as canonical JSON for oracle diffing. /// /// Amounts are decimal strings (never floats, never locale-formatted), lines @@ -406,11 +601,14 @@ pub fn cash_flow( let financing_set: HashSet = params.financing_accounts.iter().copied().collect(); - // Movement helpers: debits minus credits over a date predicate. + // Movement helpers: debits minus credits over a date predicate. Closing + // entries are skipped: a close moves earnings into retained earnings, + // which is neither net income nor an owner flow. + let closings = ledger.closing_entry_ids(); let movement_where = |account: crate::AccountId, pred: &dyn Fn(chrono::NaiveDate) -> bool| { let mut total = Decimal::ZERO; for entry in ledger.posted_entries() { - if !pred(entry.date()) { + if !pred(entry.date()) || closings.contains(&entry.id()) { continue; } for line in entry.lines() { diff --git a/crates/ledger-core/tests/properties.rs b/crates/ledger-core/tests/properties.rs index 89be657..07bb1a9 100644 --- a/crates/ledger-core/tests/properties.rs +++ b/crates/ledger-core/tests/properties.rs @@ -9,7 +9,10 @@ use chrono::NaiveDate; use ledger_core::{ AccountType, Currency, Ledger, Money, NewAsset, Side, accumulated_depreciation, book_value, depreciation_schedule, - reports::{balance_sheet, trial_balance}, + reports::{ + balance_sheet, balance_sheet_as_of, profit_and_loss, profit_and_loss_for_period, + trial_balance, + }, }; use proptest::prelude::*; use rust_decimal::Decimal; @@ -136,6 +139,52 @@ proptest! { prop_assert_eq!(bs.assets, bs.liabilities + bs.equity); } + /// Period P&Ls partition the year: split anywhere, the two halves sum + /// to the whole, and the whole is the life-of-ledger P&L. + #[test] + fn prop_period_pnl_partitions((chart, entries) in arb_ops(), split in 0u32..365) { + let (ledger, _) = build_ledger(&chart, &entries); + let jan1 = NaiveDate::from_ymd_opt(2026, 1, 1).unwrap(); + let dec31 = NaiveDate::from_ymd_opt(2026, 12, 31).unwrap(); + let cut = jan1 + chrono::Days::new(split as u64); + let first = profit_and_loss_for_period(&ledger, jan1, cut, None).unwrap(); + let rest = if cut < dec31 { + Some(profit_and_loss_for_period(&ledger, cut.succ_opt().unwrap(), dec31, None).unwrap()) + } else { + None + }; + let whole = profit_and_loss_for_period(&ledger, jan1, dec31, None).unwrap(); + let (r2, e2) = rest.map_or((Decimal::ZERO, Decimal::ZERO), |p| (p.total_revenue, p.total_expenses)); + prop_assert_eq!(first.total_revenue + r2, whole.total_revenue); + prop_assert_eq!(first.total_expenses + e2, whole.total_expenses); + let all = profit_and_loss(&ledger).unwrap(); + prop_assert_eq!((whole.total_revenue, whole.total_expenses), (all.revenue, all.expenses)); + } + + /// The accounting equation holds as of every date, and a period's net + /// income is exactly the change in current earnings across it. + #[test] + fn prop_balance_sheet_as_of_balances((chart, entries) in arb_ops(), a in 0u32..365, b in 0u32..365) { + let (ledger, _) = build_ledger(&chart, &entries); + let jan1 = NaiveDate::from_ymd_opt(2026, 1, 1).unwrap(); + let (lo, hi) = (a.min(b), a.max(b)); + let start = jan1 + chrono::Days::new(lo as u64); + let end = jan1 + chrono::Days::new(hi as u64); + let before = balance_sheet_as_of(&ledger, start.pred_opt().unwrap()).unwrap(); + let after = balance_sheet_as_of(&ledger, end).unwrap(); + prop_assert!(before.balanced()); + prop_assert!(after.balanced()); + let pnl = profit_and_loss_for_period(&ledger, start, end, None).unwrap(); + prop_assert_eq!(after.current_earnings - before.current_earnings, pnl.net_income()); + // Past the last entry, as-of agrees with the life-of-ledger sheet. + let all = balance_sheet(&ledger).unwrap(); + let last = balance_sheet_as_of(&ledger, NaiveDate::from_ymd_opt(2026, 12, 31).unwrap()).unwrap(); + prop_assert_eq!( + (last.total_assets, last.total_liabilities, last.total_equity), + (all.assets, all.liabilities, all.equity) + ); + } + /// Every posted entry balances on its own, exactly. #[test] fn prop_posted_entries_balance((chart, entries) in arb_ops()) { diff --git a/crates/ledger-core/tests/statements.rs b/crates/ledger-core/tests/statements.rs new file mode 100644 index 0000000..9d064a0 --- /dev/null +++ b/crates/ledger-core/tests/statements.rs @@ -0,0 +1,223 @@ +//! Period-scoped financial statements: profit and loss for a date range and +//! the balance sheet as of a date, line per account. +//! +//! Every expected number below is computed by hand from the entries, not +//! read off the engine. The fixture is two months of a small company: +//! +//! | date | entry | Dr | Cr | +//! |--------|-------------------------------|---------------|---------------| +//! | Jan 2 | owner contributes 10,000 | 1000 Cash | 3000 Capital | +//! | Jan 15 | client pays 2,500 | 1000 Cash | 4000 Revenue | +//! | Jan 20 | rent 800 | 6100 Rent | 1000 Cash | +//! | Feb 3 | software 120 on the card | 6000 Software | 2000 Card | +//! | Feb 10 | client pays 1,000 | 1000 Cash | 4000 Revenue | +//! | Feb 25 | pay the card 120 | 2000 Card | 1000 Cash | + +use chrono::NaiveDate; +use ledger_core::reports::{ + balance_sheet, balance_sheet_as_of, profit_and_loss, profit_and_loss_for_period, +}; +use ledger_core::{ + AccountId, AccountType, CashFlowParams, ClassId, Currency, Ledger, Money, Side, StatementLine, + cash_flow, +}; +use rust_decimal::Decimal; + +fn d(m: u32, day: u32) -> NaiveDate { + NaiveDate::from_ymd_opt(2026, m, day).unwrap() +} + +fn usd(n: i64) -> Money { + Money::new(Decimal::from(n), Currency::USD) +} + +fn dec(n: i64) -> Decimal { + Decimal::from(n) +} + +struct Books { + ledger: Ledger, + cash: AccountId, + software_class: ClassId, +} + +fn post(ledger: &mut Ledger, date: NaiveDate, desc: &str, dr: AccountId, cr: AccountId, n: i64) { + let e = ledger.create_entry(date, desc); + ledger.add_line(e, dr, Side::Debit, usd(n)).unwrap(); + ledger.add_line(e, cr, Side::Credit, usd(n)).unwrap(); + ledger.post_entry(e).unwrap(); +} + +/// The fixture. With `close_january`, January is closed before any February +/// entry exists (the close sweeps every revenue and expense balance). +fn books(close_january: bool) -> Books { + let mut l = Ledger::new(Currency::USD); + let cash = l + .add_account("1000", "Cash", AccountType::Asset, None, false) + .unwrap(); + let card = l + .add_account("2000", "Card", AccountType::Liability, None, false) + .unwrap(); + let capital = l + .add_account("3000", "Capital", AccountType::Equity, None, false) + .unwrap(); + let revenue = l + .add_account("4000", "Revenue", AccountType::Revenue, None, false) + .unwrap(); + let software = l + .add_account("6000", "Software", AccountType::Expense, None, false) + .unwrap(); + let rent = l + .add_account("6100", "Rent", AccountType::Expense, None, false) + .unwrap(); + let software_class = l.add_class("Apps").unwrap(); + + post(&mut l, d(1, 2), "Owner contribution", cash, capital, 10_000); + post(&mut l, d(1, 15), "Client payment", cash, revenue, 2_500); + post(&mut l, d(1, 20), "Rent", rent, cash, 800); + if close_january { + let jan = l.create_period("2026-01", d(1, 1), d(1, 31)).unwrap(); + l.close_period(jan).unwrap(); + } + // Software on the card, tagged to the Apps class. + let e = l.create_entry(d(2, 3), "Software"); + l.add_line(e, software, Side::Debit, usd(120)).unwrap(); + l.add_line(e, card, Side::Credit, usd(120)).unwrap(); + l.set_line_class(e, 0, Some(software_class)).unwrap(); + l.post_entry(e).unwrap(); + post(&mut l, d(2, 10), "Client payment", cash, revenue, 1_000); + post(&mut l, d(2, 25), "Pay card", card, cash, 120); + Books { + ledger: l, + cash, + software_class, + } +} + +fn line(code: &str, name: &str, n: i64) -> StatementLine { + StatementLine { + code: code.to_string(), + name: name.to_string(), + amount: dec(n), + } +} + +#[test] +fn profit_and_loss_is_scoped_to_the_period() { + let b = books(false); + let jan = profit_and_loss_for_period(&b.ledger, d(1, 1), d(1, 31), None).unwrap(); + assert_eq!(jan.revenue, vec![line("4000", "Revenue", 2_500)]); + assert_eq!(jan.expenses, vec![line("6100", "Rent", 800)]); + assert_eq!(jan.net_income(), dec(1_700)); + + let feb = profit_and_loss_for_period(&b.ledger, d(2, 1), d(2, 28), None).unwrap(); + assert_eq!(feb.revenue, vec![line("4000", "Revenue", 1_000)]); + assert_eq!(feb.expenses, vec![line("6000", "Software", 120)]); + assert_eq!(feb.net_income(), dec(880)); + + // Both months: 3,500 revenue, 920 expenses, lines sorted by code. + let both = profit_and_loss_for_period(&b.ledger, d(1, 1), d(2, 28), None).unwrap(); + assert_eq!( + both.expenses, + vec![line("6000", "Software", 120), line("6100", "Rent", 800)] + ); + assert_eq!( + (both.total_revenue, both.total_expenses), + (dec(3_500), dec(920)) + ); + // ... which is the life-of-ledger P&L, because nothing is outside it. + let all = profit_and_loss(&b.ledger).unwrap(); + assert_eq!((all.revenue, all.expenses), (dec(3_500), dec(920))); + + // One class: only the tagged software line. + let apps = + profit_and_loss_for_period(&b.ledger, d(1, 1), d(12, 31), Some(b.software_class)).unwrap(); + assert!(apps.revenue.is_empty()); + assert_eq!(apps.expenses, vec![line("6000", "Software", 120)]); + + // A backwards range is rejected, not silently empty. + assert!(profit_and_loss_for_period(&b.ledger, d(2, 1), d(1, 1), None).is_err()); +} + +#[test] +fn balance_sheet_is_as_of_the_date() { + let b = books(false); + // Before anything happened: empty and balanced. + let start = balance_sheet_as_of(&b.ledger, d(1, 1)).unwrap(); + assert!(start.assets.is_empty() && start.equity.is_empty()); + assert!(start.balanced()); + + // Jan 31: cash 10,000 + 2,500 − 800 = 11,700; equity 10,000 + 1,700. + let jan = balance_sheet_as_of(&b.ledger, d(1, 31)).unwrap(); + assert_eq!(jan.assets, vec![line("1000", "Cash", 11_700)]); + assert!(jan.liabilities.is_empty()); + assert_eq!(jan.equity, vec![line("3000", "Capital", 10_000)]); + assert_eq!(jan.current_earnings, dec(1_700)); + assert_eq!(jan.total_equity, dec(11_700)); + assert!(jan.balanced()); + + // Feb 15: cash 12,700; the card owes 120; earnings 1,700 + 880. + let mid = balance_sheet_as_of(&b.ledger, d(2, 15)).unwrap(); + assert_eq!(mid.total_assets, dec(12_700)); + assert_eq!(mid.liabilities, vec![line("2000", "Card", 120)]); + assert_eq!(mid.current_earnings, dec(2_580)); + assert_eq!(mid.total_liabilities + mid.total_equity, dec(12_700)); + + // Feb 28: card paid off, so it drops off the statement. + let end = balance_sheet_as_of(&b.ledger, d(2, 28)).unwrap(); + assert_eq!(end.total_assets, dec(12_580)); + assert!(end.liabilities.is_empty()); + assert!(end.balanced()); + // ... and agrees with the life-of-ledger balance sheet. + let all = balance_sheet(&b.ledger).unwrap(); + assert_eq!( + (all.assets, all.liabilities, all.equity), + (end.total_assets, end.total_liabilities, end.total_equity) + ); +} + +#[test] +fn a_closed_period_still_reports_its_earnings() { + let b = books(true); + // The close posted Dr 4000 2,500 / Cr 6100 800 / Cr 3999 1,700 on Jan + // 31. The January P&L must not count it: January earned 1,700. + let jan = profit_and_loss_for_period(&b.ledger, d(1, 1), d(1, 31), None).unwrap(); + assert_eq!(jan.net_income(), dec(1_700)); + assert_eq!(jan.revenue, vec![line("4000", "Revenue", 2_500)]); + let feb = profit_and_loss_for_period(&b.ledger, d(2, 1), d(2, 28), None).unwrap(); + assert_eq!(feb.net_income(), dec(880)); + + // After the close, January's earnings sit in retained earnings, not in + // current earnings; February's are current. + let jan_bs = balance_sheet_as_of(&b.ledger, d(1, 31)).unwrap(); + assert_eq!( + jan_bs.equity, + vec![ + line("3000", "Capital", 10_000), + line("3999", "Retained Earnings", 1_700) + ] + ); + assert_eq!(jan_bs.current_earnings, Decimal::ZERO); + assert!(jan_bs.balanced()); + let feb_bs = balance_sheet_as_of(&b.ledger, d(2, 28)).unwrap(); + assert_eq!(feb_bs.current_earnings, dec(880)); + assert_eq!(feb_bs.total_equity, dec(12_580)); + assert!(feb_bs.balanced()); + + // The cash flow for January: net income 1,700, and the only financing + // flow is the owner's 10,000. The close is not an owner contribution. + let cf = cash_flow( + &b.ledger, + &CashFlowParams { + start: d(1, 1), + end: d(1, 31), + cash_accounts: vec![b.cash], + financing_accounts: vec![], + }, + ) + .unwrap(); + assert_eq!(cf.net_income, dec(1_700)); + assert_eq!(cf.financing.len(), 1); + assert_eq!(cf.cash_from_financing, dec(10_000)); + assert_eq!(cf.net_change_in_cash, dec(11_700)); +} diff --git a/docs/parity.md b/docs/parity.md index a26b9be..14fb072 100644 --- a/docs/parity.md +++ b/docs/parity.md @@ -19,6 +19,8 @@ No other statuses. | Trial balance | Reports → Trial Balance | done | golden-covered | | Balance sheet (A = L + E) | Reports → Balance Sheet | done | current earnings folded in pre-close; golden-covered | | Profit & loss | Reports → Income Statement | done | golden-covered | +| P&L for a date range, line per account (closing entries excluded) | Reports → Income Statement (custom dates) | partial | hand-verified fixture + property tests; no golden file yet | +| Balance sheet as of a date, line per account | Reports → Balance Sheet (as of) | partial | hand-verified fixture + property tests; no golden file yet | | Reversing entries | Reverse transaction | done | linked via `reversal_of` | | Canonical report snapshots (JSON) | — (our oracle format) | done | `reports::snapshot` | | Fixed-asset register + straight-line depreciation | Fixed Asset register | done | `assets.rs`; golden-covered schedule; idempotent runs; disposal with gain/loss | diff --git a/docs/requirements.md b/docs/requirements.md index 3556a09..e1ea344 100644 --- a/docs/requirements.md +++ b/docs/requirements.md @@ -34,10 +34,19 @@ requirement conflict, the requirement wins. total credits (the `balanced()` check). - **R-8 — Balance sheet.** Assets, liabilities, equity grouped by account type. The accounting equation holds exactly: assets = liabilities + equity. - Because period close (R-FUTURE-2) doesn't exist yet, current earnings (net - income to date) are folded into equity so the equation holds on a live book. -- **R-9 — Profit and loss.** Revenue and expenses for the life of the ledger, - with net income. Period filtering arrives with R-FUTURE-2. + Current earnings (revenue minus expenses not yet closed to retained + earnings) are folded into equity so the equation holds on a live book. + `balance_sheet_as_of` gives the statement as of any date, a line per + account, counting every entry dated on or before it (closing entries + included: after a close the earnings sit in retained earnings). +- **R-9 — Profit and loss.** Revenue and expenses with net income. + `profit_and_loss_for_period` scopes it to a date range (optionally one + class), a line per account, and **excludes closing entries**: a close moves + earnings into retained earnings and must not make a closed period read as + break-even. The cash flow statement excludes closing entries for the same + reason. Period P&Ls partition: split a range anywhere and the halves sum + to the whole, and a range's net income equals the change in current + earnings across it (property-tested). - **R-10 — Canonical snapshots.** `reports::snapshot()` renders the three core reports as deterministic JSON (decimal strings, sorted lines, fixed keys). Two engines fed the same books must produce identical output. This