feat: period-scoped P&L and as-of balance sheet; closes don't zero periods

The engine only had life-of-ledger P&L and balance sheet totals, so every
export labeled with a date range or as-of date (PDF, XLSX, CPA package)
printed all-time numbers under that label. Core now has:

- profit_and_loss_for_period(start, end, class?) and
  balance_sheet_as_of(as_of), each with a line per account
  (StatementLine: code, name, normal-side amount) and exact totals.
- Ledger::closing_entry_ids(). Period statements exclude closing entries.
  A close (Dr revenue / Cr expense / net to Retained Earnings, dated at
  period end) moves earnings; it doesn't earn or spend. Counting it made a
  closed period's P&L read as break-even, and made the cash flow show zero
  net income plus a fake "owner contribution" to Retained Earnings. The
  as-of balance sheet keeps closing entries: after a close the earnings
  belong in Retained Earnings, not current earnings.

Tests: tests/statements.rs, a two-month fixture with every expected number
computed by hand (Jan NI 1,700; Feb 880; balance sheets on four dates; a
closed January still reporting 1,700 and a cash flow with one financing
line). Mutation-checked: dropping the closing exclusion fails it (0 vs
1,700). Properties: period P&Ls partition a year at any split, and a
range's net income equals the change in current earnings across it, with
A = L + E on both dates.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MtYEaR6M6Ybdhm6X98BWNu
This commit is contained in:
swaitsandClaude Opus 5.5 committed 2026-10-11 19:04:18 -07:00
1 parent 5548902f27
commit c6faeeb827
7 files changed
+505 -13

No files matched your search

+10
View File
@@ -568,6 +568,16 @@ impl Ledger {
self.periods.get(&id) 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<EntryId> {
self.periods
.values()
.filter_map(|p| p.closing_entry)
.collect()
}
/// All periods, in creation order. /// All periods, in creation order.
pub fn list_periods(&self) -> Vec<&Period> { pub fn list_periods(&self) -> Vec<&Period> {
let mut ids: Vec<PeriodId> = self.periods.keys().copied().collect(); let mut ids: Vec<PeriodId> = self.periods.keys().copied().collect();
+3 -2
View File
@@ -45,6 +45,7 @@ pub use money::{Currency, Money};
pub use payroll::{PayrollLine, PayrollRun, PayrollRunId}; pub use payroll::{PayrollLine, PayrollRun, PayrollRunId};
pub use periods::{Period, PeriodId, PeriodStatus}; pub use periods::{Period, PeriodId, PeriodStatus};
pub use reports::{ pub use reports::{
BalanceSheet, CashFlowLine, CashFlowParams, CashFlowStatement, ClassProfitAndLoss, BalanceSheet, BalanceSheetStatement, CashFlowLine, CashFlowParams, CashFlowStatement,
ProfitAndLoss, TrialBalance, TrialBalanceLine, cash_flow, ClassProfitAndLoss, ProfitAndLoss, ProfitAndLossStatement, StatementLine, TrialBalance,
TrialBalanceLine, balance_sheet_as_of, cash_flow, profit_and_loss_for_period,
}; };
+204 -6
View File
@@ -4,10 +4,11 @@
//! (`docs/oracle.md`) diffs: the same fixture books through two engines must //! (`docs/oracle.md`) diffs: the same fixture books through two engines must
//! produce byte-identical [`snapshot`] output. //! 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 rust_decimal::Decimal;
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use std::collections::BTreeSet; use std::collections::{BTreeMap, BTreeSet};
/// One line of a trial balance. /// One line of a trial balance.
#[derive(Clone, Debug, Serialize, Deserialize)] #[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. /// Profit and loss: revenue and expenses for the life of the ledger. For a
/// (Period filtering is R-FUTURE work; see `docs/requirements.md` R-7.) /// date range, with a line per account, use [`profit_and_loss_for_period`].
#[derive(Clone, Debug, Serialize, Deserialize)] #[derive(Clone, Debug, Serialize, Deserialize)]
pub struct ProfitAndLoss { pub struct ProfitAndLoss {
/// Total revenue (credit-normal balances, reported positive). /// Total revenue (credit-normal balances, reported positive).
@@ -265,6 +266,200 @@ pub fn balance_sheet(ledger: &Ledger) -> Result<BalanceSheet, LedgerError> {
}) })
} }
/// 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<StatementLine>,
/// Expense accounts with movement in the range, by code.
pub expenses: Vec<StatementLine>,
/// 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<StatementLine>,
/// Liability accounts with a balance, by code.
pub liabilities: Vec<StatementLine>,
/// Equity accounts with a balance, by code (current earnings excluded).
pub equity: Vec<StatementLine>,
/// 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<BTreeMap<String, (AccountType, StatementLine)>, LedgerError> {
let mut movement: BTreeMap<crate::AccountId, Decimal> = 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<ClassId>,
) -> Result<ProfitAndLossStatement, LedgerError> {
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<BalanceSheetStatement, LedgerError> {
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::<Decimal>() + 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. /// Render the three core reports as canonical JSON for oracle diffing.
/// ///
/// Amounts are decimal strings (never floats, never locale-formatted), lines /// Amounts are decimal strings (never floats, never locale-formatted), lines
@@ -406,11 +601,14 @@ pub fn cash_flow(
let financing_set: HashSet<crate::AccountId> = let financing_set: HashSet<crate::AccountId> =
params.financing_accounts.iter().copied().collect(); 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 movement_where = |account: crate::AccountId, pred: &dyn Fn(chrono::NaiveDate) -> bool| {
let mut total = Decimal::ZERO; let mut total = Decimal::ZERO;
for entry in ledger.posted_entries() { for entry in ledger.posted_entries() {
if !pred(entry.date()) { if !pred(entry.date()) || closings.contains(&entry.id()) {
continue; continue;
} }
for line in entry.lines() { for line in entry.lines() {
+50 -1
View File
@@ -9,7 +9,10 @@ use chrono::NaiveDate;
use ledger_core::{ use ledger_core::{
AccountType, Currency, Ledger, Money, NewAsset, Side, accumulated_depreciation, book_value, AccountType, Currency, Ledger, Money, NewAsset, Side, accumulated_depreciation, book_value,
depreciation_schedule, 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 proptest::prelude::*;
use rust_decimal::Decimal; use rust_decimal::Decimal;
@@ -136,6 +139,52 @@ proptest! {
prop_assert_eq!(bs.assets, bs.liabilities + bs.equity); 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. /// Every posted entry balances on its own, exactly.
#[test] #[test]
fn prop_posted_entries_balance((chart, entries) in arb_ops()) { fn prop_posted_entries_balance((chart, entries) in arb_ops()) {
+223
View File
@@ -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));
}
+2
View File
@@ -19,6 +19,8 @@ No other statuses.
| Trial balance | Reports → Trial Balance | done | golden-covered | | 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 | | 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 | | 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` | | Reversing entries | Reverse transaction | done | linked via `reversal_of` |
| Canonical report snapshots (JSON) | — (our oracle format) | done | `reports::snapshot` | | 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 | | Fixed-asset register + straight-line depreciation | Fixed Asset register | done | `assets.rs`; golden-covered schedule; idempotent runs; disposal with gain/loss |
+13 -4
View File
@@ -34,10 +34,19 @@ requirement conflict, the requirement wins.
total credits (the `balanced()` check). total credits (the `balanced()` check).
- **R-8 — Balance sheet.** Assets, liabilities, equity grouped by account - **R-8 — Balance sheet.** Assets, liabilities, equity grouped by account
type. The accounting equation holds exactly: assets = liabilities + equity. type. The accounting equation holds exactly: assets = liabilities + equity.
Because period close (R-FUTURE-2) doesn't exist yet, current earnings (net Current earnings (revenue minus expenses not yet closed to retained
income to date) are folded into equity so the equation holds on a live book. earnings) 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, `balance_sheet_as_of` gives the statement as of any date, a line per
with net income. Period filtering arrives with R-FUTURE-2. 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 - **R-10 — Canonical snapshots.** `reports::snapshot()` renders the three
core reports as deterministic JSON (decimal strings, sorted lines, fixed core reports as deterministic JSON (decimal strings, sorted lines, fixed
keys). Two engines fed the same books must produce identical output. This keys). Two engines fed the same books must produce identical output. This