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:
1 parent
5548902f27
commit
c6faeeb827
7 files changed
+505
-13
No files matched your search
@@ -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();
|
||||||
|
|||||||
@@ -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,
|
||||||
};
|
};
|
||||||
@@ -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() {
|
||||||
|
|||||||
@@ -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()) {
|
||||||
|
|||||||
@@ -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));
|
||||||
|
}
|
||||||
@@ -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
@@ -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
|
||||||
|
|||||||
Reference in new issue
Block a user