From beb1fcf233f3ed2ce49485b1b830164c508b90f7 Mon Sep 17 00:00:00 2001 From: Stephen Waits Date: Tue, 13 Oct 2026 14:39:06 -0600 Subject: [PATCH] test(core): spec-blind suites and a live GnuCash oracle; four fixes they found Max-testing pass, steps 2-4. Spec-blind tests (rank 1-2): four agents, each given only the specs and the public interface (doc comments and signatures, every body removed, never src/ or the existing tests), wrote tests/spec_reports.rs (62), spec_ledger.rs (72), spec_arap.rs (42), spec_assets_payroll.rs (50): hand-computed examples per scenario and a proptest law per invariant (conservation, partition, invariance, scaling, reversal cancels, "a refusal leaves the books unchanged" over random invalid commands). They found: 1. A refused payroll run changed the books: an all-zero run passed validation, then failed at post after creating seven payroll accounts and using up an entry id. validate_payroll_lines now refuses a run with no gross pay. 2. A refused add_account used up an account id (empty code, duplicate, unknown parent). The id is now taken only when the account is added. 3. Stale prose: close_period_to and members_equity still described the old last-member rounding. 4. Open (left #[ignore]d pending Stephen): depreciation_schedule's doc says "through the month containing" the date; the code prorates that month by days. GnuCash oracle (rank 3, an oracle never the spec): `just oracle` builds GnuCash 5.10 in a container pinned by base digest and package versions (oracle/gnucash/) and runs tests/oracle_gnucash.rs: 200 seeded random books built through wanderbooks (entries, foreign lines, reversals, monthly closes), every account's balance at month ends and random dates and each month's P&L, compared exactly: 15,800 comparisons. First run: 4,724 differences, all from foreign lines; without them, zero. Adjudicated from principles (rules.md B-3): wanderbooks booked foreign * rate unrounded ($108.34567); a ledger in one money holds no fractions of its smallest unit, and its cash must match a bank. Stephen decided: book in the base currency's minor unit, half away from zero. add_line_fx now rounds; minor units per ISO 4217 list one (2026-09-17, SIX), generated into Currency::minor_units and recorded in docs/spec/sources/iso-4217.md. Replay now takes the journaled base amount (replay_add_line_fx), so lines booked before replay exactly as booked (contract: books_written_under_older_rules_still_open). Now: zero differences, foreign lines included. Tests: 449, from 223. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01GWVo7x3UHw5kwCUaWxiejX --- crates/ledger-core/src/error.rs | 5 +- crates/ledger-core/src/ledger.rs | 96 +- crates/ledger-core/src/money.rs | 18 + crates/ledger-core/src/payroll.rs | 5 +- crates/ledger-core/src/reports.rs | 4 +- crates/ledger-core/tests/invariants.rs | 46 + crates/ledger-core/tests/oracle_gnucash.rs | 289 ++ .../tests/properties.proptest-regressions | 8 - crates/ledger-core/tests/spec_arap.rs | 1264 ++++++++ .../ledger-core/tests/spec_assets_payroll.rs | 1300 +++++++++ crates/ledger-core/tests/spec_ledger.rs | 2411 ++++++++++++++++ crates/ledger-core/tests/spec_reports.rs | 2535 +++++++++++++++++ crates/ledger-store/src/store.rs | 8 +- crates/ledger-store/tests/store.rs | 15 +- docs/oracle.md | 10 +- docs/spec/rules.md | 11 +- docs/spec/sources/iso-4217.md | 24 + justfile | 6 + oracle/gnucash/Dockerfile | 10 + oracle/gnucash/books.py | 96 + 20 files changed, 8110 insertions(+), 51 deletions(-) create mode 100644 crates/ledger-core/tests/oracle_gnucash.rs delete mode 100644 crates/ledger-core/tests/properties.proptest-regressions create mode 100644 crates/ledger-core/tests/spec_arap.rs create mode 100644 crates/ledger-core/tests/spec_assets_payroll.rs create mode 100644 crates/ledger-core/tests/spec_ledger.rs create mode 100644 crates/ledger-core/tests/spec_reports.rs create mode 100644 docs/spec/sources/iso-4217.md create mode 100644 oracle/gnucash/Dockerfile create mode 100644 oracle/gnucash/books.py diff --git a/crates/ledger-core/src/error.rs b/crates/ledger-core/src/error.rs index 316aa45..55cdb61 100644 --- a/crates/ledger-core/src/error.rs +++ b/crates/ledger-core/src/error.rs @@ -348,8 +348,9 @@ pub enum LedgerError { #[error("replay id mismatch: journal says {0}, allocator expects {1}")] ReplayIdMismatch(u64, u64), - /// A payroll run must have at least one employee line. - #[error("payroll run must have at least one line")] + /// A payroll run must pay some wages: at least one employee line with + /// gross pay. + #[error("payroll run must have at least one employee with gross pay")] EmptyPayroll, /// Payroll amounts must not be negative. diff --git a/crates/ledger-core/src/ledger.rs b/crates/ledger-core/src/ledger.rs index 7091c31..1ac8502 100644 --- a/crates/ledger-core/src/ledger.rs +++ b/crates/ledger-core/src/ledger.rs @@ -541,8 +541,8 @@ impl Ledger { parent: Option, placeholder: bool, ) -> Result { + // The id is used only if the account is: a refusal uses up nothing. let id = self.next_account_id; - self.next_account_id += 1; self.chart.add(Account { id, code: code.to_string(), @@ -554,6 +554,7 @@ impl Ledger { tax_category: None, deductible_percent: None, })?; + self.next_account_id += 1; Ok(id) } @@ -714,10 +715,26 @@ impl Ledger { } /// Add a foreign-currency line to a draft entry. `foreign_amount` is the - /// amount in `foreign_currency`; `fx_rate` converts foreign -> base - /// (base = foreign * rate). The stored `amount` is the base-currency - /// equivalent; the foreign original is kept for reference. Rates are - /// exact Decimals, never floats. + /// amount in `foreign_currency`; `fx_rate` converts foreign -> base. + /// The stored `amount` is foreign * rate rounded to the base currency's + /// minor unit (cents for USD), half away from zero; the foreign + /// original is kept for reference. Rates are exact Decimals, never + /// floats. + /// + /// ```text + /// GOAL: book a foreign-currency amount in the ledger's one money. + /// SOURCE: docs/spec/rules.md B-3 (Pacioli ch. 36: one money; ASC + /// 830-20-30-1: at the transaction-date rate); minor units: ISO + /// 4217; rounding: DECISION (Stephen, 2026-10-13). + /// INVARIANTS: + /// 1. The base amount MUST be a whole number of the base currency's + /// smallest unit, within half a unit of foreign * rate. + /// 2. NEVER convert into the base currency itself. + /// SCENARIOS: + /// 1. IF 100.00 EUR at 1.0834567 into USD: SHOULD book 108.35. + /// 2. IF 10.00 USD at 151.237 into JPY: SHOULD book 1512. + /// 3. IF the rounded amount is zero: SHOULD refuse. + /// ``` pub fn add_line_fx( &mut self, entry: EntryId, @@ -728,27 +745,6 @@ impl Ledger { fx_rate: Decimal, ) -> Result<(), LedgerError> { self.check_line_account(account)?; - self.replay_add_line_fx( - entry, - account, - side, - foreign_currency, - foreign_amount, - fx_rate, - ) - } - - /// [`Self::add_line_fx`] without the account check added on - /// 2026-10-13, for replaying journals written before it. - pub fn replay_add_line_fx( - &mut self, - entry: EntryId, - account: AccountId, - side: Side, - foreign_currency: Currency, - foreign_amount: Decimal, - fx_rate: Decimal, - ) -> Result<(), LedgerError> { if foreign_amount <= Decimal::ZERO { return Err(LedgerError::InvalidDocumentLineAmount(foreign_amount)); } @@ -761,18 +757,55 @@ impl Ledger { found: foreign_currency.code().to_string(), }); } - let base_amount = foreign_amount + let exact = foreign_amount .checked_mul(fx_rate) .ok_or(LedgerError::AmountOutOfRange)?; + // Booked in the base currency's smallest unit (cents for USD), half + // away from zero: a ledger in one money holds no fractions of its + // smallest unit (rules.md B-3; DECISION, Stephen 2026-10-13, after + // the GnuCash cross-check found the difference). + let base_amount = match self.base_currency.minor_units() { + Some(dp) => exact + .round_dp_with_strategy(dp, rust_decimal::RoundingStrategy::MidpointAwayFromZero), + None => exact, + }; if base_amount <= Decimal::ZERO { return Err(LedgerError::InvalidFxRate(fx_rate)); } - let base = self.base_currency; + self.replay_add_line_fx( + entry, + account, + side, + Money::new(base_amount, self.base_currency), + foreign_currency, + foreign_amount, + ) + } + + /// A foreign-currency line exactly as a journal recorded it: the base + /// amount as booked (rounded or not, by the rule of its day) and the + /// foreign original. For replay only; new work uses + /// [`Self::add_line_fx`]. + pub fn replay_add_line_fx( + &mut self, + entry: EntryId, + account: AccountId, + side: Side, + amount: Money, + foreign_currency: Currency, + foreign_amount: Decimal, + ) -> Result<(), LedgerError> { + if amount.currency != self.base_currency { + return Err(LedgerError::CurrencyMismatch { + expected: self.base_currency.code().to_string(), + found: amount.currency.code().to_string(), + }); + } let e = self.draft_mut(entry)?; e.push_line(JournalLine { account, side, - amount: Money::new(base_amount, base), + amount, foreign_currency: Some(foreign_currency), foreign_amount: Some(foreign_amount), class_id: None, @@ -1168,8 +1201,9 @@ impl Ledger { /// - `RetainedEarnings`: account `3999`, created if missing. /// - `Account`: one equity account (a sole proprietor's capital). /// - `Members`: each member's capital account gets their share of - /// the net, to the cent (the last absorbs rounding), and their own - /// distribution account. + /// the net, to the cent (odd cents by largest remainder; closes + /// journaled before 2026-10-13 replay with the last member + /// absorbing them), and their own distribution account. /// 3. Marks the period closed. /// /// Returns the closing entry's id, or `None` if there was nothing to diff --git a/crates/ledger-core/src/money.rs b/crates/ledger-core/src/money.rs index f038c37..355d13b 100644 --- a/crates/ledger-core/src/money.rs +++ b/crates/ledger-core/src/money.rs @@ -39,6 +39,24 @@ impl Currency { Ok(Currency(upper)) } + /// The currency's minor units: how many decimal places its smallest + /// unit has (2 for USD's cents, 0 for JPY, 3 for KWD), or `None` for + /// codes ISO 4217 gives none (gold, SDRs). Unlisted codes are 2. + /// + /// SOURCE: ISO 4217 list one, published 2026-09-17 + /// (docs/spec/sources/iso-4217.md); generated from it, not typed in. + pub fn minor_units(&self) -> Option { + match self.code() { + "BIF" | "CLP" | "DJF" | "GNF" | "ISK" | "JPY" | "KMF" | "KRW" | "PYG" | "RWF" + | "UGX" | "UYI" | "VND" | "VUV" | "XAF" | "XOF" | "XPF" => Some(0), + "BHD" | "IQD" | "JOD" | "KWD" | "LYD" | "OMR" | "TND" => Some(3), + "CLF" | "UYW" => Some(4), + "XAG" | "XAU" | "XBA" | "XBB" | "XBC" | "XBD" | "XDR" | "XPD" | "XPT" | "XSU" + | "XTS" | "XUA" | "XXX" => None, + _ => Some(2), + } + } + /// The three-letter code, e.g. `"USD"`. pub fn code(&self) -> &str { std::str::from_utf8(&self.0).expect("currency codes are ASCII by construction") diff --git a/crates/ledger-core/src/payroll.rs b/crates/ledger-core/src/payroll.rs index 6392d00..6746d3d 100644 --- a/crates/ledger-core/src/payroll.rs +++ b/crates/ledger-core/src/payroll.rs @@ -91,7 +91,10 @@ pub struct PayrollAccounts { /// amounts, no negative net pay. Journaling stores run this before writing /// anything, so a doomed run never leaves journal lines behind. pub fn validate_payroll_lines(lines: &[PayrollLine]) -> Result<(), LedgerError> { - if lines.is_empty() { + // No wages at all is no payroll: refused before anything changes + // (found by the spec-blind tests, 2026-10-13: an all-zero run passed + // validation, then failed at post after creating accounts). + if lines.is_empty() || lines.iter().all(|l| l.gross_pay.is_zero()) { return Err(LedgerError::EmptyPayroll); } for line in lines { diff --git a/crates/ledger-core/src/reports.rs b/crates/ledger-core/src/reports.rs index b6c7ef2..eb134f1 100644 --- a/crates/ledger-core/src/reports.rs +++ b/crates/ledger-core/src/reports.rs @@ -810,8 +810,8 @@ pub struct MembersEquityStatement { /// distributions (debits). Earnings are partnership earnings: net income /// for the period, and earnings from before it not yet distributed (retained /// earnings plus unclosed income), are allocated by share. Allocation is to -/// the cent with the last member absorbing rounding, so every row's total -/// is exact and the ending total equals balance-sheet equity. +/// the cent, odd cents by largest remainder, so every row's total is exact +/// and the ending total equals balance-sheet equity. /// /// ```text /// GOAL: each member's capital roll-forward for a partnership over a diff --git a/crates/ledger-core/tests/invariants.rs b/crates/ledger-core/tests/invariants.rs index 8ff29cb..b524663 100644 --- a/crates/ledger-core/tests/invariants.rs +++ b/crates/ledger-core/tests/invariants.rs @@ -1016,3 +1016,49 @@ fn a_line_on_an_unknown_or_placeholder_account_is_refused_when_added() { "refused lines aren't added" ); } + +/// Spec (rules.md B-3; DECISION, Stephen 2026-10-13, after the GnuCash +/// cross-check): a foreign line is booked in the base currency's smallest +/// unit, half away from zero; the foreign original is kept. +#[test] +fn a_foreign_line_is_booked_in_whole_base_units() { + let book = |base: Currency, foreign: Currency, amount: &str, rate: &str| { + let mut l = Ledger::new(base); + let a = l + .add_account("1000", "Cash", AccountType::Asset, None, false) + .unwrap(); + let e = l.create_entry(date(5), "fx"); + let r = l.add_line_fx( + e, + a, + Side::Debit, + foreign, + amount.parse().unwrap(), + rate.parse().unwrap(), + ); + r.map(|()| { + let line = l.get_entry(e).unwrap().lines()[0].clone(); + (line.amount.amount, line.foreign_amount) + }) + }; + let dec = |s: &str| s.parse::().unwrap(); + // 100.00 EUR x 1.0834567 = 108.34567 USD -> 108.35. + assert_eq!( + book(Currency::USD, Currency::EUR, "100.00", "1.0834567").unwrap(), + (dec("108.35"), Some(dec("100.00"))) + ); + // 10.00 USD x 151.237 = 1512.37 JPY -> 1512 (yen have no minor unit). + assert_eq!( + book(Currency::JPY, Currency::USD, "10.00", "151.237") + .unwrap() + .0, + dec("1512") + ); + // Exactly half a cent rounds away from zero. + assert_eq!( + book(Currency::USD, Currency::EUR, "1", "0.125").unwrap().0, + dec("0.13") + ); + // Rounds to nothing: refused. + assert!(book(Currency::USD, Currency::EUR, "0.001", "1").is_err()); +} diff --git a/crates/ledger-core/tests/oracle_gnucash.rs b/crates/ledger-core/tests/oracle_gnucash.rs new file mode 100644 index 0000000..2b238a4 --- /dev/null +++ b/crates/ledger-core/tests/oracle_gnucash.rs @@ -0,0 +1,289 @@ +//! Cross-check against GnuCash (rank 3: a differential oracle). +//! +//! GnuCash is *an* independent implementation of double entry, not the +//! spec (docs/oracle.md, "Who is right"). This test builds random books +//! through wanderbooks' engine (entries, foreign-currency lines, reversals, +//! period closes), hands their posted entries to GnuCash 5.10 running in a +//! pinned container (oracle/gnucash/), and compares every account's +//! balance at month ends and random dates, exactly. A difference is a +//! finding to adjudicate from principles (rules.md), never a reason to +//! change wanderbooks to match. +//! +//! Needs Docker: `just oracle` builds the image and runs this. + +use chrono::NaiveDate; +use ledger_core::reports::{profit_and_loss_for_period, trial_balance_as_of}; +use ledger_core::{AccountId, AccountType, Currency, Ledger, Money, Side}; +use rust_decimal::Decimal; +use serde_json::{Value, json}; +use std::collections::BTreeMap; +use std::io::Write; +use std::process::{Command, Stdio}; + +/// Books per run, and the seed of the first. +const BOOKS: u64 = 200; +const SEED: u64 = 0x005e_ed0f_b00c; + +/// A small deterministic generator (xorshift64*): the run is reproducible +/// from its seed, with no new dependency. +struct Rng(u64); +impl Rng { + fn next(&mut self) -> u64 { + self.0 ^= self.0 >> 12; + self.0 ^= self.0 << 25; + self.0 ^= self.0 >> 27; + self.0.wrapping_mul(0x2545_f491_4f6c_dd1d) + } + fn below(&mut self, n: u64) -> u64 { + self.next() % n + } +} + +fn d(m: u32, day: u32) -> NaiveDate { + NaiveDate::from_ymd_opt(2026, m, day).unwrap() +} + +fn month_end(m: u32) -> NaiveDate { + if m == 12 { + d(12, 31) + } else { + d(m + 1, 1).pred_opt().unwrap() + } +} + +/// Random books through the engine's public commands. Closing months +/// (when `closes`) happens in order, after the month's entries. +fn generate(seed: u64, closes: bool, fx: bool) -> (Ledger, Vec) { + let mut r = Rng(seed | 1); + let mut l = Ledger::new(Currency::USD); + let chart = [ + ("1000", "Cash", AccountType::Asset), + ("1500", "Equipment", AccountType::Asset), + ("2000", "Payables", AccountType::Liability), + ("3000", "Capital", AccountType::Equity), + ("4000", "Sales", AccountType::Revenue), + ("4100", "Interest", AccountType::Revenue), + ("6000", "Rent", AccountType::Expense), + ("6100", "Software", AccountType::Expense), + ]; + let ids: Vec = chart + .iter() + .map(|(c, n, t)| l.add_account(c, n, *t, None, false).unwrap()) + .collect(); + let mut posted = Vec::new(); + for month in 1..=6u32 { + let entries = 1 + r.below(6); + for _ in 0..entries { + let day = 1 + r.below(28) as u32; + let e = l.create_entry(d(month, day), "generated"); + let lines = 1 + r.below(3); + let mut net = Decimal::ZERO; + for _ in 0..lines { + let account = ids[r.below(ids.len() as u64) as usize]; + let side = if r.below(2) == 0 { + Side::Debit + } else { + Side::Credit + }; + if fx && r.below(8) == 0 { + // A foreign line: odd decimal places in the base amount. + let foreign = Decimal::new(1 + r.below(500_000) as i64, 2); + let rate = Decimal::new(5_000 + r.below(20_000) as i64, 4); + l.add_line_fx(e, account, side, Currency::EUR, foreign, rate) + .unwrap(); + // Balance against what was booked (rounded to the cent). + let base = l + .get_entry(e) + .unwrap() + .lines() + .last() + .unwrap() + .amount + .amount; + net += if side == Side::Debit { base } else { -base }; + } else { + let amount = Decimal::new(1 + r.below(1_000_000) as i64, 2); + l.add_line(e, account, side, Money::new(amount, Currency::USD)) + .unwrap(); + net += if side == Side::Debit { amount } else { -amount }; + } + } + // A plug line on cash balances the entry. + if !net.is_zero() { + let side = if net > Decimal::ZERO { + Side::Credit + } else { + Side::Debit + }; + l.add_line(e, ids[0], side, Money::new(net.abs(), Currency::USD)) + .unwrap(); + } else { + let amount = Money::new(Decimal::ONE, Currency::USD); + l.add_line(e, ids[0], Side::Debit, amount).unwrap(); + l.add_line(e, ids[3], Side::Credit, amount).unwrap(); + } + if l.post_entry(e).is_ok() { + posted.push(e); + } + } + // Now and then, reverse an earlier entry of this month. + if r.below(3) == 0 + && let Some(&e) = posted.last() + { + let date = l.get_entry(e).unwrap().date(); + let _ = l.reverse_entry(e, date, "reversal"); + } + if closes { + let p = l + .create_period(&format!("2026-{month:02}"), d(month, 1), month_end(month)) + .unwrap(); + l.close_period(p).unwrap(); + } + } + (l, ids) +} + +fn book_json(l: &Ledger, as_of: &[NaiveDate]) -> Value { + let accounts: Vec = l + .chart() + .all() + .map(|a| { + json!({"id": a.id, "code": a.code, "name": a.name, + "type": format!("{:?}", a.account_type)}) + }) + .collect(); + let entries: Vec = l + .posted_entries() + .map(|e| { + json!({ + "date": e.date().to_string(), + "description": e.description(), + "lines": e.lines().iter().map(|ln| json!({ + "account": ln.account, + "side": if ln.side == Side::Debit { "debit" } else { "credit" }, + "amount": ln.amount.amount.to_string(), + })).collect::>(), + }) + }) + .collect(); + json!({ + "accounts": accounts, + "entries": entries, + "as_of": as_of.iter().map(|d| d.to_string()).collect::>(), + }) +} + +/// wanderbooks' trial balance as of a date, as GnuCash reports balances: +/// debits positive, by account id, zero for accounts not listed. +fn ours(l: &Ledger, as_of: NaiveDate) -> BTreeMap { + let tb = trial_balance_as_of(l, as_of); + let mut out: BTreeMap = + l.chart().all().map(|a| (a.id, Decimal::ZERO)).collect(); + for line in tb.lines { + let id = l.chart().find_by_code(&line.code).unwrap().id; + out.insert(id, line.debit - line.credit); + } + out +} + +fn gnucash(books: &[Value]) -> Vec { + let mut child = Command::new("docker") + .args(["run", "--rm", "-i", "wanderbooks-gnucash-oracle"]) + .stdin(Stdio::piped()) + .stdout(Stdio::piped()) + .stderr(Stdio::null()) + .spawn() + .expect("docker is needed: run `just oracle`"); + child + .stdin + .take() + .unwrap() + .write_all(serde_json::to_string(books).unwrap().as_bytes()) + .unwrap(); + let out = child.wait_with_output().unwrap(); + assert!( + out.status.success(), + "the GnuCash oracle failed; build it with `just oracle`" + ); + serde_json::from_slice(&out.stdout).expect("oracle output is JSON") +} + +fn dec(v: &Value) -> Decimal { + v.as_str().unwrap().parse().unwrap() +} + +#[test] +#[ignore = "needs Docker and the GnuCash image: `just oracle`"] +fn balances_match_gnucash_on_generated_books() { + let mut cases = Vec::new(); + for i in 0..BOOKS { + let seed = SEED.wrapping_add(i.wrapping_mul(0x9e37_79b9_7f4a_7c15)); + let closes = i % 2 == 1; + let (l, _) = generate(seed, closes, true); + let mut r = Rng(seed ^ 0xa5a5); + let mut as_of: Vec = (1..=6).map(month_end).collect(); + for _ in 0..3 { + as_of.push(d(1 + r.below(6) as u32, 1 + r.below(28) as u32)); + } + cases.push((seed, closes, l, as_of)); + } + let books: Vec = cases + .iter() + .map(|(_, _, l, as_of)| book_json(l, as_of)) + .collect(); + let results = gnucash(&books); + assert_eq!(results.len(), cases.len()); + + let mut diffs = Vec::new(); + let mut compared = 0usize; + for ((seed, closes, l, as_of), result) in cases.iter().zip(&results) { + for date in as_of { + let theirs = &result["balances"][date.to_string()]; + for (id, mine) in ours(l, *date) { + let g = dec(&theirs[id.to_string()]); + compared += 1; + if g != mine { + let code = &l.chart().get(id).unwrap().code; + diffs.push(format!( + "seed {seed:#x} (closes: {closes}) account {code} as of {date}: wanderbooks {mine}, GnuCash {g}" + )); + } + } + } + // A month's P&L is the change in its income and expense balances, + // on books with no closing entries (GnuCash books a close as an + // ordinary transaction; wanderbooks' period P&L leaves it out). + if !closes { + for m in 2..=6u32 { + let pnl = profit_and_loss_for_period(l, d(m, 1), month_end(m), None).unwrap(); + let before = &result["balances"][month_end(m - 1).to_string()]; + let after = &result["balances"][month_end(m).to_string()]; + let mut gnucash_net = Decimal::ZERO; + for a in l.chart().all() { + if matches!(a.account_type, AccountType::Revenue | AccountType::Expense) { + let k = a.id.to_string(); + // Income is credit-normal: net income = -(change). + gnucash_net -= dec(&after[&k]) - dec(&before[&k]); + } + } + compared += 1; + if gnucash_net != pnl.net_income() { + diffs.push(format!( + "seed {seed:#x} P&L 2026-{m:02}: wanderbooks {}, GnuCash {gnucash_net}", + pnl.net_income() + )); + } + } + } + } + eprintln!( + "compared {compared} balances and P&Ls across {} books", + cases.len() + ); + assert!( + diffs.is_empty(), + "{} difference(s) with GnuCash; adjudicate each from rules.md:\n{}", + diffs.len(), + diffs.join("\n") + ); +} diff --git a/crates/ledger-core/tests/properties.proptest-regressions b/crates/ledger-core/tests/properties.proptest-regressions deleted file mode 100644 index 14c8aaf..0000000 --- a/crates/ledger-core/tests/properties.proptest-regressions +++ /dev/null @@ -1,8 +0,0 @@ -# Seeds for failure cases proptest has generated in the past. It is -# automatically read and these particular cases re-run before any -# novel cases are generated. -# -# It is recommended to check this file in to source control so that -# everyone who runs the test benefits from these saved cases. -cc 973b72cb286deab3cabcf8a48a797a1aa76e660f1a6f3a7046d02f655ad467a6 # shrinks to cents = 8074248648, shares = [0.0877914951989026063100137174, 0.327846364883401920438957476, 0.2235939643347050754458161866, 0.3607681755829903978052126200] -cc 181075cd6abb81d059781749aadb13e990e59e6658279089e173fe655bd6bcf2 # shrinks to cents = 4209937262, shares = [0.3215789473684210526315789474, 0.2426315789473684210526315789, 0.1663157894736842105263157895, 0.2694736842105263157894736842] diff --git a/crates/ledger-core/tests/spec_arap.rs b/crates/ledger-core/tests/spec_arap.rs new file mode 100644 index 0000000..7ae3e07 --- /dev/null +++ b/crates/ledger-core/tests/spec_arap.rs @@ -0,0 +1,1264 @@ +//! Spec-derived tests for A/R and A/P: customers, vendors, invoices, bills, +//! payments, aging, and the control accounts they post to. +//! +//! Oracles are the spec blocks in the `ledger-core` interface (doc comments +//! on `Ledger::create_invoice`, `create_bill`, `receive_payment`, `pay_bill`, +//! `arap::aging_bucket`, the `ControlAccount` / `ControlAccountMismatch` +//! docs) and `docs/spec/rules.md` B-18. Expected values are computed by hand +//! from those, never from the code under test. + +use std::str::FromStr; + +use chrono::{Duration, NaiveDate}; +use ledger_core::arap::{aging_bucket, derive_status, validate_due_date, validate_lines}; +use ledger_core::{ + AccountId, AccountType, AgingRow, Bill, Currency, DocumentLine, DocumentStatus, EntryStatus, + Invoice, Ledger, LedgerError, Side, +}; +use proptest::prelude::*; +use rust_decimal::Decimal; + +// ---------------------------------------------------------------- helpers + +fn dec(s: &str) -> Decimal { + Decimal::from_str(s).expect("decimal literal") +} + +fn cents(c: i64) -> Decimal { + Decimal::new(c, 2) +} + +fn d(y: i32, m: u32, day: u32) -> NaiveDate { + NaiveDate::from_ymd_opt(y, m, day).expect("valid date") +} + +fn line(desc: &str, amount: &str) -> DocumentLine { + DocumentLine { + description: desc.to_string(), + amount: dec(amount), + } +} + +/// A ledger with only a cash account (1000), which the payment commands +/// need as their cash side. +fn books() -> (Ledger, AccountId) { + let mut l = Ledger::new(Currency::USD); + let cash = l + .add_account("1000", "Cash", AccountType::Asset, None, false) + .expect("cash account"); + (l, cash) +} + +fn acct(l: &Ledger, code: &str) -> Option { + l.chart().find_by_code(code).map(|a| a.id) +} + +fn bal(l: &Ledger, code: &str) -> Decimal { + match acct(l, code) { + Some(id) => l.account_balance(id).expect("known account"), + None => Decimal::ZERO, + } +} + +/// Everything observable about the books, deterministically ordered, so a +/// refusal can be checked to leave them unchanged. +fn fingerprint(l: &Ledger) -> String { + let mut out = String::new(); + let mut accts: Vec<_> = l + .chart() + .all() + .map(|a| { + format!( + "{}|{}|{}|{:?}|{}|{:?}", + a.id, a.code, a.name, a.account_type, a.placeholder, a.parent + ) + }) + .collect(); + accts.sort(); + out.push_str(&accts.join("\n")); + out.push_str(&format!( + "\nnext: acct {} entry {} inv {} bill {} cust {} vend {}\n", + l.next_account_id(), + l.next_entry_id(), + l.next_invoice_id(), + l.next_bill_id(), + l.next_customer_id(), + l.next_vendor_id() + )); + for id in 0..l.next_entry_id() + 2 { + if let Some(e) = l.get_entry(id) { + out.push_str(&format!( + "E{} {} {:?} {:?} {:?} {:?}\n", + e.id(), + e.date(), + e.description(), + e.status(), + e.lines() + .iter() + .map(|x| (x.account, x.side, x.amount.amount)) + .collect::>(), + l.entry_vendor(id) + )); + } + } + for id in 0..l.next_invoice_id() + 2 { + if let Some(i) = l.get_invoice(id) { + out.push_str(&format!("{i:?}\n")); + } + } + for id in 0..l.next_bill_id() + 2 { + if let Some(b) = l.get_bill(id) { + out.push_str(&format!("{b:?}\n")); + } + } + for c in l.list_customers() { + out.push_str(&format!("{c:?}\n")); + } + for v in l.list_vendors() { + out.push_str(&format!("{v:?}\n")); + } + out +} + +/// Expected bucket index from rules.md B-18, written independently: +/// 0 Current (on or before due), 1: 1-30, 2: 31-60, 3: 61-90, 4: 90+. +fn expected_bucket(due: NaiveDate, as_of: NaiveDate) -> usize { + let days = (as_of - due).num_days(); + match days { + i64::MIN..=0 => 0, + 1..=30 => 1, + 31..=60 => 2, + 61..=90 => 3, + _ => 4, + } +} + +fn buckets(r: &AgingRow) -> [Decimal; 5] { + [r.current, r.days_30, r.days_60, r.days_90, r.days_90_plus] +} + +fn status_law(paid: Decimal, total: Decimal) -> DocumentStatus { + if paid.is_zero() { + DocumentStatus::Open + } else if paid < total { + DocumentStatus::Partial + } else { + DocumentStatus::Paid + } +} + +/// The (account, side, amount) lines of an entry. +fn entry_lines(l: &Ledger, id: u64) -> Vec<(AccountId, Side, Decimal)> { + l.get_entry(id) + .expect("entry exists") + .lines() + .iter() + .map(|x| (x.account, x.side, x.amount.amount)) + .collect() +} + +/// Net debit-minus-credit per account within one entry. +fn net_for(lines: &[(AccountId, Side, Decimal)], account: AccountId) -> Decimal { + lines + .iter() + .filter(|(a, _, _)| *a == account) + .map(|(_, s, amt)| match s { + Side::Debit => *amt, + Side::Credit => -*amt, + }) + .sum() +} + +// ------------------------------------------------- Invoice / Bill methods + +/// Oracle: Invoice doc: total() is "Total of all lines", balance() is +/// "Amount still owed" = total - amount_paid. +#[test] +fn invoice_total_is_sum_of_lines_and_balance_is_total_minus_paid() { + let inv = Invoice { + id: 7, + customer_id: 1, + date: d(2026, 3, 1), + due_date: d(2026, 3, 31), + lines: vec![line("a", "100.10"), line("b", "0.05"), line("c", "49.85")], + amount_paid: dec("60.00"), + status: DocumentStatus::Partial, + entry_id: 1, + }; + assert_eq!(inv.total(), dec("150.00")); + assert_eq!(inv.balance(), dec("90.00")); +} + +/// Oracle: Bill doc: total() is the sum of lines, balance() total - paid. +#[test] +fn bill_total_is_sum_of_lines_and_balance_is_total_minus_paid() { + let bill = Bill { + id: 3, + vendor_id: 2, + date: d(2026, 3, 1), + due_date: d(2026, 3, 1), + lines: vec![line("x", "19.99"), line("y", "0.01")], + amount_paid: dec("20.00"), + status: DocumentStatus::Paid, + entry_id: 9, + }; + assert_eq!(bill.total(), dec("20.00")); + assert_eq!(bill.balance(), Decimal::ZERO); +} + +// ------------------------------------------------------ pure validators + +/// Oracle: validate_lines doc: "at least one" line. +#[test] +fn validate_lines_refuses_no_lines() { + assert_eq!(validate_lines(&[]), Err(LedgerError::EmptyDocumentLines)); +} + +/// Oracle: validate_lines doc: "all positive amounts" (zero is not positive). +#[test] +fn validate_lines_refuses_zero_and_negative_amounts() { + assert_eq!( + validate_lines(&[line("ok", "1.00"), line("zero", "0")]), + Err(LedgerError::InvalidDocumentLineAmount(Decimal::ZERO)) + ); + assert_eq!( + validate_lines(&[line("neg", "-0.01")]), + Err(LedgerError::InvalidDocumentLineAmount(dec("-0.01"))) + ); +} + +/// Oracle: validate_lines doc: one or more positive lines are valid. +#[test] +fn validate_lines_accepts_positive_lines() { + assert_eq!(validate_lines(&[line("a", "0.01")]), Ok(())); + assert_eq!( + validate_lines(&[line("a", "5"), line("b", "1000000")]), + Ok(()) + ); +} + +/// Oracle: validate_due_date doc: due date "not before" the document date +/// (equal is allowed). +#[test] +fn validate_due_date_allows_same_day_and_later_refuses_earlier() { + assert_eq!(validate_due_date(d(2026, 5, 1), d(2026, 5, 1)), Ok(())); + assert_eq!(validate_due_date(d(2026, 5, 1), d(2026, 6, 1)), Ok(())); + assert!(matches!( + validate_due_date(d(2026, 5, 1), d(2026, 4, 30)), + Err(LedgerError::DueBeforeDate(_)) + )); +} + +/// Oracle: DocumentStatus docs: Open = nothing paid, Partial = partially +/// paid, Paid = fully paid. +#[test] +fn derive_status_examples() { + assert_eq!(derive_status(dec("0"), dec("100")), DocumentStatus::Open); + assert_eq!( + derive_status(dec("0.01"), dec("100")), + DocumentStatus::Partial + ); + assert_eq!( + derive_status(dec("99.99"), dec("100")), + DocumentStatus::Partial + ); + assert_eq!( + derive_status(dec("100.00"), dec("100")), + DocumentStatus::Paid + ); +} + +// ------------------------------------------------------------ aging_bucket + +/// Oracle: aging_bucket SCENARIO 1 and B-18 DECISION: due today is Current. +#[test] +fn aging_due_today_is_current() { + let r = aging_bucket(d(2026, 10, 13), d(2026, 10, 13), dec("42.00")); + assert_eq!( + buckets(&r), + [dec("42.00"), dec("0"), dec("0"), dec("0"), dec("0")] + ); +} + +/// Oracle: aging_bucket SCENARIO 1: before the due date is Current. +#[test] +fn aging_not_yet_due_is_current() { + let r = aging_bucket(d(2026, 12, 31), d(2026, 10, 13), dec("5")); + assert_eq!(r.current, dec("5")); + assert_eq!(r.total(), dec("5")); +} + +/// Oracle: aging_bucket SCENARIOS 2-5 at each edge (1, 30, 31, 60, 61, 90, +/// 91 days past due), B-18. +#[test] +fn aging_boundaries_by_days_past_due() { + let due = d(2026, 1, 1); + let cases: [(i64, usize); 8] = [ + (1, 1), + (30, 1), + (31, 2), + (60, 2), + (61, 3), + (90, 3), + (91, 4), + (400, 4), + ]; + for (days, idx) in cases { + let r = aging_bucket(due, due + Duration::days(days), dec("10.00")); + let mut want = [Decimal::ZERO; 5]; + want[idx] = dec("10.00"); + assert_eq!(buckets(&r), want, "{days} days past due"); + } +} + +/// Oracle: AgingRow::total doc ("Total outstanding") = sum of the buckets. +#[test] +fn aging_row_total_sums_buckets() { + let r = AgingRow { + name: "x".into(), + current: dec("1"), + days_30: dec("2"), + days_60: dec("3.5"), + days_90: dec("4"), + days_90_plus: dec("0.25"), + }; + assert_eq!(r.total(), dec("10.75")); +} + +proptest! { + #![proptest_config(ProptestConfig::with_cases(256))] + + /// Oracle: aging_bucket INVARIANT 1 (whole balance in exactly one + /// bucket, others zero) and SCENARIOS 1-5 (which bucket). + #[test] + fn prop_aging_one_bucket_matches_spec( + offset in -400i64..400, + bal_cents in 1i64..100_000_000, + ) { + let due = d(2026, 6, 15); + let as_of = due + Duration::days(offset); + let balance = cents(bal_cents); + let r = aging_bucket(due, as_of, balance); + let b = buckets(&r); + let mut want = [Decimal::ZERO; 5]; + want[expected_bucket(due, as_of)] = balance; + prop_assert_eq!(b, want); + prop_assert_eq!(r.total(), balance); + } + + /// Oracle: aging_bucket INVARIANT 2: never moves to a younger bucket as + /// as_of advances. + #[test] + fn prop_aging_only_ages( + a in -200i64..300, + step in 0i64..200, + ) { + let due = d(2026, 6, 15); + let r1 = aging_bucket(due, due + Duration::days(a), dec("1")); + let r2 = aging_bucket(due, due + Duration::days(a + step), dec("1")); + let idx = |r: &AgingRow| buckets(r).iter().position(|x| !x.is_zero()).unwrap(); + prop_assert!(idx(&r2) >= idx(&r1)); + } + + /// Oracle: DocumentStatus docs (Open / Partial / Paid exactly per paid: + /// zero / between / equal). + #[test] + fn prop_derive_status_law(total_c in 1i64..10_000_000, frac in 0u32..=100) { + let total = cents(total_c); + let paid = match frac { + 0 => Decimal::ZERO, + 100 => total, + f => (total * Decimal::from(f) / Decimal::from(100)).round_dp(2), + }; + prop_assert_eq!(derive_status(paid, total), status_law(paid, total)); + } + + /// Oracle: validate_lines doc: valid iff non-empty and every amount > 0. + #[test] + fn prop_validate_lines_iff_nonempty_and_positive( + amts in proptest::collection::vec(-1000i64..1000, 0..5) + ) { + let lines: Vec = amts + .iter() + .map(|c| DocumentLine { description: "l".into(), amount: cents(*c) }) + .collect(); + let ok = !amts.is_empty() && amts.iter().all(|c| *c > 0); + prop_assert_eq!(validate_lines(&lines).is_ok(), ok); + } +} + +// ------------------------------------------------------ customers/vendors + +/// Oracle: add_customer doc ("Returns its id") with get_customer / +/// list_customers ("in id order"). +#[test] +fn add_customer_and_vendor_are_retrievable_in_id_order() { + let (mut l, _) = books(); + let a = l.add_customer("Acme").unwrap(); + let b = l.add_customer("Beta").unwrap(); + assert_ne!(a, b); + assert_eq!(l.get_customer(a).unwrap().name, "Acme"); + assert_eq!(l.get_customer(b).unwrap().name, "Beta"); + let names: Vec<_> = l.list_customers().iter().map(|c| c.name.clone()).collect(); + assert_eq!(names, vec!["Acme", "Beta"]); + + let v = l.add_vendor("Landlord").unwrap(); + let w = l.add_vendor("Hosting").unwrap(); + assert_ne!(v, w); + assert_eq!(l.get_vendor(v).unwrap().name, "Landlord"); + let vn: Vec<_> = l.list_vendors().iter().map(|x| x.name.clone()).collect(); + assert_eq!(vn, vec!["Landlord", "Hosting"]); +} + +/// Oracle: next_customer_id / next_vendor_id docs ("the id the next ... +/// added will receive"). +#[test] +fn add_customer_and_vendor_use_the_announced_next_id() { + let (mut l, _) = books(); + let nc = l.next_customer_id(); + assert_eq!(l.add_customer("A").unwrap(), nc); + let nv = l.next_vendor_id(); + assert_eq!(l.add_vendor("V").unwrap(), nv); +} + +// ------------------------------------------------------------ invoices + +/// Oracle: create_invoice INVARIANT 1 (Dr 1200 / Cr 4000 for the sum of the +/// lines, dated the invoice date) and INVARIANT 2 (starts open, nothing +/// paid); control accounts doc: missing 1200/4000 are created. +#[test] +fn create_invoice_posts_dr_receivables_cr_revenue_for_the_line_sum() { + let (mut l, _) = books(); + let c = l.add_customer("Acme").unwrap(); + assert!(acct(&l, "1200").is_none() && acct(&l, "4000").is_none()); + let next = l.next_invoice_id(); + let id = l + .create_invoice( + c, + d(2026, 3, 1), + d(2026, 3, 31), + vec![line("design", "1000.00"), line("hosting", "234.56")], + ) + .unwrap(); + assert_eq!(id, next); + + let ar = l.chart().find_by_code("1200").expect("1200 created"); + assert_eq!(ar.account_type, AccountType::Asset); + assert!(!ar.placeholder); + let rev = l.chart().find_by_code("4000").expect("4000 created"); + assert_eq!(rev.account_type, AccountType::Revenue); + assert!(!rev.placeholder); + + let inv = l.get_invoice(id).unwrap().clone(); + assert_eq!(inv.customer_id, c); + assert_eq!(inv.total(), dec("1234.56")); + assert_eq!(inv.amount_paid, Decimal::ZERO); + assert_eq!(inv.status, DocumentStatus::Open); + assert_eq!(inv.balance(), dec("1234.56")); + + let e = l.get_entry(inv.entry_id).unwrap(); + assert_eq!(e.status(), EntryStatus::Posted); + assert_eq!(e.date(), d(2026, 3, 1)); + let lines = entry_lines(&l, inv.entry_id); + assert!(lines.iter().all(|(a, _, _)| *a == ar.id || *a == rev.id)); + assert_eq!(net_for(&lines, ar.id), dec("1234.56")); + assert_eq!(net_for(&lines, rev.id), dec("-1234.56")); + + assert_eq!(bal(&l, "1200"), dec("1234.56")); + assert_eq!(bal(&l, "4000"), dec("1234.56")); +} + +/// Oracle: control accounts doc: an existing postable account of the right +/// type is the one used ("find (or create)"); nothing new is added. +#[test] +fn create_invoice_uses_existing_right_type_controls() { + let (mut l, _) = books(); + let ar = l + .add_account("1200", "Trade Debtors", AccountType::Asset, None, false) + .unwrap(); + let rev = l + .add_account("4000", "Sales", AccountType::Revenue, None, false) + .unwrap(); + let n = l.chart().len(); + let c = l.add_customer("Acme").unwrap(); + let id = l + .create_invoice(c, d(2026, 3, 1), d(2026, 3, 1), vec![line("x", "10")]) + .unwrap(); + assert_eq!(l.chart().len(), n); + let lines = entry_lines(&l, l.get_invoice(id).unwrap().entry_id); + assert_eq!(net_for(&lines, ar), dec("10")); + assert_eq!(net_for(&lines, rev), dec("-10")); +} + +/// Oracle: create_invoice SCENARIO 1 (unknown customer) + INVARIANT 3. +#[test] +fn create_invoice_refuses_unknown_customer_books_unchanged() { + let (mut l, _) = books(); + let before = fingerprint(&l); + let r = l.create_invoice(999, d(2026, 3, 1), d(2026, 3, 2), vec![line("x", "1")]); + assert_eq!(r, Err(LedgerError::UnknownCustomer(999))); + assert_eq!(fingerprint(&l), before); +} + +/// Oracle: create_invoice SCENARIO 1 (line amount not positive; no lines, +/// per validate_lines) + INVARIANT 3. +#[test] +fn create_invoice_refuses_bad_lines_books_unchanged() { + let (mut l, _) = books(); + let c = l.add_customer("Acme").unwrap(); + let before = fingerprint(&l); + let r = l.create_invoice( + c, + d(2026, 3, 1), + d(2026, 3, 2), + vec![line("x", "5"), line("y", "0")], + ); + assert_eq!( + r, + Err(LedgerError::InvalidDocumentLineAmount(Decimal::ZERO)) + ); + let r = l.create_invoice(c, d(2026, 3, 1), d(2026, 3, 2), vec![line("x", "-5")]); + assert_eq!(r, Err(LedgerError::InvalidDocumentLineAmount(dec("-5")))); + let r = l.create_invoice(c, d(2026, 3, 1), d(2026, 3, 2), vec![]); + assert_eq!(r, Err(LedgerError::EmptyDocumentLines)); + assert_eq!(fingerprint(&l), before); +} + +/// Oracle: create_invoice SCENARIO 1 (due date before invoice date) + +/// INVARIANT 3. +#[test] +fn create_invoice_refuses_due_before_date_books_unchanged() { + let (mut l, _) = books(); + let c = l.add_customer("Acme").unwrap(); + let before = fingerprint(&l); + let r = l.create_invoice(c, d(2026, 3, 2), d(2026, 3, 1), vec![line("x", "1")]); + assert!(matches!(r, Err(LedgerError::DueBeforeDate(_))), "{r:?}"); + assert_eq!(fingerprint(&l), before); +} + +/// Closes January 2026 on `l`. +/// +/// ASSUMED: closing a period with nothing in it still marks it closed +/// (close_period returns Ok(None)); the precondition is asserted. +fn close_january(l: &mut Ledger) { + let p = l + .create_period("2026-01", d(2026, 1, 1), d(2026, 1, 31)) + .unwrap(); + l.close_period(p).unwrap(); + assert!(l.closed_period_containing(d(2026, 1, 15)).is_some()); +} + +/// Oracle: create_invoice SCENARIO 1 (date in a closed period) + INVARIANT 3. +#[test] +fn create_invoice_refuses_closed_period_books_unchanged() { + let (mut l, _) = books(); + let c = l.add_customer("Acme").unwrap(); + close_january(&mut l); + let before = fingerprint(&l); + let r = l.create_invoice(c, d(2026, 1, 15), d(2026, 2, 15), vec![line("x", "1")]); + assert!(matches!(r, Err(LedgerError::ClosedPeriod(..))), "{r:?}"); + assert_eq!(fingerprint(&l), before); +} + +/// Oracle: create_bill SCENARIO 1 (date in a closed period) + INVARIANT 2. +#[test] +fn create_bill_refuses_closed_period_books_unchanged() { + let (mut l, _) = books(); + let v = l.add_vendor("Hosting").unwrap(); + close_january(&mut l); + let before = fingerprint(&l); + let r = l.create_bill(v, d(2026, 1, 15), d(2026, 2, 15), vec![line("x", "1")]); + assert!(matches!(r, Err(LedgerError::ClosedPeriod(..))), "{r:?}"); + assert_eq!(fingerprint(&l), before); +} + +// ------------------------------------------------------ control accounts + +fn expect_mismatch( + r: Result, + code: &str, + name: &str, + expected: AccountType, + found: AccountType, + placeholder: bool, +) { + match r { + Err(LedgerError::ControlAccountMismatch { + code: c, + name: n, + expected: e, + found: f, + placeholder: p, + .. + }) => { + assert_eq!(c, code); + assert_eq!(n, name); + assert_eq!(e, expected); + assert_eq!(f, found); + assert_eq!(p, placeholder); + } + other => panic!("expected ControlAccountMismatch for {code}, got {other:?}"), + } +} + +/// Oracle: ControlAccountMismatch doc: 1200 of the wrong type is refused; +/// create_invoice INVARIANT 3: books unchanged (4000 not created either). +#[test] +fn invoice_refuses_wrong_type_receivables_books_unchanged() { + let (mut l, _) = books(); + l.add_account("1200", "Loan", AccountType::Liability, None, false) + .unwrap(); + let c = l.add_customer("Acme").unwrap(); + let before = fingerprint(&l); + let r = l.create_invoice(c, d(2026, 3, 1), d(2026, 3, 1), vec![line("x", "1")]); + expect_mismatch( + r, + "1200", + "Loan", + AccountType::Asset, + AccountType::Liability, + false, + ); + assert!(acct(&l, "4000").is_none()); + assert_eq!(fingerprint(&l), before); +} + +/// Oracle: ControlAccountMismatch doc: a placeholder 1200 (right type) is +/// refused, with placeholder = true; books unchanged. +#[test] +fn invoice_refuses_placeholder_receivables_books_unchanged() { + let (mut l, _) = books(); + l.add_account("1200", "Receivables", AccountType::Asset, None, true) + .unwrap(); + let c = l.add_customer("Acme").unwrap(); + let before = fingerprint(&l); + let r = l.create_invoice(c, d(2026, 3, 1), d(2026, 3, 1), vec![line("x", "1")]); + expect_mismatch( + r, + "1200", + "Receivables", + AccountType::Asset, + AccountType::Asset, + true, + ); + assert_eq!(fingerprint(&l), before); +} + +/// Oracle: ControlAccountMismatch doc: 4000 must be a postable Revenue +/// account; books unchanged (1200 not created either). +#[test] +fn invoice_refuses_wrong_type_revenue_books_unchanged() { + let (mut l, _) = books(); + l.add_account("4000", "Rent", AccountType::Expense, None, false) + .unwrap(); + let c = l.add_customer("Acme").unwrap(); + let before = fingerprint(&l); + let r = l.create_invoice(c, d(2026, 3, 1), d(2026, 3, 1), vec![line("x", "1")]); + expect_mismatch( + r, + "4000", + "Rent", + AccountType::Revenue, + AccountType::Expense, + false, + ); + assert!(acct(&l, "1200").is_none()); + assert_eq!(fingerprint(&l), before); +} + +/// Oracle: ControlAccountMismatch doc: 2000 must be a postable Liability; +/// create_bill INVARIANT 2: books unchanged (6999 not created). +#[test] +fn bill_refuses_wrong_type_payables_books_unchanged() { + let (mut l, _) = books(); + l.add_account("2000", "Savings", AccountType::Asset, None, false) + .unwrap(); + let v = l.add_vendor("Hosting").unwrap(); + let before = fingerprint(&l); + let r = l.create_bill(v, d(2026, 3, 1), d(2026, 3, 1), vec![line("x", "1")]); + expect_mismatch( + r, + "2000", + "Savings", + AccountType::Liability, + AccountType::Asset, + false, + ); + assert!(acct(&l, "6999").is_none()); + assert_eq!(fingerprint(&l), before); +} + +/// Oracle: ControlAccountMismatch doc: a placeholder 6999 is refused; books +/// unchanged (2000 not created). +#[test] +fn bill_refuses_placeholder_general_expenses_books_unchanged() { + let (mut l, _) = books(); + l.add_account("6999", "Other", AccountType::Expense, None, true) + .unwrap(); + let v = l.add_vendor("Hosting").unwrap(); + let before = fingerprint(&l); + let r = l.create_bill(v, d(2026, 3, 1), d(2026, 3, 1), vec![line("x", "1")]); + expect_mismatch( + r, + "6999", + "Other", + AccountType::Expense, + AccountType::Expense, + true, + ); + assert!(acct(&l, "2000").is_none()); + assert_eq!(fingerprint(&l), before); +} + +/// Oracle: ControlAccountMismatch doc: 6999 of the wrong type is refused. +#[test] +fn bill_refuses_wrong_type_general_expenses_books_unchanged() { + let (mut l, _) = books(); + l.add_account("6999", "Misc income", AccountType::Revenue, None, false) + .unwrap(); + let v = l.add_vendor("Hosting").unwrap(); + let before = fingerprint(&l); + let r = l.create_bill(v, d(2026, 3, 1), d(2026, 3, 1), vec![line("x", "1")]); + expect_mismatch( + r, + "6999", + "Misc income", + AccountType::Expense, + AccountType::Revenue, + false, + ); + assert_eq!(fingerprint(&l), before); +} + +// --------------------------------------------------------------- payments + +/// Oracle: receive_payment INVARIANTS 1-2 (Dr cash / Cr 1200 for the +/// amount; paid rises; Partial when partly, Paid exactly when fully). +#[test] +fn receive_payment_partial_then_full() { + let (mut l, cash) = books(); + let c = l.add_customer("Acme").unwrap(); + let id = l + .create_invoice(c, d(2026, 3, 1), d(2026, 3, 31), vec![line("x", "300.00")]) + .unwrap(); + let ar = acct(&l, "1200").unwrap(); + + let e1 = l + .receive_payment(id, d(2026, 3, 10), dec("100.00"), cash) + .unwrap(); + let lines = entry_lines(&l, e1); + assert_eq!(l.get_entry(e1).unwrap().status(), EntryStatus::Posted); + assert_eq!(l.get_entry(e1).unwrap().date(), d(2026, 3, 10)); + assert!(lines.iter().all(|(a, _, _)| *a == ar || *a == cash)); + assert_eq!(net_for(&lines, cash), dec("100.00")); + assert_eq!(net_for(&lines, ar), dec("-100.00")); + let inv = l.get_invoice(id).unwrap(); + assert_eq!(inv.amount_paid, dec("100.00")); + assert_eq!(inv.status, DocumentStatus::Partial); + assert_eq!(inv.balance(), dec("200.00")); + assert_eq!(bal(&l, "1200"), dec("200.00")); + assert_eq!(bal(&l, "1000"), dec("100.00")); + + l.receive_payment(id, d(2026, 3, 20), dec("200.00"), cash) + .unwrap(); + let inv = l.get_invoice(id).unwrap(); + assert_eq!(inv.amount_paid, dec("300.00")); + assert_eq!(inv.status, DocumentStatus::Paid); + assert_eq!(inv.balance(), Decimal::ZERO); + assert_eq!(bal(&l, "1200"), Decimal::ZERO); + assert_eq!(bal(&l, "1000"), dec("300.00")); +} + +/// Oracle: receive_payment SCENARIO 1 (amount not positive) — refusal leaves +/// the books unchanged (B-8 general refusal rule, as for post_entry). +#[test] +fn receive_payment_refuses_non_positive_amount() { + let (mut l, cash) = books(); + let c = l.add_customer("Acme").unwrap(); + let id = l + .create_invoice(c, d(2026, 3, 1), d(2026, 3, 31), vec![line("x", "50")]) + .unwrap(); + let before = fingerprint(&l); + assert_eq!( + l.receive_payment(id, d(2026, 3, 2), Decimal::ZERO, cash), + Err(LedgerError::InvalidPaymentAmount(Decimal::ZERO)) + ); + assert_eq!( + l.receive_payment(id, d(2026, 3, 2), dec("-1"), cash), + Err(LedgerError::InvalidPaymentAmount(dec("-1"))) + ); + assert_eq!(fingerprint(&l), before); +} + +/// Oracle: receive_payment SCENARIO 2 (exceeds what's owed: Overpayment; +/// never let paid exceed the total), including after full payment. +#[test] +fn receive_payment_refuses_overpayment() { + let (mut l, cash) = books(); + let c = l.add_customer("Acme").unwrap(); + let id = l + .create_invoice(c, d(2026, 3, 1), d(2026, 3, 31), vec![line("x", "50.00")]) + .unwrap(); + l.receive_payment(id, d(2026, 3, 2), dec("20.00"), cash) + .unwrap(); + let before = fingerprint(&l); + assert_eq!( + l.receive_payment(id, d(2026, 3, 3), dec("30.01"), cash), + Err(LedgerError::Overpayment(dec("30.01"), dec("30.00"))) + ); + assert_eq!(fingerprint(&l), before); + l.receive_payment(id, d(2026, 3, 3), dec("30.00"), cash) + .unwrap(); + let before = fingerprint(&l); + assert_eq!( + l.receive_payment(id, d(2026, 3, 4), dec("0.01"), cash), + Err(LedgerError::Overpayment(dec("0.01"), Decimal::ZERO)) + ); + assert_eq!(fingerprint(&l), before); + assert_eq!(l.get_invoice(id).unwrap().amount_paid, dec("50.00")); +} + +/// Oracle: UnknownInvoice / UnknownBill error docs ("the referenced ... +/// does not exist"); a refusal leaves the books unchanged. +#[test] +fn payments_refuse_unknown_documents() { + let (mut l, cash) = books(); + let before = fingerprint(&l); + assert_eq!( + l.receive_payment(77, d(2026, 3, 2), dec("1"), cash), + Err(LedgerError::UnknownInvoice(77)) + ); + assert_eq!( + l.pay_bill(78, d(2026, 3, 2), dec("1"), cash), + Err(LedgerError::UnknownBill(78)) + ); + assert_eq!(fingerprint(&l), before); +} + +// ------------------------------------------------------------------ bills + +/// Oracle: create_bill INVARIANT 1 (Dr 6999 / Cr 2000 for the sum of the +/// lines, dated the bill date, tagged with the vendor); controls created. +#[test] +fn create_bill_posts_dr_general_expenses_cr_payables_tagged() { + let (mut l, _) = books(); + let v = l.add_vendor("Hosting").unwrap(); + let id = l + .create_bill( + v, + d(2026, 4, 2), + d(2026, 5, 2), + vec![line("vps", "40.00"), line("backup", "9.50")], + ) + .unwrap(); + let ap = l.chart().find_by_code("2000").expect("2000 created"); + assert_eq!(ap.account_type, AccountType::Liability); + assert!(!ap.placeholder); + let ex = l.chart().find_by_code("6999").expect("6999 created"); + assert_eq!(ex.account_type, AccountType::Expense); + assert!(!ex.placeholder); + + let bill = l.get_bill(id).unwrap().clone(); + assert_eq!(bill.vendor_id, v); + assert_eq!(bill.total(), dec("49.50")); + assert_eq!(bill.amount_paid, Decimal::ZERO); + assert_eq!(bill.status, DocumentStatus::Open); + + let e = l.get_entry(bill.entry_id).unwrap(); + assert_eq!(e.status(), EntryStatus::Posted); + assert_eq!(e.date(), d(2026, 4, 2)); + let lines = entry_lines(&l, bill.entry_id); + assert!(lines.iter().all(|(a, _, _)| *a == ap.id || *a == ex.id)); + assert_eq!(net_for(&lines, ex.id), dec("49.50")); + assert_eq!(net_for(&lines, ap.id), dec("-49.50")); + assert_eq!(l.entry_vendor(bill.entry_id), Some(v)); + assert_eq!(bal(&l, "2000"), dec("49.50")); + assert_eq!(bal(&l, "6999"), dec("49.50")); +} + +/// Oracle: create_bill SCENARIO 1 (unknown vendor, bad line, due before +/// date) + INVARIANT 2. +#[test] +fn create_bill_refusals_leave_books_unchanged() { + let (mut l, _) = books(); + let v = l.add_vendor("Hosting").unwrap(); + let before = fingerprint(&l); + assert_eq!( + l.create_bill(555, d(2026, 4, 1), d(2026, 4, 1), vec![line("x", "1")]), + Err(LedgerError::UnknownVendor(555)) + ); + assert_eq!( + l.create_bill(v, d(2026, 4, 1), d(2026, 4, 1), vec![line("x", "-2")]), + Err(LedgerError::InvalidDocumentLineAmount(dec("-2"))) + ); + assert_eq!( + l.create_bill(v, d(2026, 4, 1), d(2026, 4, 1), vec![]), + Err(LedgerError::EmptyDocumentLines) + ); + let r = l.create_bill(v, d(2026, 4, 2), d(2026, 4, 1), vec![line("x", "1")]); + assert!(matches!(r, Err(LedgerError::DueBeforeDate(_))), "{r:?}"); + assert_eq!(fingerprint(&l), before); +} + +/// Oracle: pay_bill INVARIANTS 1-2 (Dr 2000 / Cr cash for the amount, +/// tagged with the vendor; paid rises) and DocumentStatus docs. +#[test] +fn pay_bill_partial_then_full() { + let (mut l, cash) = books(); + let v = l.add_vendor("Hosting").unwrap(); + let id = l + .create_bill(v, d(2026, 4, 1), d(2026, 4, 30), vec![line("x", "80.00")]) + .unwrap(); + let ap = acct(&l, "2000").unwrap(); + let e = l.pay_bill(id, d(2026, 4, 5), dec("30.00"), cash).unwrap(); + let lines = entry_lines(&l, e); + assert_eq!(l.get_entry(e).unwrap().status(), EntryStatus::Posted); + assert_eq!(l.get_entry(e).unwrap().date(), d(2026, 4, 5)); + assert!(lines.iter().all(|(a, _, _)| *a == ap || *a == cash)); + assert_eq!(net_for(&lines, ap), dec("30.00")); + assert_eq!(net_for(&lines, cash), dec("-30.00")); + assert_eq!(l.entry_vendor(e), Some(v)); + let b = l.get_bill(id).unwrap(); + assert_eq!(b.amount_paid, dec("30.00")); + assert_eq!(b.status, DocumentStatus::Partial); + assert_eq!(bal(&l, "2000"), dec("50.00")); + + l.pay_bill(id, d(2026, 4, 6), dec("50.00"), cash).unwrap(); + let b = l.get_bill(id).unwrap(); + assert_eq!(b.status, DocumentStatus::Paid); + assert_eq!(b.balance(), Decimal::ZERO); + assert_eq!(bal(&l, "2000"), Decimal::ZERO); + assert_eq!(bal(&l, "1000"), dec("-80.00")); +} + +/// Oracle: pay_bill SCENARIO 1 (non-positive or exceeding what's owed: +/// refuse); refusal leaves the books unchanged. +#[test] +fn pay_bill_refuses_bad_amounts_books_unchanged() { + let (mut l, cash) = books(); + let v = l.add_vendor("Hosting").unwrap(); + let id = l + .create_bill(v, d(2026, 4, 1), d(2026, 4, 30), vec![line("x", "80.00")]) + .unwrap(); + let before = fingerprint(&l); + assert_eq!( + l.pay_bill(id, d(2026, 4, 2), Decimal::ZERO, cash), + Err(LedgerError::InvalidPaymentAmount(Decimal::ZERO)) + ); + assert_eq!( + l.pay_bill(id, d(2026, 4, 2), dec("-3"), cash), + Err(LedgerError::InvalidPaymentAmount(dec("-3"))) + ); + assert_eq!( + l.pay_bill(id, d(2026, 4, 2), dec("80.01"), cash), + Err(LedgerError::Overpayment(dec("80.01"), dec("80.00"))) + ); + assert_eq!(fingerprint(&l), before); +} + +// ------------------------------------------------------------------ aging + +/// Oracle: ar_aging doc (one row per customer with an outstanding balance, +/// bucketed by days past due) and B-18 buckets; fully paid customers get no +/// row. +/// +/// ASSUMED: rows carry the customer's name and their order is unspecified +/// (the test sorts by name). +#[test] +fn ar_aging_example() { + let (mut l, cash) = books(); + let a = l.add_customer("Acme").unwrap(); + let b = l.add_customer("Beta").unwrap(); + let z = l.add_customer("Zeta").unwrap(); + let as_of = d(2026, 10, 13); + // Acme: due today (Current) 100, 45 days late 200 with 50 paid (31-60). + l.create_invoice(a, d(2026, 9, 13), as_of, vec![line("x", "100")]) + .unwrap(); + let i2 = l + .create_invoice( + a, + d(2026, 8, 1), + as_of - Duration::days(45), + vec![line("y", "200")], + ) + .unwrap(); + l.receive_payment(i2, d(2026, 9, 1), dec("50"), cash) + .unwrap(); + // Beta: 91 days late (90+) 10. + l.create_invoice( + b, + d(2026, 6, 1), + as_of - Duration::days(91), + vec![line("z", "10")], + ) + .unwrap(); + // Zeta: fully paid, no row. + let i4 = l + .create_invoice(z, d(2026, 9, 1), d(2026, 9, 2), vec![line("w", "7")]) + .unwrap(); + l.receive_payment(i4, d(2026, 9, 2), dec("7"), cash) + .unwrap(); + + let mut rows = l.ar_aging(as_of); + rows.sort_by(|x, y| x.name.cmp(&y.name)); + assert_eq!(rows.len(), 2); + assert_eq!(rows[0].name, "Acme"); + assert_eq!( + buckets(&rows[0]), + [dec("100"), dec("0"), dec("150"), dec("0"), dec("0")] + ); + assert_eq!(rows[1].name, "Beta"); + assert_eq!( + buckets(&rows[1]), + [dec("0"), dec("0"), dec("0"), dec("0"), dec("10")] + ); +} + +/// Oracle: ap_aging doc (one row per vendor with an outstanding balance) and +/// B-18 (1-30 and 61-90 buckets). +#[test] +fn ap_aging_example() { + let (mut l, cash) = books(); + let v = l.add_vendor("Hosting").unwrap(); + let w = l.add_vendor("Paid Up").unwrap(); + let as_of = d(2026, 10, 13); + l.create_bill( + v, + d(2026, 9, 1), + as_of - Duration::days(30), + vec![line("a", "12.34")], + ) + .unwrap(); + l.create_bill( + v, + d(2026, 7, 1), + as_of - Duration::days(61), + vec![line("b", "1.00")], + ) + .unwrap(); + let pb = l + .create_bill(w, d(2026, 9, 1), d(2026, 9, 30), vec![line("c", "5")]) + .unwrap(); + l.pay_bill(pb, d(2026, 9, 5), dec("5"), cash).unwrap(); + let rows = l.ap_aging(as_of); + assert_eq!(rows.len(), 1); + assert_eq!(rows[0].name, "Hosting"); + assert_eq!( + buckets(&rows[0]), + [dec("0"), dec("12.34"), dec("0"), dec("1.00"), dec("0")] + ); +} + +// ------------------------------------------------- sequence properties + +#[derive(Clone, Debug)] +struct Doc { + who: usize, + line_cents: Vec, + due_offset: i64, +} + +fn doc_strategy() -> impl Strategy { + ( + 0usize..3, + proptest::collection::vec(1i64..500_000, 1..4), + 0i64..60, + ) + .prop_map(|(who, line_cents, due_offset)| Doc { + who, + line_cents, + due_offset, + }) +} + +/// Payment request: (document index, percent of the remaining balance, +/// 1..=130 — above 100 is an overpayment attempt). +fn pay_strategy() -> impl Strategy> { + proptest::collection::vec((0usize..8, 1u32..=130), 0..16) +} + +fn doc_lines(doc: &Doc) -> Vec { + doc.line_cents + .iter() + .map(|c| DocumentLine { + description: "item".into(), + amount: cents(*c), + }) + .collect() +} + +proptest! { + #![proptest_config(ProptestConfig::with_cases(64))] + + /// Oracles: receive_payment INVARIANT 2 and SCENARIOS 1-2 (paid rises by + /// the amount, never exceeds total, Overpayment refused with books + /// unchanged); DocumentStatus docs (status law); Invoice::balance doc; + /// conservation (1200 balance = sum of open invoice balances, 4000 = + /// sum of invoice totals, cash = sum received, since every movement on + /// them comes through invoices and payments); ar_aging doc + B-18 + /// (each open balance in exactly one bucket; buckets sum to open + /// balances; due today is Current). + #[test] + fn prop_invoices_and_payments_conserve( + docs in proptest::collection::vec(doc_strategy(), 1..6), + pays in pay_strategy(), + ) { + let (mut l, cash) = books(); + let names = ["Alpha", "Bravo", "Charlie"]; + let custs: Vec<_> = names.iter().map(|n| l.add_customer(n).unwrap()).collect(); + let base = d(2026, 3, 1); + let mut ids = Vec::new(); + let mut expect_paid: Vec = Vec::new(); + let mut totals: Vec = Vec::new(); + for doc in &docs { + let id = l + .create_invoice(custs[doc.who], base, base + Duration::days(doc.due_offset), doc_lines(doc)) + .unwrap(); + ids.push(id); + expect_paid.push(Decimal::ZERO); + totals.push(doc.line_cents.iter().map(|c| cents(*c)).sum()); + } + let mut received = Decimal::ZERO; + for (k, pct) in &pays { + let i = k % ids.len(); + let remaining = totals[i] - expect_paid[i]; + let amount = if *pct > 100 { + remaining + cents(i64::from(*pct - 100)) + } else { + (remaining * Decimal::from(*pct) / Decimal::from(100)).round_dp(2) + }; + let before = fingerprint(&l); + let r = l.receive_payment(ids[i], base + Duration::days(5), amount, cash); + if amount <= Decimal::ZERO { + prop_assert_eq!(r, Err(LedgerError::InvalidPaymentAmount(amount))); + prop_assert_eq!(fingerprint(&l), before); + } else if amount > remaining { + prop_assert_eq!(r, Err(LedgerError::Overpayment(amount, remaining))); + prop_assert_eq!(fingerprint(&l), before); + } else { + prop_assert!(r.is_ok(), "{:?}", r); + expect_paid[i] += amount; + received += amount; + } + } + + let mut open_sum = Decimal::ZERO; + for (i, id) in ids.iter().enumerate() { + let inv = l.get_invoice(*id).unwrap(); + prop_assert_eq!(inv.total(), totals[i]); + prop_assert_eq!(inv.amount_paid, expect_paid[i]); + prop_assert_eq!(inv.balance(), totals[i] - expect_paid[i]); + prop_assert!(inv.balance() >= Decimal::ZERO); + prop_assert_eq!(inv.status, status_law(expect_paid[i], totals[i])); + open_sum += inv.balance(); + } + prop_assert_eq!(bal(&l, "1200"), open_sum); + prop_assert_eq!(bal(&l, "4000"), totals.iter().copied().sum::()); + prop_assert_eq!(l.account_balance(cash).unwrap(), received); + + // Aging as of a date spanning every bucket edge in turn. + for as_of_off in [0i64, 1, 30, 31, 60, 61, 90, 91, 200] { + let as_of = base + Duration::days(as_of_off); + let rows = l.ar_aging(as_of); + let mut want: std::collections::BTreeMap = Default::default(); + for (i, doc) in docs.iter().enumerate() { + let open = totals[i] - expect_paid[i]; + if open.is_zero() { + continue; + } + let due = base + Duration::days(doc.due_offset); + want.entry(names[doc.who].to_string()).or_insert([Decimal::ZERO; 5]) + [expected_bucket(due, as_of)] += open; + } + let got: std::collections::BTreeMap = + rows.iter().map(|r| (r.name.clone(), buckets(r))).collect(); + prop_assert_eq!(rows.len(), got.len(), "one row per customer"); + prop_assert_eq!(&got, &want); + let row_sum: Decimal = rows.iter().map(|r| r.total()).sum(); + prop_assert_eq!(row_sum, open_sum); + } + } + + /// Oracles: pay_bill INVARIANTS 1-2 and SCENARIO 1; DocumentStatus docs; + /// Bill::balance doc; conservation (2000 = sum of open bill balances, + /// 6999 = sum of bill totals, cash = -sum paid); every payment entry + /// tagged with its vendor; ap_aging doc + B-18. + #[test] + fn prop_bills_and_payments_conserve( + docs in proptest::collection::vec(doc_strategy(), 1..6), + pays in pay_strategy(), + ) { + let (mut l, cash) = books(); + let names = ["Vendor A", "Vendor B", "Vendor C"]; + let vends: Vec<_> = names.iter().map(|n| l.add_vendor(n).unwrap()).collect(); + let base = d(2026, 5, 1); + let mut ids = Vec::new(); + let mut expect_paid: Vec = Vec::new(); + let mut totals: Vec = Vec::new(); + for doc in &docs { + let id = l + .create_bill(vends[doc.who], base, base + Duration::days(doc.due_offset), doc_lines(doc)) + .unwrap(); + prop_assert_eq!(l.entry_vendor(l.get_bill(id).unwrap().entry_id), Some(vends[doc.who])); + ids.push(id); + expect_paid.push(Decimal::ZERO); + totals.push(doc.line_cents.iter().map(|c| cents(*c)).sum()); + } + let mut paid_out = Decimal::ZERO; + for (k, pct) in &pays { + let i = k % ids.len(); + let remaining = totals[i] - expect_paid[i]; + let amount = if *pct > 100 { + remaining + cents(i64::from(*pct - 100)) + } else { + (remaining * Decimal::from(*pct) / Decimal::from(100)).round_dp(2) + }; + let before = fingerprint(&l); + let r = l.pay_bill(ids[i], base + Duration::days(3), amount, cash); + if amount <= Decimal::ZERO { + prop_assert_eq!(r, Err(LedgerError::InvalidPaymentAmount(amount))); + prop_assert_eq!(fingerprint(&l), before); + } else if amount > remaining { + prop_assert!(r.is_err(), "overpayment accepted: {:?}", r); + prop_assert_eq!(fingerprint(&l), before); + } else { + let e = r.unwrap(); + prop_assert_eq!(l.entry_vendor(e), Some(vends[docs[i].who])); + expect_paid[i] += amount; + paid_out += amount; + } + } + + let mut open_sum = Decimal::ZERO; + for (i, id) in ids.iter().enumerate() { + let b = l.get_bill(*id).unwrap(); + prop_assert_eq!(b.total(), totals[i]); + prop_assert_eq!(b.amount_paid, expect_paid[i]); + prop_assert_eq!(b.balance(), totals[i] - expect_paid[i]); + prop_assert_eq!(b.status, status_law(expect_paid[i], totals[i])); + open_sum += b.balance(); + } + prop_assert_eq!(bal(&l, "2000"), open_sum); + prop_assert_eq!(bal(&l, "6999"), totals.iter().copied().sum::()); + prop_assert_eq!(l.account_balance(cash).unwrap(), -paid_out); + + for as_of_off in [0i64, 15, 45, 75, 120] { + let as_of = base + Duration::days(as_of_off); + let rows = l.ap_aging(as_of); + let mut want: std::collections::BTreeMap = Default::default(); + for (i, doc) in docs.iter().enumerate() { + let open = totals[i] - expect_paid[i]; + if open.is_zero() { + continue; + } + let due = base + Duration::days(doc.due_offset); + want.entry(names[doc.who].to_string()).or_insert([Decimal::ZERO; 5]) + [expected_bucket(due, as_of)] += open; + } + let got: std::collections::BTreeMap = + rows.iter().map(|r| (r.name.clone(), buckets(r))).collect(); + prop_assert_eq!(rows.len(), got.len(), "one row per vendor"); + prop_assert_eq!(&got, &want); + } + } +} diff --git a/crates/ledger-core/tests/spec_assets_payroll.rs b/crates/ledger-core/tests/spec_assets_payroll.rs new file mode 100644 index 0000000..20ff7db --- /dev/null +++ b/crates/ledger-core/tests/spec_assets_payroll.rs @@ -0,0 +1,1300 @@ +//! Spec-derived tests for fixed assets (depreciation, disposal) and payroll. +//! +//! Every expected value here is a literal worked by hand from the written +//! specification (the interface doc comments, `docs/spec/rules.md` B-15 and +//! B-22, and IRS Pub. 15 in `docs/spec/sources/irs-payroll.md`), or a law the +//! specification states. Nothing is read back from the code under test and +//! then asserted. + +use std::str::FromStr; + +use chrono::{Datelike, Months, NaiveDate}; +use ledger_core::payroll::{ + PayrollAccounts, payroll_entry_plan, payroll_remit_plan, validate_payroll_lines, +}; +use ledger_core::{ + AccountId, AccountType, Asset, AssetId, AssetStatus, Currency, DepreciationMethod, EntryId, + Ledger, LedgerError, Money, NewAsset, PayrollLine, Side, accumulated_depreciation, book_value, + depreciation_schedule, disposal_amounts, +}; +use proptest::prelude::*; +use rust_decimal::Decimal; + +// ---------------------------------------------------------------- helpers + +fn d(s: &str) -> Decimal { + Decimal::from_str(s).expect("decimal literal") +} + +fn usd(s: &str) -> Money { + Money::new(d(s), Currency::USD) +} + +fn date(y: i32, m: u32, day: u32) -> NaiveDate { + NaiveDate::from_ymd_opt(y, m, day).expect("valid date") +} + +fn month_end(day: NaiveDate) -> NaiveDate { + let first = NaiveDate::from_ymd_opt(day.year(), day.month(), 1).expect("first of month"); + first + .checked_add_months(Months::new(1)) + .expect("next month") + .pred_opt() + .expect("previous day") +} + +/// A free-standing asset for the pure functions (account ids are opaque +/// there; nothing looks them up). +fn asset(cost: &str, salvage: &str, life: u32, purchase: NaiveDate) -> Asset { + Asset { + id: 1, + description: "Test asset".to_string(), + purchase_date: purchase, + cost: usd(cost), + salvage_value: usd(salvage), + useful_life_months: life, + method: DepreciationMethod::StraightLine, + cost_account: 10, + accum_depr_account: 11, + expense_account: 12, + status: AssetStatus::Active, + } +} + +/// The accounts a fixture ledger holds. +struct Accts { + cash: AccountId, + capital: AccountId, + equipment: AccountId, + accum: AccountId, + depr_expense: AccountId, + liability: AccountId, +} + +/// A USD ledger with a chart for assets and payroll, funded with $10,000 +/// cash on 2026-01-01 (Dr 1000 / Cr 3000). +fn fixture() -> (Ledger, Accts) { + let mut l = Ledger::new(Currency::USD); + let cash = l + .add_account("1000", "Cash", AccountType::Asset, None, false) + .unwrap(); + let equipment = l + .add_account("1500", "Equipment", AccountType::Asset, None, false) + .unwrap(); + let accum = l + .add_account( + "1510", + "Accumulated Depreciation", + AccountType::Asset, + None, + false, + ) + .unwrap(); + let liability = l + .add_account("2500", "Loan", AccountType::Liability, None, false) + .unwrap(); + let capital = l + .add_account("3000", "Owner Capital", AccountType::Equity, None, false) + .unwrap(); + let depr_expense = l + .add_account( + "6200", + "Depreciation Expense", + AccountType::Expense, + None, + false, + ) + .unwrap(); + let e = l.try_create_entry(date(2026, 1, 1), "Opening").unwrap(); + l.add_line(e, cash, Side::Debit, usd("10000.00")).unwrap(); + l.add_line(e, capital, Side::Credit, usd("10000.00")) + .unwrap(); + l.post_entry(e).unwrap(); + ( + l, + Accts { + cash, + capital, + equipment, + accum, + depr_expense, + liability, + }, + ) +} + +fn new_asset(a: &Accts) -> NewAsset { + NewAsset { + description: "Laptop".to_string(), + purchase_date: date(2026, 1, 1), + cost: usd("12000.00"), + salvage_value: usd("0.00"), + useful_life_months: 12, + cost_account: a.equipment, + accum_depr_account: a.accum, + expense_account: a.depr_expense, + } +} + +/// Everything observable about the books, for "a refusal leaves the books +/// unchanged". +#[derive(Debug, PartialEq)] +struct Snapshot { + accounts: Vec<(String, AccountType, Decimal)>, + posted: Vec, + next_entry: EntryId, + next_asset: AssetId, + next_run: u64, + assets: Vec, + runs: usize, +} + +fn snapshot(l: &Ledger) -> Snapshot { + let mut accounts: Vec<(String, AccountType, Decimal)> = l + .chart() + .all() + .map(|a| { + ( + a.code.clone(), + a.account_type, + l.account_balance(a.id).unwrap(), + ) + }) + .collect(); + accounts.sort_by(|x, y| x.0.cmp(&y.0)); + Snapshot { + accounts, + posted: l.posted_entries().map(|e| e.id()).collect(), + next_entry: l.next_entry_id(), + next_asset: l.next_asset_id(), + next_run: l.next_payroll_run_id(), + assets: l.registered_assets().cloned().collect(), + runs: l.list_payroll_runs().len(), + } +} + +fn balance_of(l: &Ledger, code: &str) -> Decimal { + let acct = l.chart().find_by_code(code).expect("account exists"); + l.account_balance(acct.id).unwrap() +} + +#[allow(clippy::too_many_arguments)] +fn pline( + name: &str, + gross: &str, + fed: &str, + state: &str, + ss_e: &str, + med_e: &str, + ss_r: &str, + med_r: &str, + other: &str, +) -> PayrollLine { + PayrollLine { + employee_name: name.to_string(), + gross_pay: d(gross), + federal_withholding: d(fed), + state_withholding: d(state), + ss_employee: d(ss_e), + medicare_employee: d(med_e), + ss_employer: d(ss_r), + medicare_employer: d(med_r), + other_deductions: d(other), + } +} + +/// $5,000 gross; FICA at the Pub. 15 2026 rates: SS 6.2% = 310.00 each +/// share, Medicare 1.45% = 72.50 each share. +fn alice() -> PayrollLine { + pline( + "Alice", "5000.00", "600.00", "250.00", "310.00", "72.50", "310.00", "72.50", "200.00", + ) +} + +/// $2,000 gross, no state withholding, no other deductions. +fn bob() -> PayrollLine { + pline( + "Bob", "2000.00", "150.00", "0", "124.00", "29.00", "124.00", "29.00", "0", + ) +} + +fn entry_lines_by_code(l: &Ledger, e: EntryId) -> Vec<(String, Side, Decimal)> { + l.get_entry(e) + .expect("entry exists") + .lines() + .iter() + .map(|ln| { + ( + l.chart().get(ln.account).unwrap().code.clone(), + ln.side, + ln.amount.amount, + ) + }) + .collect() +} + +fn ent(code: &str, side: Side, amt: &str) -> (String, Side, Decimal) { + (code.to_string(), side, d(amt)) +} + +fn plan_totals(plan: &[(AccountId, Side, Money)]) -> (Decimal, Decimal) { + let mut dr = Decimal::ZERO; + let mut cr = Decimal::ZERO; + for (_, side, m) in plan { + match side { + Side::Debit => dr += m.amount, + Side::Credit => cr += m.amount, + } + } + (dr, cr) +} + +fn plan_sum(plan: &[(AccountId, Side, Money)], acct: AccountId, side: Side) -> Decimal { + plan.iter() + .filter(|(a, s, _)| *a == acct && *s == side) + .map(|(_, _, m)| m.amount) + .sum() +} + +const PA: PayrollAccounts = PayrollAccounts { + salary_expense: 1, + payroll_tax_expense: 2, + federal_payable: 3, + state_payable: 4, + ss_payable: 5, + medicare_payable: 6, + other_payable: 7, +}; +const PLAN_CASH: AccountId = 100; + +// ------------------------------------------------- depreciation schedule + +/// Oracle: B-15 straight line, (cost − salvage) / life = 12000 / 12 = 1000.00 +/// per full month; a purchase on the 1st makes every month full. +#[test] +fn full_months_each_take_one_twelfth_b15() { + let a = asset("12000.00", "0.00", 12, date(2026, 1, 1)); + let s = depreciation_schedule(&a, date(2026, 12, 31)); + assert_eq!(s.len(), 12); + for (i, line) in s.iter().enumerate() { + let n = i as i64 + 1; + assert_eq!(line.period, format!("2026-{:02}", n)); + assert_eq!(line.amount, d("1000.00")); + assert_eq!(line.accumulated, Decimal::from(1000 * n)); + assert_eq!(line.book_value, Decimal::from(12000 - 1000 * n)); + } +} + +/// Oracle: interface doc "through the month containing `through`": through +/// 2026-06-15 covers Jan..Jun. +#[test] +#[ignore = "SPEC-DISAGREEMENT: schedule through 2026-06-15 prorates June to 500.00 (acc 5500.00), doc says through the month containing it (acc 6000.00)"] +fn schedule_runs_through_month_containing_through_date() { + let a = asset("12000.00", "0.00", 12, date(2026, 1, 1)); + let s = depreciation_schedule(&a, date(2026, 6, 15)); + assert_eq!(s.len(), 6); + assert_eq!(s.last().unwrap().period, "2026-06"); + assert_eq!(s.last().unwrap().accumulated, d("6000.00")); +} + +/// Oracle: interface doc "clamped to the depreciation end": a schedule asked +/// through 2030 still totals cost − salvage = 12000.00, all in 12 non-zero +/// months. +#[test] +fn schedule_clamps_to_end_of_life() { + let a = asset("12000.00", "0.00", 12, date(2026, 1, 1)); + let s = depreciation_schedule(&a, date(2030, 12, 31)); + let total: Decimal = s.iter().map(|l| l.amount).sum(); + assert_eq!(total, d("12000.00")); + assert_eq!(s.iter().filter(|l| !l.amount.is_zero()).count(), 12); + assert_eq!(s.last().unwrap().book_value, d("0.00")); +} + +/// Oracle: B-15 partial months prorated by days; monthly 620/2 = 310.00. +/// Bought 2026-01-15: Jan holds 15..31 = 17/31 → 170.00; Feb full 310.00; +/// life ends 2026-03-15 so Mar holds 1..14 = 14/31 → 140.00. 17/31 + 14/31 +/// = one full month, and the total is exactly 620.00. +/// +/// ASSUMED: life ends on purchase_date + useful_life_months and the end day +/// itself is not held (matches the doc's "17/31 + 14/30" pairing for a +/// purchase on the 15th). +#[test] +fn partial_first_and_last_months_make_one_full_month_b15() { + let a = asset("620.00", "0.00", 2, date(2026, 1, 15)); + let s = depreciation_schedule(&a, date(2027, 12, 31)); + let got: Vec<(&str, Decimal, Decimal, Decimal)> = s + .iter() + .map(|l| (l.period.as_str(), l.amount, l.accumulated, l.book_value)) + .collect(); + assert_eq!( + got, + vec![ + ("2026-01", d("170.00"), d("170.00"), d("450.00")), + ("2026-02", d("310.00"), d("480.00"), d("140.00")), + ("2026-03", d("140.00"), d("620.00"), d("0.00")), + ] + ); +} + +/// Oracle: interface doc "17/31 + 14/30 ≠ 1, so the last month absorbs the +/// drift and the lifetime total is exactly (cost − salvage)". Depreciable +/// 1030 − 100 = 930.00 over 3 months = 310.00/month. Bought 2026-01-15: +/// Jan 17/31 → 170.00, Feb 310.00, Mar 310.00 (acc 790.00), Apr (30 days) +/// trues up to 930 − 790 = 140.00, not 14/30 × 310 = 144.67. +/// +/// ASSUMED: same end-of-life convention as the test above. +#[test] +fn last_month_trues_up_unequal_month_lengths_b15() { + let a = asset("1030.00", "100.00", 3, date(2026, 1, 15)); + let s = depreciation_schedule(&a, date(2027, 12, 31)); + let got: Vec<(&str, Decimal, Decimal, Decimal)> = s + .iter() + .map(|l| (l.period.as_str(), l.amount, l.accumulated, l.book_value)) + .collect(); + assert_eq!( + got, + vec![ + ("2026-01", d("170.00"), d("170.00"), d("860.00")), + ("2026-02", d("310.00"), d("480.00"), d("550.00")), + ("2026-03", d("310.00"), d("790.00"), d("240.00")), + ("2026-04", d("140.00"), d("930.00"), d("100.00")), + ] + ); +} + +/// Oracle: B-15 "salvage never depreciated": cost 5000, salvage 1000, life 4 +/// → 1000.00/month, book value stops at 1000.00 forever. +#[test] +fn salvage_value_is_never_depreciated_b15() { + let a = asset("5000.00", "1000.00", 4, date(2026, 3, 1)); + let s = depreciation_schedule(&a, date(2026, 6, 30)); + let amounts: Vec = s.iter().map(|l| l.amount).collect(); + assert_eq!(amounts, vec![d("1000.00"); 4]); + assert_eq!(s.last().unwrap().book_value, d("1000.00")); + assert_eq!(book_value(&a, date(2040, 1, 31)), d("1000.00")); + assert_eq!( + accumulated_depreciation(&a, date(2040, 1, 31)), + d("4000.00") + ); +} + +/// Oracle: B-21/interface "exact cents … never a penny off": 1000.00 over 3 +/// months cannot split evenly; months are 333.33 or 333.34 and sum to +/// exactly 1000.00. +#[test] +fn uneven_split_rounds_to_cents_and_sums_exactly() { + let a = asset("1000.00", "0.00", 3, date(2026, 1, 1)); + let s = depreciation_schedule(&a, date(2026, 3, 31)); + assert_eq!(s.len(), 3); + for l in &s { + assert!( + l.amount == d("333.33") || l.amount == d("333.34"), + "month {} = {}", + l.period, + l.amount + ); + } + let total: Decimal = s.iter().map(|l| l.amount).sum(); + assert_eq!(total, d("1000.00")); +} + +/// Oracle: interface doc "from the purchase month": nothing is depreciated +/// before purchase. +/// +/// ASSUMED: a `through` date before the purchase month yields an empty +/// schedule, and accumulated depreciation before purchase is 0. +#[test] +fn nothing_depreciates_before_purchase() { + let a = asset("12000.00", "0.00", 12, date(2026, 1, 1)); + assert!(depreciation_schedule(&a, date(2025, 12, 31)).is_empty()); + assert_eq!(accumulated_depreciation(&a, date(2025, 12, 31)), d("0")); + assert_eq!(book_value(&a, date(2025, 12, 31)), d("12000.00")); +} + +/// Oracle: interface docs "accumulated through as_of" and "book value = +/// cost − accumulated": mid-life at 2026-06-30 = 6 × 1000.00. +#[test] +fn accumulated_and_book_value_mid_life() { + let a = asset("12000.00", "0.00", 12, date(2026, 1, 1)); + assert_eq!( + accumulated_depreciation(&a, date(2026, 6, 30)), + d("6000.00") + ); + assert_eq!(book_value(&a, date(2026, 6, 30)), d("6000.00")); +} + +// ------------------------------------------------------------- disposal + +/// Oracle: dispose_asset INVARIANT 1, gain = proceeds − book value: +/// 7000 − (12000 − 6000) = +1000.00. +#[test] +fn disposal_gain_is_proceeds_minus_book_value() { + let a = asset("12000.00", "0.00", 12, date(2026, 1, 1)); + let got = disposal_amounts(&a, date(2026, 6, 30), usd("7000.00")).unwrap(); + assert_eq!(got.accumulated, d("6000.00")); + assert_eq!(got.book_value, d("6000.00")); + assert_eq!(got.gain_loss, d("1000.00")); +} + +/// Oracle: DisposalAmounts doc "loss is negative": 5000 − 6000 = −1000.00; +/// scrapping for 0 loses the whole book value, −6000.00. +#[test] +fn disposal_loss_is_negative() { + let a = asset("12000.00", "0.00", 12, date(2026, 1, 1)); + let got = disposal_amounts(&a, date(2026, 6, 30), usd("5000.00")).unwrap(); + assert_eq!(got.gain_loss, d("-1000.00")); + let scrap = disposal_amounts(&a, date(2026, 6, 30), usd("0.00")).unwrap(); + assert_eq!(scrap.gain_loss, d("-6000.00")); +} + +/// Oracle: B-15 + gain law: after full life, book value = salvage 1000.00, +/// selling for 1000.00 is neither gain nor loss. +#[test] +fn disposal_after_full_life_at_salvage_is_break_even() { + let a = asset("5000.00", "1000.00", 4, date(2026, 3, 1)); + let got = disposal_amounts(&a, date(2027, 12, 31), usd("1000.00")).unwrap(); + assert_eq!(got.accumulated, d("4000.00")); + assert_eq!(got.book_value, d("1000.00")); + assert_eq!(got.gain_loss, d("0")); +} + +/// Oracle: LedgerError::NegativeProceeds doc "disposal proceeds must not be +/// negative". +#[test] +fn disposal_refuses_negative_proceeds() { + let a = asset("12000.00", "0.00", 12, date(2026, 1, 1)); + assert_eq!( + disposal_amounts(&a, date(2026, 6, 30), usd("-1.00")), + Err(LedgerError::NegativeProceeds(d("-1.00"))) + ); +} + +/// Oracle: LedgerError::DisposalBeforePurchase doc "disposal date must not +/// precede the purchase date". +#[test] +fn disposal_refuses_date_before_purchase() { + let a = asset("12000.00", "0.00", 12, date(2026, 1, 1)); + assert!(matches!( + disposal_amounts(&a, date(2025, 12, 31), usd("100.00")), + Err(LedgerError::DisposalBeforePurchase(_)) + )); +} + +// ------------------------------------------------- register / dispose + +/// Oracle: register_asset GOAL + next_asset_id doc: a valid asset is +/// registered under the announced id, Active, straight-line, with its inputs. +#[test] +fn register_asset_records_inputs_under_next_id() { + let (mut l, a) = fixture(); + let new = new_asset(&a); + assert_eq!(l.validate_new_asset(&new), Ok(())); + let expected_id = l.next_asset_id(); + let id = l.register_asset(new).unwrap(); + assert_eq!(id, expected_id); + let got = l.get_asset(id).unwrap(); + assert_eq!(got.description, "Laptop"); + assert_eq!(got.purchase_date, date(2026, 1, 1)); + assert_eq!(got.cost, usd("12000.00")); + assert_eq!(got.salvage_value, usd("0.00")); + assert_eq!(got.useful_life_months, 12); + assert_eq!(got.method, DepreciationMethod::StraightLine); + assert_eq!(got.cost_account, a.equipment); + assert_eq!(got.accum_depr_account, a.accum); + assert_eq!(got.expense_account, a.depr_expense); + assert_eq!(got.status, AssetStatus::Active); + assert_eq!(l.registered_assets().count(), 1); +} + +/// Checks a refusal from both validate_new_asset and register_asset, and that +/// neither changed the books. +fn assert_asset_refused(new: NewAsset, l: &mut Ledger, check: impl Fn(&LedgerError) -> bool) { + let before = snapshot(l); + let v = l.validate_new_asset(&new); + assert!( + matches!(&v, Err(e) if check(e)), + "validate_new_asset: {v:?}" + ); + let r = l.register_asset(new); + assert!(matches!(&r, Err(e) if check(e)), "register_asset: {r:?}"); + assert_eq!(snapshot(l), before, "refusal changed the books"); +} + +/// Oracle: register_asset SCENARIO 1 "description is blank: SHOULD refuse"; +/// refusal leaves the books unchanged (B-8 / post_entry INVARIANT 3 spirit). +#[test] +fn register_asset_refuses_empty_description() { + let (mut l, a) = fixture(); + let mut n = new_asset(&a); + n.description = String::new(); + assert_asset_refused(n, &mut l, |e| *e == LedgerError::EmptyAssetDescription); +} + +/// Oracle: register_asset SCENARIO 1 says "blank", not just empty. +/// +/// ASSUMED: whitespace-only counts as blank. +#[test] +fn register_asset_refuses_whitespace_description() { + let (mut l, a) = fixture(); + let mut n = new_asset(&a); + n.description = " ".to_string(); + assert_asset_refused(n, &mut l, |e| *e == LedgerError::EmptyAssetDescription); +} + +/// Oracle: register_asset SCENARIO 1 "cost isn't positive" → InvalidAssetCost. +#[test] +fn register_asset_refuses_non_positive_cost() { + for cost in ["0.00", "-5.00"] { + let (mut l, a) = fixture(); + let mut n = new_asset(&a); + n.cost = usd(cost); + n.salvage_value = usd("0.00"); + let want = d(cost); + assert_asset_refused( + n, + &mut l, + |e| matches!(e, LedgerError::InvalidAssetCost(c) if *c == want), + ); + } +} + +/// Oracle: register_asset SCENARIO 1 "salvage is negative or not below cost". +#[test] +fn register_asset_refuses_bad_salvage() { + for salvage in ["-0.01", "12000.00", "12000.01"] { + let (mut l, a) = fixture(); + let mut n = new_asset(&a); + n.salvage_value = usd(salvage); + assert_asset_refused(n, &mut l, |e| { + matches!(e, LedgerError::InvalidSalvageValue(_)) + }); + } +} + +/// Oracle: register_asset SCENARIO 1 "useful life is zero months". +#[test] +fn register_asset_refuses_zero_life() { + let (mut l, a) = fixture(); + let mut n = new_asset(&a); + n.useful_life_months = 0; + assert_asset_refused(n, &mut l, |e| *e == LedgerError::InvalidUsefulLife(0)); +} + +/// Oracle: register_asset doc "asset/asset/expense types" and +/// AssetAccountWrongType's role names. +#[test] +fn register_asset_refuses_wrong_account_types() { + // cost account is equity + let (mut l, a) = fixture(); + let mut n = new_asset(&a); + n.cost_account = a.capital; + assert_asset_refused( + n, + &mut l, + |e| matches!(e, LedgerError::AssetAccountWrongType { role, .. } if *role == "cost"), + ); + + // accumulated depreciation is a liability + let (mut l, a) = fixture(); + let mut n = new_asset(&a); + n.accum_depr_account = a.liability; + assert_asset_refused( + n, + &mut l, + |e| matches!(e, LedgerError::AssetAccountWrongType { role, .. } if *role == "accumulated depreciation"), + ); + + // depreciation expense is equity + let (mut l, a) = fixture(); + let mut n = new_asset(&a); + n.expense_account = a.capital; + assert_asset_refused( + n, + &mut l, + |e| matches!(e, LedgerError::AssetAccountWrongType { role, .. } if *role == "depreciation expense"), + ); +} + +/// Oracle: register_asset doc "three distinct accounts" → DuplicateAssetAccount. +#[test] +fn register_asset_refuses_duplicate_account_links() { + let (mut l, a) = fixture(); + let mut n = new_asset(&a); + n.accum_depr_account = a.equipment; + let dup = a.equipment; + assert_asset_refused(n, &mut l, |e| *e == LedgerError::DuplicateAssetAccount(dup)); +} + +/// Oracle: NewAsset doc "cost … in the ledger's base currency". +/// +/// ASSUMED: the error variant is unspecified; any refusal passes. +#[test] +fn register_asset_refuses_foreign_currency() { + let (mut l, a) = fixture(); + let mut n = new_asset(&a); + n.cost = Money::new(d("12000.00"), Currency::EUR); + n.salvage_value = Money::new(d("0.00"), Currency::EUR); + assert_asset_refused(n, &mut l, |_| true); +} + +/// Oracle: dispose_asset doc "records the lifecycle change" → status +/// Disposed { date, proceeds }. +#[test] +fn dispose_asset_marks_disposed() { + let (mut l, a) = fixture(); + let id = l.register_asset(new_asset(&a)).unwrap(); + l.dispose_asset(id, date(2026, 6, 30), usd("7000.00")) + .unwrap(); + assert_eq!( + l.get_asset(id).unwrap().status, + AssetStatus::Disposed { + date: date(2026, 6, 30), + proceeds: usd("7000.00"), + } + ); +} + +/// Oracle: dispose_asset SCENARIO 1 "unknown or already disposed: SHOULD +/// refuse", leaving the books unchanged. +#[test] +fn dispose_asset_refuses_unknown_and_twice() { + let (mut l, a) = fixture(); + let id = l.register_asset(new_asset(&a)).unwrap(); + let before = snapshot(&l); + assert_eq!( + l.dispose_asset(id + 100, date(2026, 6, 30), usd("1.00")), + Err(LedgerError::UnknownAsset(id + 100)) + ); + assert_eq!(snapshot(&l), before); + + l.dispose_asset(id, date(2026, 6, 30), usd("7000.00")) + .unwrap(); + let before = snapshot(&l); + assert_eq!( + l.dispose_asset(id, date(2026, 7, 31), usd("1.00")), + Err(LedgerError::AssetAlreadyDisposed(id)) + ); + assert_eq!(snapshot(&l), before); +} + +/// Oracle: NegativeProceeds / DisposalBeforePurchase error docs. +/// +/// ASSUMED: dispose_asset applies the same disposal checks as +/// disposal_amounts (it records the lifecycle change of a valid disposal). +#[test] +fn dispose_asset_refuses_bad_disposals_unchanged() { + let (mut l, a) = fixture(); + let id = l.register_asset(new_asset(&a)).unwrap(); + let before = snapshot(&l); + assert_eq!( + l.dispose_asset(id, date(2026, 6, 30), usd("-1.00")), + Err(LedgerError::NegativeProceeds(d("-1.00"))) + ); + assert!(matches!( + l.dispose_asset(id, date(2025, 12, 1), usd("1.00")), + Err(LedgerError::DisposalBeforePurchase(_)) + )); + assert_eq!(snapshot(&l), before); +} + +// ---------------------------------------------------------------- payroll + +/// Oracle: PayrollLine::net_pay doc "gross minus all employee withholdings +/// and deductions": 5000 − 600 − 250 − 310 − 72.50 − 200 = 3567.50 (employer +/// shares are not withheld from the employee). +#[test] +fn net_pay_is_gross_minus_employee_withholdings() { + assert_eq!(alice().net_pay(), d("3567.50")); + assert_eq!(bob().net_pay(), d("1697.00")); +} + +/// Oracle: validate_payroll_lines doc "non-empty". +#[test] +fn validate_lines_refuses_empty() { + assert_eq!(validate_payroll_lines(&[]), Err(LedgerError::EmptyPayroll)); +} + +/// Oracle: validate_payroll_lines doc "no negative amounts", every field. +#[test] +fn validate_lines_refuses_each_negative_field() { + for field in 0..8 { + let mut ln = alice(); + let neg = d("-1.00"); + match field { + 0 => ln.gross_pay = neg, + 1 => ln.federal_withholding = neg, + 2 => ln.state_withholding = neg, + 3 => ln.ss_employee = neg, + 4 => ln.medicare_employee = neg, + 5 => ln.ss_employer = neg, + 6 => ln.medicare_employer = neg, + _ => ln.other_deductions = neg, + } + assert_eq!( + validate_payroll_lines(&[ln]), + Err(LedgerError::NegativePayrollAmount(neg)), + "field {field}" + ); + } +} + +/// Oracle: validate_payroll_lines doc "no negative net pay": withholdings of +/// 5000.01 against 5000.00 gross. +#[test] +fn validate_lines_refuses_negative_net_pay() { + let mut ln = alice(); + ln.other_deductions = d("3767.51"); // 600+250+310+72.50+3767.51 = 5000.01 + assert_eq!( + validate_payroll_lines(&[ln]), + Err(LedgerError::NegativeNetPay("Alice".to_string())) + ); +} + +/// Oracle: net_pay doc "Must be >= 0": exactly zero net pay is allowed; the +/// employer shares may exceed what's left (they're not withheld). +#[test] +fn validate_lines_accepts_zero_net_pay() { + let mut ln = alice(); + ln.other_deductions = d("3767.50"); + assert_eq!(ln.net_pay(), d("0")); + assert_eq!(validate_payroll_lines(&[ln]), Ok(())); +} + +/// Oracle: payroll_entry_plan doc + run_payroll INVARIANT 1, worked by hand: +/// Dr 6100 5000.00, Dr 6110 382.50 (310 + 72.50), Cr 2110 600, Cr 2120 250, +/// Cr 2130 620 (both SS shares), Cr 2140 145 (both Medicare), Cr 2150 200, +/// Cr cash 3567.50. Debits = credits = 5382.50. +#[test] +fn entry_plan_matches_hand_worked_entry() { + let plan = payroll_entry_plan(&[alice()], PA, PLAN_CASH, Currency::USD); + let got: Vec<(AccountId, Side, Money)> = plan; + let want = [ + (1, Side::Debit, usd("5000.00")), + (2, Side::Debit, usd("382.50")), + (3, Side::Credit, usd("600.00")), + (4, Side::Credit, usd("250.00")), + (5, Side::Credit, usd("620.00")), + (6, Side::Credit, usd("145.00")), + (7, Side::Credit, usd("200.00")), + (PLAN_CASH, Side::Credit, usd("3567.50")), + ]; + assert_eq!(got.len(), want.len(), "{got:?}"); + for (g, w) in got.iter().zip(want.iter()) { + assert_eq!( + (g.0, g.1, g.2.amount, g.2.currency), + (w.0, w.1, w.2.amount, w.2.currency) + ); + } +} + +/// Oracle: payroll_entry_plan doc "Zero totals are skipped": Bob has no state +/// withholding and no other deductions, so no 2120 or 2150 line. +#[test] +fn entry_plan_skips_zero_totals() { + let plan = payroll_entry_plan(&[bob()], PA, PLAN_CASH, Currency::USD); + assert!(plan.iter().all(|(_, _, m)| !m.amount.is_zero())); + assert!( + plan.iter() + .all(|(a, _, _)| *a != PA.state_payable && *a != PA.other_payable) + ); + assert_eq!(plan_totals(&plan), (d("2153.00"), d("2153.00"))); +} + +/// Oracle: payroll_remit_plan doc "Dr each payable with a non-zero balance, +/// Cr cash for the total": 600 + 145 = 745.00; the zero is skipped. +#[test] +fn remit_plan_hand_worked() { + let plan = payroll_remit_plan( + &[(3, d("600.00")), (4, d("0")), (6, d("145.00"))], + PLAN_CASH, + Currency::USD, + ); + let got: Vec<(AccountId, Side, Decimal)> = + plan.iter().map(|(a, s, m)| (*a, *s, m.amount)).collect(); + assert_eq!( + got, + vec![ + (3, Side::Debit, d("600.00")), + (6, Side::Debit, d("145.00")), + (PLAN_CASH, Side::Credit, d("745.00")), + ] + ); +} + +/// Oracle: run_payroll INVARIANTS 1–2 and doc (auto-create 6100/6110/ +/// 2110–2150), hand-worked Alice entry; balances after: cash 10000 − 3567.50 +/// = 6432.50; payables as in the plan. +#[test] +fn run_payroll_posts_hand_worked_entry() { + let (mut l, a) = fixture(); + assert_eq!( + l.validate_payroll(date(2026, 2, 27), &[alice()], a.cash), + Ok(()) + ); + let next = l.next_payroll_run_id(); + let id = l + .run_payroll(date(2026, 2, 27), vec![alice()], a.cash) + .unwrap(); + assert_eq!(id, next); + let run = l.get_payroll_run(id).unwrap().clone(); + assert_eq!(run.date, date(2026, 2, 27)); + assert_eq!(run.lines, vec![alice()]); + let e = l.get_entry(run.entry_id).unwrap(); + assert_eq!(e.status(), ledger_core::EntryStatus::Posted); + assert_eq!(e.date(), date(2026, 2, 27)); + assert_eq!( + entry_lines_by_code(&l, run.entry_id), + vec![ + ent("6100", Side::Debit, "5000.00"), + ent("6110", Side::Debit, "382.50"), + ent("2110", Side::Credit, "600.00"), + ent("2120", Side::Credit, "250.00"), + ent("2130", Side::Credit, "620.00"), + ent("2140", Side::Credit, "145.00"), + ent("2150", Side::Credit, "200.00"), + ent("1000", Side::Credit, "3567.50"), + ] + ); + assert_eq!(balance_of(&l, "1000"), d("6432.50")); + assert_eq!(balance_of(&l, "6100"), d("5000.00")); + assert_eq!(balance_of(&l, "6110"), d("382.50")); + assert_eq!(balance_of(&l, "2130"), d("620.00")); +} + +/// Oracle: payroll_entry_plan doc (one line per account total) with two +/// employees: gross 7000.00, employer FICA 382.50 + 153.00 = 535.50, fed +/// 750.00, state 250.00, SS 620 + 248 = 868.00, Medicare 145 + 58 = 203.00, +/// other 200.00, net 3567.50 + 1697.00 = 5264.50. +/// +/// ASSUMED: lines are aggregated per account ("zero totals are skipped" +/// implies totals), not one line per employee. +#[test] +fn run_payroll_aggregates_employees() { + let (mut l, a) = fixture(); + let id = l + .run_payroll(date(2026, 2, 27), vec![alice(), bob()], a.cash) + .unwrap(); + let entry = l.get_payroll_run(id).unwrap().entry_id; + assert_eq!( + entry_lines_by_code(&l, entry), + vec![ + ent("6100", Side::Debit, "7000.00"), + ent("6110", Side::Debit, "535.50"), + ent("2110", Side::Credit, "750.00"), + ent("2120", Side::Credit, "250.00"), + ent("2130", Side::Credit, "868.00"), + ent("2140", Side::Credit, "203.00"), + ent("2150", Side::Credit, "200.00"), + ent("1000", Side::Credit, "5264.50"), + ] + ); +} + +/// Oracle: payroll_tax_payables doc (code order, normal-side, zero/missing +/// skipped) and remit_payroll_taxes INVARIANT 1 "MUST zero every payroll +/// payable against cash": remit 600 + 250 + 620 + 145 + 200 = 1815.00, cash +/// 6432.50 → 4617.50; a second remit refuses with NothingToRemit, unchanged. +#[test] +fn remit_zeroes_every_payable() { + let (mut l, a) = fixture(); + assert!(l.payroll_tax_payables().is_empty()); + l.run_payroll(date(2026, 2, 27), vec![alice()], a.cash) + .unwrap(); + let codes: Vec<(String, Decimal)> = l + .payroll_tax_payables() + .into_iter() + .map(|(id, bal)| (l.chart().get(id).unwrap().code.clone(), bal)) + .collect(); + assert_eq!( + codes, + vec![ + ("2110".to_string(), d("600.00")), + ("2120".to_string(), d("250.00")), + ("2130".to_string(), d("620.00")), + ("2140".to_string(), d("145.00")), + ("2150".to_string(), d("200.00")), + ] + ); + let e = l.remit_payroll_taxes(date(2026, 3, 15), a.cash).unwrap(); + assert_eq!( + entry_lines_by_code(&l, e), + vec![ + ent("2110", Side::Debit, "600.00"), + ent("2120", Side::Debit, "250.00"), + ent("2130", Side::Debit, "620.00"), + ent("2140", Side::Debit, "145.00"), + ent("2150", Side::Debit, "200.00"), + ent("1000", Side::Credit, "1815.00"), + ] + ); + for code in ["2110", "2120", "2130", "2140", "2150"] { + assert_eq!(balance_of(&l, code), d("0"), "{code}"); + } + assert_eq!(balance_of(&l, "1000"), d("4617.50")); + assert!(l.payroll_tax_payables().is_empty()); + + let before = snapshot(&l); + assert_eq!( + l.remit_payroll_taxes(date(2026, 3, 16), a.cash), + Err(LedgerError::NothingToRemit) + ); + assert_eq!(snapshot(&l), before); +} + +/// Oracle: remit_payroll_taxes SCENARIO 1 on fresh books: nothing owed. +#[test] +fn remit_with_nothing_owed_refuses() { + let (mut l, a) = fixture(); + let before = snapshot(&l); + assert_eq!( + l.remit_payroll_taxes(date(2026, 3, 15), a.cash), + Err(LedgerError::NothingToRemit) + ); + assert_eq!(snapshot(&l), before); +} + +fn assert_payroll_refused( + l: &mut Ledger, + when: NaiveDate, + lines: Vec, + cash: AccountId, + check: impl Fn(&LedgerError) -> bool, +) { + let before = snapshot(l); + let v = l.validate_payroll(when, &lines, cash); + assert!(matches!(&v, Err(e) if check(e)), "validate_payroll: {v:?}"); + let r = l.run_payroll(when, lines, cash); + assert!(matches!(&r, Err(e) if check(e)), "run_payroll: {r:?}"); + assert_eq!(snapshot(l), before, "refusal changed the books"); +} + +/// Oracle: run_payroll SCENARIO 1 (negative amount, withholdings exceed +/// gross) and validate_payroll_lines (empty); a refusal leaves the books — +/// including the chart, which must not gain payroll accounts — unchanged. +#[test] +fn run_payroll_refusals_leave_books_unchanged() { + let (mut l, a) = fixture(); + assert_payroll_refused(&mut l, date(2026, 2, 27), vec![], a.cash, |e| { + *e == LedgerError::EmptyPayroll + }); + let mut neg = alice(); + neg.state_withholding = d("-0.01"); + assert_payroll_refused(&mut l, date(2026, 2, 27), vec![bob(), neg], a.cash, |e| { + *e == LedgerError::NegativePayrollAmount(d("-0.01")) + }); + let mut over = bob(); + over.federal_withholding = d("1847.01"); // 1847.01+124+29 = 2000.01 + assert_payroll_refused( + &mut l, + date(2026, 2, 27), + vec![alice(), over], + a.cash, + |e| *e == LedgerError::NegativeNetPay("Bob".to_string()), + ); +} + +/// Oracle: run_payroll SCENARIO 1 "gross is zero: SHOULD refuse". +/// +/// ASSUMED: a run whose only line is all zeros is "gross is zero"; the error +/// variant is unspecified. +#[test] +fn run_payroll_refuses_zero_gross() { + let (mut l, a) = fixture(); + let zero = pline("Zed", "0", "0", "0", "0", "0", "0", "0", "0"); + assert_payroll_refused(&mut l, date(2026, 2, 27), vec![zero], a.cash, |_| true); +} + +/// Oracle: validate_payroll/run_payroll take a cash account; an unknown one +/// cannot be credited (B-1 / R-1 "no unknown accounts"). +/// +/// ASSUMED: the refusal comes before posting; variant unspecified. +#[test] +fn run_payroll_refuses_unknown_cash_account() { + let (mut l, _) = fixture(); + assert_payroll_refused(&mut l, date(2026, 2, 27), vec![alice()], 9_999, |_| true); +} + +/// Oracle: ControlAccount doc "a command refuses one of the wrong type +/// (ControlAccountMismatch)": 6100 already exists as an Asset. +#[test] +fn run_payroll_refuses_wrong_type_control_account() { + let (mut l, a) = fixture(); + l.add_account("6100", "Not wages", AccountType::Asset, None, false) + .unwrap(); + assert_payroll_refused( + &mut l, + date(2026, 2, 27), + vec![alice()], + a.cash, + |e| matches!(e, LedgerError::ControlAccountMismatch { code, .. } if code == "6100"), + ); +} + +/// Oracle: B-12 "a closed period takes no new postings"; remit doc "rejects +/// dates in closed periods". +#[test] +fn payroll_and_remit_refuse_closed_period() { + let (mut l, a) = fixture(); + let p = l + .create_period("2026-01", date(2026, 1, 1), date(2026, 1, 31)) + .unwrap(); + l.close_period(p).unwrap(); + assert_payroll_refused(&mut l, date(2026, 1, 20), vec![alice()], a.cash, |e| { + matches!(e, LedgerError::ClosedPeriod(..)) + }); + l.run_payroll(date(2026, 2, 27), vec![alice()], a.cash) + .unwrap(); + let before = snapshot(&l); + assert!(matches!( + l.remit_payroll_taxes(date(2026, 1, 20), a.cash), + Err(LedgerError::ClosedPeriod(..)) + )); + assert_eq!(snapshot(&l), before); +} + +// ------------------------------------------------------------- properties + +fn arb_purchase() -> impl Strategy { + (2020i32..=2030, 1u32..=12, 1u32..=31).prop_map(|(y, m, day)| { + let first = NaiveDate::from_ymd_opt(y, m, 1).unwrap(); + let last = month_end(first).day(); + NaiveDate::from_ymd_opt(y, m, day.min(last)).unwrap() + }) +} + +fn arb_asset() -> impl Strategy { + (1i64..=10_000_000, 1u32..=120, arb_purchase()) + .prop_flat_map(|(cost, life, p)| (Just(cost), 0..cost, Just(life), Just(p))) + .prop_map(|(cost, salvage, life, p)| Asset { + cost: Money::new(Decimal::new(cost, 2), Currency::USD), + salvage_value: Money::new(Decimal::new(salvage, 2), Currency::USD), + ..asset("1.00", "0.00", life, p) + }) +} + +fn next_period(p: &str) -> String { + let y: i32 = p[0..4].parse().unwrap(); + let m: u32 = p[5..7].parse().unwrap(); + if m == 12 { + format!("{:04}-01", y + 1) + } else { + format!("{:04}-{:02}", y, m + 1) + } +} + +fn period_end(p: &str) -> NaiveDate { + let y: i32 = p[0..4].parse().unwrap(); + let m: u32 = p[5..7].parse().unwrap(); + month_end(NaiveDate::from_ymd_opt(y, m, 1).unwrap()) +} + +proptest! { + #![proptest_config(ProptestConfig::with_cases(256))] + + /// Oracle: B-15 laws — a full schedule sums to exactly cost − salvage; + /// amounts are non-negative whole cents; accumulated is the running sum + /// and non-decreasing; book value = cost − accumulated, never rises, never + /// below salvage, ends at salvage; months are consecutive from the + /// purchase month. + /// + /// ASSUMED: a full schedule has `life` or `life + 1` months (life + 1 + /// when the first month is partial). + #[test] + fn prop_full_schedule_laws(a in arb_asset()) { + let cost = a.cost.amount; + let salvage = a.salvage_value.amount; + let s = depreciation_schedule(&a, date(2050, 12, 31)); + prop_assert!(!s.is_empty()); + let total: Decimal = s.iter().map(|l| l.amount).sum(); + prop_assert_eq!(total, cost - salvage); + let life = a.useful_life_months as usize; + prop_assert!(s.len() == life || s.len() == life + 1, "len {} life {}", s.len(), life); + prop_assert_eq!(&s[0].period, &format!("{:04}-{:02}", a.purchase_date.year(), a.purchase_date.month())); + let mut acc = Decimal::ZERO; + let mut prev_bv = cost; + for (i, l) in s.iter().enumerate() { + if i > 0 { + prop_assert_eq!(&l.period, &next_period(&s[i - 1].period)); + } + prop_assert!(l.amount >= Decimal::ZERO); + prop_assert_eq!(l.amount, l.amount.round_dp(2)); + acc += l.amount; + prop_assert_eq!(l.accumulated, acc); + prop_assert_eq!(l.book_value, cost - l.accumulated); + prop_assert!(l.book_value <= prev_bv); + prop_assert!(l.book_value >= salvage); + prev_bv = l.book_value; + } + prop_assert_eq!(s.last().unwrap().book_value, salvage); + } + + /// Oracle: accumulated_depreciation / book_value docs agree with the + /// schedule at each month end, a shorter schedule is a prefix of the full + /// one, and nothing is depreciated before purchase. + #[test] + fn prop_point_queries_agree_with_schedule(a in arb_asset()) { + let full = depreciation_schedule(&a, date(2050, 12, 31)); + for (i, l) in full.iter().enumerate() { + let end = period_end(&l.period); + prop_assert_eq!(accumulated_depreciation(&a, end), l.accumulated); + prop_assert_eq!(book_value(&a, end), l.book_value); + let prefix = depreciation_schedule(&a, end); + prop_assert_eq!(&prefix[..], &full[..=i]); + } + let before = a.purchase_date.pred_opt().unwrap(); + prop_assert_eq!(accumulated_depreciation(&a, before), Decimal::ZERO); + prop_assert_eq!(book_value(&a, before), a.cost.amount); + } + + /// Oracle: B-15 partial months by days + interface "the last month absorbs + /// the drift". When (cost − salvage) / life is a whole number of cents M + /// and the purchase is after the 1st, the middle months are each exactly + /// M and the partial first and last months together are exactly M. + #[test] + fn prop_partial_first_and_last_sum_to_one_month( + m in 1i64..=100_000, + life in 2u32..=60, + salvage in 0i64..=100_000, + p in arb_purchase().prop_filter("after the 1st", |p| p.day() > 1), + ) { + let monthly = Decimal::new(m, 2); + let mut a = asset("1.00", "0.00", life, p); + a.salvage_value = Money::new(Decimal::new(salvage, 2), Currency::USD); + a.cost = Money::new(Decimal::new(salvage + m * i64::from(life), 2), Currency::USD); + let s = depreciation_schedule(&a, date(2050, 12, 31)); + prop_assert_eq!(s.len(), life as usize + 1); + for l in &s[1..s.len() - 1] { + prop_assert_eq!(l.amount, monthly); + } + prop_assert_eq!(s[0].amount + s[s.len() - 1].amount, monthly); + } + + /// Oracle: dispose_asset INVARIANT 1 / DisposalAmounts docs: gain_loss = + /// proceeds − book_value, book_value = cost − accumulated, both matching + /// the point queries for the disposal date. + #[test] + fn prop_disposal_amounts_laws( + a in arb_asset(), + offset in 0u32..=200, + proceeds in 0i64..=20_000_000, + ) { + let when = month_end(a.purchase_date.checked_add_months(Months::new(offset)).unwrap()); + let pr = Decimal::new(proceeds, 2); + let got = disposal_amounts(&a, when, Money::new(pr, Currency::USD)).unwrap(); + prop_assert_eq!(got.accumulated, accumulated_depreciation(&a, when)); + prop_assert_eq!(got.book_value, a.cost.amount - got.accumulated); + prop_assert_eq!(got.book_value, book_value(&a, when)); + prop_assert_eq!(got.gain_loss, pr - got.book_value); + prop_assert!(got.book_value >= a.salvage_value.amount); + } +} + +fn arb_line() -> impl Strategy { + (1i64..=1_000_000) + .prop_flat_map(|gross| { + let cap = gross / 5; + ( + Just(gross), + prop::collection::vec((0i64..=cap, any::()), 5), + prop::collection::vec((0i64..=gross, any::()), 2), + ) + }) + .prop_map(|(gross, w, e)| { + let z = |(v, keep): (i64, bool)| Decimal::new(if keep { v } else { 0 }, 2); + PayrollLine { + employee_name: "E".to_string(), + gross_pay: Decimal::new(gross, 2), + federal_withholding: z(w[0]), + state_withholding: z(w[1]), + ss_employee: z(w[2]), + medicare_employee: z(w[3]), + other_deductions: z(w[4]), + ss_employer: z(e[0]), + medicare_employer: z(e[1]), + } + }) +} + +proptest! { + #![proptest_config(ProptestConfig::with_cases(256))] + + /// Oracle: payroll_entry_plan doc — always balances, no zero lines, base + /// currency, Dr 6100 = Σ gross, Dr 6110 = Σ employer FICA, each payable = + /// its withholdings (both FICA shares), Cr cash = Σ net pay, and net pay + /// = gross − employee withholdings. + #[test] + fn prop_entry_plan_laws(lines in prop::collection::vec(arb_line(), 1..5)) { + prop_assert_eq!(validate_payroll_lines(&lines), Ok(())); + let plan = payroll_entry_plan(&lines, PA, PLAN_CASH, Currency::USD); + let (dr, cr) = plan_totals(&plan); + prop_assert_eq!(dr, cr); + prop_assert!(plan.iter().all(|(_, _, m)| m.amount > Decimal::ZERO && m.currency == Currency::USD)); + let sum = |f: fn(&PayrollLine) -> Decimal| -> Decimal { lines.iter().map(f).sum() }; + for l in &lines { + prop_assert_eq!( + l.net_pay(), + l.gross_pay - l.federal_withholding - l.state_withholding + - l.ss_employee - l.medicare_employee - l.other_deductions + ); + } + prop_assert_eq!(plan_sum(&plan, PA.salary_expense, Side::Debit), sum(|l| l.gross_pay)); + prop_assert_eq!(plan_sum(&plan, PA.payroll_tax_expense, Side::Debit), sum(|l| l.ss_employer + l.medicare_employer)); + prop_assert_eq!(plan_sum(&plan, PA.federal_payable, Side::Credit), sum(|l| l.federal_withholding)); + prop_assert_eq!(plan_sum(&plan, PA.state_payable, Side::Credit), sum(|l| l.state_withholding)); + prop_assert_eq!(plan_sum(&plan, PA.ss_payable, Side::Credit), sum(|l| l.ss_employee + l.ss_employer)); + prop_assert_eq!(plan_sum(&plan, PA.medicare_payable, Side::Credit), sum(|l| l.medicare_employee + l.medicare_employer)); + prop_assert_eq!(plan_sum(&plan, PA.other_payable, Side::Credit), sum(|l| l.other_deductions)); + prop_assert_eq!(plan_sum(&plan, PLAN_CASH, Side::Credit), sum(|l| l.net_pay())); + } + + /// Oracle: payroll_remit_plan doc "balances by construction": Σ Dr = Cr + /// cash, one debit per non-zero payable, no zero lines. + #[test] + fn prop_remit_plan_balances(bals in prop::collection::vec(0i64..=1_000_000, 1..6)) { + let payables: Vec<(AccountId, Decimal)> = bals + .iter() + .enumerate() + .map(|(i, c)| (i as AccountId + 1, Decimal::new(*c, 2))) + .collect(); + let plan = payroll_remit_plan(&payables, PLAN_CASH, Currency::USD); + let total: Decimal = payables.iter().map(|(_, b)| *b).sum(); + let (dr, cr) = plan_totals(&plan); + prop_assert_eq!(dr, cr); + prop_assert_eq!(dr, total); + prop_assert!(plan.iter().all(|(_, _, m)| !m.amount.is_zero())); + for (acct, bal) in &payables { + prop_assert_eq!(plan_sum(&plan, *acct, Side::Debit), *bal); + } + } +} + +proptest! { + #![proptest_config(ProptestConfig::with_cases(48))] + + /// Oracle: run_payroll INVARIANT 1 + remit INVARIANT 1: after a run and a + /// remittance, every 2110–2150 payable is zero and cash has fallen by + /// exactly gross + employer FICA (= 6100 + 6110), the run's whole cost. + #[test] + fn prop_run_then_remit_zeroes_payables(lines in prop::collection::vec(arb_line(), 1..4)) { + let (mut l, a) = fixture(); + prop_assert_eq!(l.validate_payroll(date(2026, 2, 27), &lines, a.cash), Ok(())); + l.run_payroll(date(2026, 2, 27), lines.clone(), a.cash).unwrap(); + let cost: Decimal = lines.iter().map(|x| x.gross_pay + x.ss_employer + x.medicare_employer).sum(); + let withheld: Decimal = lines + .iter() + .map(|x| x.gross_pay - x.net_pay() + x.ss_employer + x.medicare_employer) + .sum(); + let owed: Decimal = l.payroll_tax_payables().iter().map(|(_, b)| *b).sum(); + prop_assert_eq!(owed, withheld); + if !withheld.is_zero() { + l.remit_payroll_taxes(date(2026, 3, 15), a.cash).unwrap(); + } + prop_assert!(l.payroll_tax_payables().is_empty()); + for code in ["2110", "2120", "2130", "2140", "2150"] { + if let Some(acct) = l.chart().find_by_code(code) { + prop_assert_eq!(l.account_balance(acct.id).unwrap(), Decimal::ZERO); + } + } + prop_assert_eq!(balance_of(&l, "1000"), d("10000.00") - cost); + } +} diff --git a/crates/ledger-core/tests/spec_ledger.rs b/crates/ledger-core/tests/spec_ledger.rs new file mode 100644 index 0000000..7d0ff0e --- /dev/null +++ b/crates/ledger-core/tests/spec_ledger.rs @@ -0,0 +1,2411 @@ +//! Spec-derived tests for the `Ledger` command surface. +//! +//! Written from the spec blocks (GOAL / SOURCE / INVARIANTS / SCENARIOS) in +//! the public interface and from `docs/spec/rules.md`, never from the +//! implementation. Every expected value is a literal computed by hand from +//! the spec, or a law (a model kept by the test itself). A test whose name +//! does not already say its oracle says it in its doc comment. +//! +//! Tests marked `#[ignore = "SPEC-DISAGREEMENT: ..."]` were derived from the +//! spec and fail against the code; they are findings, not bugs in the test. + +use std::collections::BTreeSet; +use std::str::FromStr; + +use chrono::NaiveDate; +use ledger_core::reports::trial_balance; +use ledger_core::{ + AccountId, AccountType, CloseTo, Currency, DocumentLine, EntryId, EntryStatus, Ledger, + LedgerError, MemberClose, Money, PeriodId, PeriodStatus, Side, trial_balance_as_of, +}; +use proptest::prelude::*; +use rust_decimal::Decimal; + +// --------------------------------------------------------------------------- +// helpers +// --------------------------------------------------------------------------- + +fn d(y: i32, m: u32, day: u32) -> NaiveDate { + NaiveDate::from_ymd_opt(y, m, day).expect("valid date") +} + +fn dec(s: &str) -> Decimal { + Decimal::from_str(s).expect("valid decimal") +} + +fn usd(s: &str) -> Money { + Money::new(dec(s), Currency::USD) +} + +/// A small partnership chart (the Wanderland shape). +struct Books { + l: Ledger, + cash: AccountId, + card: AccountId, + cap_a: AccountId, + cap_b: AccountId, + dist_a: AccountId, + rev: AccountId, + exp: AccountId, + header: AccountId, +} + +fn books() -> Books { + let mut l = Ledger::new(Currency::USD); + let header = l + .add_account("1900", "Other Assets", AccountType::Asset, None, true) + .unwrap(); + let cash = l + .add_account("1000", "Bluevine Checking", AccountType::Asset, None, false) + .unwrap(); + let card = l + .add_account( + "2100", + "Due to Shareholders", + AccountType::Liability, + None, + false, + ) + .unwrap(); + let cap_a = l + .add_account("3000", "Capital A", AccountType::Equity, None, false) + .unwrap(); + let cap_b = l + .add_account("3001", "Capital B", AccountType::Equity, None, false) + .unwrap(); + let dist_a = l + .add_account("3002", "Distributions A", AccountType::Equity, None, false) + .unwrap(); + let rev = l + .add_account("4000", "App Revenue", AccountType::Revenue, None, false) + .unwrap(); + let exp = l + .add_account("6000", "Software", AccountType::Expense, None, false) + .unwrap(); + Books { + l, + cash, + card, + cap_a, + cap_b, + dist_a, + rev, + exp, + header, + } +} + +/// Draft, add lines, post. Panics on any refusal (setup only). +fn post(l: &mut Ledger, date: NaiveDate, lines: &[(AccountId, Side, &str)]) -> EntryId { + let id = l.try_create_entry(date, "test entry").unwrap(); + for (a, s, amt) in lines { + l.add_line(id, *a, *s, usd(amt)).unwrap(); + } + l.post_entry(id).unwrap(); + id +} + +/// Build a draft from lines and try to post it. A refusal at `add_line` +/// counts as well as a refusal at `post_entry` (the spec says the amount +/// rules hold "at post"; it doesn't say whether `add_line` checks them +/// early). Returns the refusal, or `None` if the entry posted. +fn try_post( + l: &mut Ledger, + date: NaiveDate, + lines: &[(AccountId, Side, Money)], +) -> Option { + let id = l.try_create_entry(date, "attempt").unwrap(); + for (a, s, m) in lines { + if let Err(e) = l.add_line(id, *a, *s, *m) { + return Some(e); + } + } + l.post_entry(id).err() +} + +/// Everything a refusal could disturb, as plain comparable data. +#[derive(Clone, Debug, PartialEq)] +struct Observed { + chart: Vec, + entries: Vec, + posted_order: Vec, + periods: serde_json::Value, + next_ids: Vec, + tb: serde_json::Value, + entry_vendors: Vec>, + cleared: Vec, + closing: BTreeSet, + counts: (usize, usize, usize), +} + +fn observe(l: &Ledger) -> Observed { + let mut chart: Vec<_> = l.chart().all().collect(); + chart.sort_by(|a, b| a.code.cmp(&b.code)); + let chart = chart + .into_iter() + .map(|a| serde_json::to_value(a).unwrap()) + .collect(); + let horizon = l.next_entry_id() + 3; + let entries = (0..horizon) + .map(|i| serde_json::to_value(l.get_entry(i)).unwrap()) + .collect(); + let entry_vendors = (0..horizon).map(|i| l.entry_vendor(i)).collect(); + let mut cleared = Vec::new(); + for e in l.posted_entries() { + for i in 0..e.lines().len() + 1 { + cleared.push(l.is_cleared(e.id(), i)); + } + } + Observed { + chart, + entries, + posted_order: l.posted_entries().map(|e| e.id()).collect(), + periods: serde_json::to_value(l.list_periods()).unwrap(), + next_ids: vec![ + l.next_account_id(), + l.next_entry_id(), + l.next_period_id(), + l.next_customer_id(), + l.next_vendor_id(), + l.next_invoice_id(), + l.next_bill_id(), + u64::from(l.next_class_id()), + ], + tb: serde_json::to_value(trial_balance(l)).unwrap(), + entry_vendors, + cleared, + closing: l.closing_entry_ids(), + counts: ( + l.chart().len(), + l.list_classes().len(), + l.list_vendors().len(), + ), + } +} + +/// [`observe`] with the account-id allocator masked out: the burned-id +/// finding (see `a_refused_account_never_uses_up_an_id`) is reported once, +/// so it doesn't hide other differences. +fn observe_but_account_ids(l: &Ledger) -> Observed { + let mut o = observe(l); + o.next_ids[0] = 0; + o +} + +fn lines_of(l: &Ledger, id: EntryId) -> Vec<(AccountId, Side, Decimal)> { + l.get_entry(id) + .unwrap() + .lines() + .iter() + .map(|x| (x.account, x.side, x.amount.amount)) + .collect() +} + +/// Signed movement (debit +, credit -) of one account within one entry, +/// computed from the lines by the test, not by the engine. +fn entry_movement(l: &Ledger, id: EntryId, account: AccountId) -> Decimal { + lines_of(l, id) + .into_iter() + .filter(|(a, _, _)| *a == account) + .map(|(_, s, amt)| if s == Side::Debit { amt } else { -amt }) + .sum() +} + +fn tb_has_code(l: &Ledger, as_of: NaiveDate, code: &str) -> bool { + trial_balance_as_of(l, as_of) + .lines + .iter() + .any(|x| x.code == code) +} + +// --------------------------------------------------------------------------- +// add_account +// --------------------------------------------------------------------------- + +/// Oracle: R-4 / B-4: an added account is findable by its code with exactly +/// the type, parent, and placeholder flag it was given. +#[test] +fn an_added_account_is_registered_under_its_code() { + let mut b = books(); + let n = b.l.chart().len(); + let id = + b.l.add_account( + "1010", + "Petty Cash", + AccountType::Asset, + Some(b.cash), + false, + ) + .unwrap(); + let a = b.l.chart().find_by_code("1010").unwrap(); + assert_eq!(a.id, id); + assert_eq!(a.name, "Petty Cash"); + assert_eq!(a.account_type, AccountType::Asset); + assert_eq!(a.parent, Some(b.cash)); + assert!(!a.placeholder); + assert_eq!(a.currency, Currency::USD); + assert_eq!(b.l.chart().len(), n + 1); +} + +/// Oracle: add_account SCENARIO 1: an empty code is refused. +#[test] +fn an_empty_account_code_is_refused() { + let mut b = books(); + let before = observe_but_account_ids(&b.l); + assert_eq!( + b.l.add_account("", "Nameless", AccountType::Asset, None, false), + Err(LedgerError::EmptyAccountCode) + ); + assert_eq!(observe_but_account_ids(&b.l), before); +} + +/// Oracle: add_account SCENARIO 1: a taken code is refused. +#[test] +fn a_duplicate_account_code_is_refused() { + let mut b = books(); + let before = observe_but_account_ids(&b.l); + assert_eq!( + b.l.add_account("1000", "Other Cash", AccountType::Asset, None, false), + Err(LedgerError::DuplicateAccountCode("1000".into())) + ); + assert_eq!(observe_but_account_ids(&b.l), before); +} + +/// Oracle: add_account SCENARIO 2: an unknown parent is refused. +#[test] +fn an_unknown_parent_account_is_refused() { + let mut b = books(); + let ghost = 999_999; + let before = observe_but_account_ids(&b.l); + assert_eq!( + b.l.add_account("1011", "Orphan", AccountType::Asset, Some(ghost), false), + Err(LedgerError::UnknownParentAccount(ghost)) + ); + assert_eq!(observe_but_account_ids(&b.l), before); +} + +/// Oracle: the refusal law ("a refusal MUST leave the books unchanged", +/// post_entry INVARIANT 3; create_period: "NEVER ... use up an id") applied +/// to add_account: a refused account never uses up an account id, so the +/// next account gets the id `next_account_id` announced before the refusal. +/// ASSUMED: the law covers add_account (its own spec block doesn't repeat +/// it; ParentTypeMismatch already behaves this way). +#[test] +fn a_refused_account_never_uses_up_an_id() { + let mut b = books(); + let ghost = 999_999; + let announced = b.l.next_account_id(); + assert!( + b.l.add_account("", "x", AccountType::Asset, None, false) + .is_err() + ); + assert_eq!(b.l.next_account_id(), announced, "empty code"); + assert!( + b.l.add_account("1000", "x", AccountType::Asset, None, false) + .is_err() + ); + assert_eq!(b.l.next_account_id(), announced, "duplicate code"); + assert!( + b.l.add_account("1011", "x", AccountType::Asset, Some(ghost), false) + .is_err() + ); + assert_eq!(b.l.next_account_id(), announced, "unknown parent"); + let id = + b.l.add_account("1011", "x", AccountType::Asset, None, false) + .unwrap(); + assert_eq!(id, announced); +} + +/// Oracle: add_account SCENARIO 3 (B-4 DECISION): a child of another type is +/// refused with ParentTypeMismatch and NEVER added. +#[test] +fn a_child_account_has_its_parents_type() { + let mut b = books(); + let before = observe(&b.l); + assert_eq!( + b.l.add_account( + "1950", + "Loan", + AccountType::Liability, + Some(b.header), + false + ), + Err(LedgerError::ParentTypeMismatch { + code: "1950".into(), + parent_code: "1900".into(), + expected: AccountType::Asset, + found: AccountType::Liability, + }) + ); + assert!(b.l.chart().find_by_code("1950").is_none()); + assert_eq!(observe(&b.l), before); + // Same type under a placeholder header: accepted. + assert!( + b.l.add_account("1950", "Deposit", AccountType::Asset, Some(b.header), false) + .is_ok() + ); +} + +// --------------------------------------------------------------------------- +// try_create_entry +// --------------------------------------------------------------------------- + +/// Oracle: try_create_entry SCENARIO 2: each new id is one past the last, +/// and is the id `next_entry_id` announced. +#[test] +fn a_new_draft_gets_the_next_id_one_past_the_last() { + let mut b = books(); + let announced = b.l.next_entry_id(); + let e1 = b.l.try_create_entry(d(2026, 1, 5), "a").unwrap(); + let e2 = b.l.try_create_entry(d(2026, 1, 6), "b").unwrap(); + assert_eq!(e1, announced); + assert_eq!(e2, e1 + 1); + let e = b.l.get_entry(e1).unwrap(); + assert_eq!(e.status(), EntryStatus::Draft); + assert_eq!(e.date(), d(2026, 1, 5)); + assert_eq!(e.description(), "a"); + assert!(e.lines().is_empty()); +} + +/// Oracle: try_create_entry SCENARIO 1 (B-12): a date inside a closed period, +/// first and last day included, is refused with ClosedPeriod and creates +/// nothing; the day after the period is accepted. +#[test] +fn a_draft_is_never_created_in_a_closed_period() { + let mut b = books(); + post( + &mut b.l, + d(2026, 1, 10), + &[ + (b.cash, Side::Debit, "10.00"), + (b.rev, Side::Credit, "10.00"), + ], + ); + let jan = + b.l.create_period("2026-01", d(2026, 1, 1), d(2026, 1, 31)) + .unwrap(); + b.l.close_period(jan).unwrap(); + for day in [d(2026, 1, 1), d(2026, 1, 15), d(2026, 1, 31)] { + let before = observe(&b.l); + let r = b.l.try_create_entry(day, "late"); + assert!( + matches!(r, Err(LedgerError::ClosedPeriod(p, _)) if p == jan), + "{day}: {r:?}" + ); + assert_eq!(observe(&b.l), before); + } + assert!(b.l.try_create_entry(d(2026, 2, 1), "next month").is_ok()); + assert!(b.l.try_create_entry(d(2025, 12, 31), "prior month").is_ok()); +} + +// --------------------------------------------------------------------------- +// add_line / add_line_fx +// --------------------------------------------------------------------------- + +/// Oracle: JournalEntry::lines doc: lines are kept in the order added, with +/// the account, side, and amount given. +#[test] +fn lines_are_kept_in_the_order_added() { + let mut b = books(); + let e = b.l.try_create_entry(d(2026, 3, 1), "split").unwrap(); + b.l.add_line(e, b.exp, Side::Debit, usd("12.34")).unwrap(); + b.l.add_line(e, b.cash, Side::Credit, usd("10.00")).unwrap(); + b.l.add_line(e, b.card, Side::Credit, usd("2.34")).unwrap(); + assert_eq!( + lines_of(&b.l, e), + vec![ + (b.exp, Side::Debit, dec("12.34")), + (b.cash, Side::Credit, dec("10.00")), + (b.card, Side::Credit, dec("2.34")), + ] + ); +} + +/// Oracle: B-1 DECISION (Stephen, 2026-10-13): a line on an unknown account +/// is refused when added (UnknownAccount), not only at post. +#[test] +fn a_line_on_an_unknown_account_is_refused_when_added() { + let mut b = books(); + let e = b.l.try_create_entry(d(2026, 3, 1), "x").unwrap(); + let before = observe(&b.l); + assert_eq!( + b.l.add_line(e, 424_242, Side::Debit, usd("1.00")), + Err(LedgerError::UnknownAccount(424_242)) + ); + assert_eq!(observe(&b.l), before); +} + +/// Oracle: B-1 DECISION / R-4: a line on a placeholder account is refused +/// when added (PlaceholderAccount). +#[test] +fn a_line_on_a_placeholder_account_is_refused_when_added() { + let mut b = books(); + let e = b.l.try_create_entry(d(2026, 3, 1), "x").unwrap(); + let before = observe(&b.l); + let r = b.l.add_line(e, b.header, Side::Debit, usd("1.00")); + assert!( + matches!(r, Err(LedgerError::PlaceholderAccount(_))), + "{r:?}" + ); + assert_eq!(observe(&b.l), before); +} + +/// Oracle: B-3 / R-5 and the add_line doc: a foreign-currency amount is +/// refused, never converted (CurrencyMismatch). +#[test] +fn a_foreign_currency_line_is_refused_not_converted() { + let mut b = books(); + let e = b.l.try_create_entry(d(2026, 3, 1), "x").unwrap(); + let before = observe(&b.l); + let r = b.l.add_line( + e, + b.exp, + Side::Debit, + Money::new(dec("5.00"), Currency::EUR), + ); + assert!( + matches!(r, Err(LedgerError::CurrencyMismatch { .. })), + "{r:?}" + ); + assert_eq!(observe(&b.l), before); +} + +/// Oracle: R-3 / B-8 and the PostedEntryImmutable doc ("add lines"): a posted +/// entry takes no more lines. +#[test] +fn a_posted_entry_takes_no_more_lines() { + let mut b = books(); + let e = post( + &mut b.l, + d(2026, 3, 1), + &[(b.exp, Side::Debit, "5.00"), (b.cash, Side::Credit, "5.00")], + ); + let before = observe(&b.l); + assert_eq!( + b.l.add_line(e, b.exp, Side::Debit, usd("1.00")), + Err(LedgerError::PostedEntryImmutable) + ); + assert_eq!(observe(&b.l), before); +} + +/// Oracle: UnknownEntry doc: a line on an entry that does not exist is refused. +#[test] +fn a_line_on_an_unknown_entry_is_refused() { + let mut b = books(); + let before = observe(&b.l); + assert_eq!( + b.l.add_line(777_777, b.exp, Side::Debit, usd("1.00")), + Err(LedgerError::UnknownEntry(777_777)) + ); + assert_eq!(observe(&b.l), before); +} + +/// Oracle: B-3 (ASC 830-20-30-1) and the add_line_fx doc: base = foreign × +/// rate (100 EUR × 1.10 = 110.00 USD), the original kept; the entry then +/// balances in base currency. +#[test] +fn a_foreign_line_is_measured_at_its_rate_and_keeps_the_original() { + let mut b = books(); + let e = + b.l.try_create_entry(d(2026, 3, 1), "Berlin hosting") + .unwrap(); + b.l.add_line_fx( + e, + b.exp, + Side::Debit, + Currency::EUR, + dec("100"), + dec("1.10"), + ) + .unwrap(); + b.l.add_line(e, b.cash, Side::Credit, usd("110.00")) + .unwrap(); + let line = &b.l.get_entry(e).unwrap().lines()[0]; + assert_eq!(line.amount, usd("110.00")); + assert_eq!(line.amount.currency, Currency::USD); + assert_eq!(line.foreign_currency, Some(Currency::EUR)); + assert_eq!(line.foreign_amount, Some(dec("100"))); + b.l.post_entry(e).unwrap(); + assert_eq!(b.l.account_balance(b.exp).unwrap(), dec("110.00")); + assert_eq!(b.l.account_balance(b.cash).unwrap(), dec("-110.00")); +} + +/// Oracle: InvalidFxRate doc: a rate that isn't positive is refused, the +/// draft unchanged. +#[test] +fn a_non_positive_fx_rate_is_refused() { + let mut b = books(); + let e = b.l.try_create_entry(d(2026, 3, 1), "x").unwrap(); + for rate in ["0", "-1.10"] { + let before = observe(&b.l); + assert_eq!( + b.l.add_line_fx(e, b.exp, Side::Debit, Currency::EUR, dec("100"), dec(rate)), + Err(LedgerError::InvalidFxRate(dec(rate))) + ); + assert_eq!(observe(&b.l), before); + } +} + +/// Oracle: rules.md B-3 "Checked: fx_overflow_is_an_error" and B-21 (exact +/// amounts): a product that can't be represented is refused, never rounded +/// or wrapped. +#[test] +fn fx_overflow_is_an_error() { + let mut b = books(); + let e = b.l.try_create_entry(d(2026, 3, 1), "x").unwrap(); + let before = observe(&b.l); + assert!( + b.l.add_line_fx(e, b.exp, Side::Debit, Currency::EUR, Decimal::MAX, dec("2")) + .is_err() + ); + assert_eq!(observe(&b.l), before); +} + +/// Oracle: B-1 DECISION applies to add_line_fx too: unknown account refused +/// when added. +#[test] +fn a_foreign_line_on_an_unknown_account_is_refused_when_added() { + let mut b = books(); + let e = b.l.try_create_entry(d(2026, 3, 1), "x").unwrap(); + let before = observe(&b.l); + assert_eq!( + b.l.add_line_fx(e, 424_242, Side::Debit, Currency::EUR, dec("1"), dec("1.1")), + Err(LedgerError::UnknownAccount(424_242)) + ); + assert_eq!(observe(&b.l), before); +} + +/// Oracle: B-3 + line range (AmountOutOfRange: at most one quadrillion): +/// a foreign line converting to 2,000,000,000,000,000 USD never posts. +#[test] +fn a_foreign_line_beyond_line_range_never_posts() { + let mut b = books(); + let e = b.l.try_create_entry(d(2026, 3, 1), "x").unwrap(); + let big = dec("1000000000000000"); + let added = + b.l.add_line_fx(e, b.exp, Side::Debit, Currency::EUR, big, dec("2")); + if added.is_ok() { + let _ = + b.l.add_line_fx(e, b.cash, Side::Credit, Currency::EUR, big, dec("2")); + assert_eq!(b.l.post_entry(e), Err(LedgerError::AmountOutOfRange)); + } + assert_eq!(b.l.posted_entries().count(), 0); +} + +// --------------------------------------------------------------------------- +// post_entry and JournalEntry::validate +// --------------------------------------------------------------------------- + +/// Oracle: post_entry SCENARIO 1: an unknown entry is refused (UnknownEntry). +#[test] +fn posting_an_unknown_entry_is_refused() { + let mut b = books(); + let before = observe(&b.l); + assert_eq!( + b.l.post_entry(31_337), + Err(LedgerError::UnknownEntry(31_337)) + ); + assert_eq!(observe(&b.l), before); +} + +/// Oracle: post_entry SCENARIO 2 (B-8): a posted entry is never posted +/// twice (EntryAlreadyPosted), the log unchanged. +#[test] +fn a_posted_entry_is_never_posted_twice() { + let mut b = books(); + let e = post( + &mut b.l, + d(2026, 3, 1), + &[(b.exp, Side::Debit, "5.00"), (b.cash, Side::Credit, "5.00")], + ); + let before = observe(&b.l); + assert_eq!(b.l.post_entry(e), Err(LedgerError::EntryAlreadyPosted(e))); + assert_eq!(observe(&b.l), before); + assert_eq!(b.l.posted_entries().count(), 1); +} + +/// Oracle: post_entry SCENARIO 3 (B-12): an entry dated in a closed period +/// is refused (ClosedPeriod), even one drafted through the unchecked +/// constructor. +#[test] +fn an_entry_dated_in_a_closed_period_never_posts() { + let mut b = books(); + let jan = + b.l.create_period("2026-01", d(2026, 1, 1), d(2026, 1, 31)) + .unwrap(); + b.l.close_period(jan).unwrap(); + let e = b.l.create_entry(d(2026, 1, 20), "backdated"); + // add_line may itself refuse the closed date; either way it never posts. + let _ = b.l.add_line(e, b.exp, Side::Debit, usd("5.00")); + let _ = b.l.add_line(e, b.cash, Side::Credit, usd("5.00")); + let before = observe(&b.l); + let r = b.l.post_entry(e); + assert!( + matches!(r, Err(LedgerError::ClosedPeriod(p, _)) if p == jan), + "{r:?}" + ); + assert_eq!(observe(&b.l), before); +} + +/// Oracle: R-1/B-1: fewer than two lines is refused (TooFewLines), for zero +/// lines and for one. +#[test] +fn an_entry_with_fewer_than_two_lines_never_posts() { + let mut b = books(); + assert_eq!( + try_post(&mut b.l, d(2026, 3, 1), &[]), + Some(LedgerError::TooFewLines) + ); + assert_eq!( + try_post( + &mut b.l, + d(2026, 3, 1), + &[(b.cash, Side::Debit, usd("1.00"))] + ), + Some(LedgerError::TooFewLines) + ); + assert_eq!(b.l.posted_entries().count(), 0); +} + +/// Oracle: R-1/B-1 (no epsilon): 100.00 against 99.99 is refused with the +/// offending totals. +#[test] +fn an_unbalanced_entry_is_refused_with_its_totals() { + let mut b = books(); + let r = try_post( + &mut b.l, + d(2026, 3, 1), + &[ + (b.exp, Side::Debit, usd("100.00")), + (b.cash, Side::Credit, usd("99.99")), + ], + ); + assert_eq!( + r, + Some(LedgerError::UnbalancedEntry { + debits: dec("100.00"), + credits: dec("99.99"), + }) + ); + // One ten-billionth is still a difference. + let r = try_post( + &mut b.l, + d(2026, 3, 1), + &[ + (b.exp, Side::Debit, usd("1.0000000001")), + (b.cash, Side::Credit, usd("1")), + ], + ); + assert!( + matches!(r, Some(LedgerError::UnbalancedEntry { .. })), + "{r:?}" + ); + assert_eq!(b.l.posted_entries().count(), 0); +} + +/// Oracle: R-2 exact decimals: 0.10 + 0.20 credits balance a 0.30 debit +/// exactly (a float would not). +#[test] +fn exact_decimal_sums_balance() { + let mut b = books(); + let r = try_post( + &mut b.l, + d(2026, 3, 1), + &[ + (b.exp, Side::Debit, usd("0.30")), + (b.cash, Side::Credit, usd("0.10")), + (b.card, Side::Credit, usd("0.20")), + ], + ); + assert_eq!(r, None); +} + +/// Oracle: R-6 / B-2: a zero-amount line is refused (ZeroAmountLine). +#[test] +fn a_zero_amount_line_never_posts() { + let mut b = books(); + let r = try_post( + &mut b.l, + d(2026, 3, 1), + &[ + (b.exp, Side::Debit, usd("5.00")), + (b.cash, Side::Credit, usd("5.00")), + (b.card, Side::Credit, usd("0.00")), + ], + ); + assert_eq!(r, Some(LedgerError::ZeroAmountLine)); + assert_eq!(b.l.posted_entries().count(), 0); +} + +/// Oracle: B-2: a negative amount is refused (NegativeAmountLine): the side +/// says the direction. +#[test] +fn a_negative_amount_line_never_posts() { + let mut b = books(); + let r = try_post( + &mut b.l, + d(2026, 3, 1), + &[ + (b.exp, Side::Debit, usd("5.00")), + (b.cash, Side::Debit, usd("-5.00")), + ], + ); + assert_eq!(r, Some(LedgerError::NegativeAmountLine)); + assert_eq!(b.l.posted_entries().count(), 0); +} + +/// Oracle: AmountOutOfRange doc ("at most 1000000000000000, ten decimal +/// places"): exactly one quadrillion and exactly ten places post; one cent +/// more, or an eleventh place, never does. +#[test] +fn line_range_is_one_quadrillion_and_ten_places() { + let mut b = books(); + let ok = |b: &mut Books, amt: &str| { + try_post( + &mut b.l, + d(2026, 3, 1), + &[ + (b.exp, Side::Debit, usd(amt)), + (b.cash, Side::Credit, usd(amt)), + ], + ) + }; + assert_eq!(ok(&mut b, "1000000000000000"), None); + assert_eq!(ok(&mut b, "0.0000000001"), None); + assert_eq!( + ok(&mut b, "1000000000000000.01"), + Some(LedgerError::AmountOutOfRange) + ); + assert_eq!( + ok(&mut b, "0.00000000001"), + Some(LedgerError::AmountOutOfRange) + ); + assert_eq!(b.l.posted_entries().count(), 2); +} + +/// Oracle: post_entry SCENARIO 4 / JournalEntry::validate: a draft carrying +/// an unknown or placeholder account (smuggled in through the replay path) +/// is refused at post with that reason. +#[test] +fn a_draft_with_an_unknown_or_placeholder_account_never_posts() { + let mut b = books(); + let e = b.l.try_create_entry(d(2026, 3, 1), "x").unwrap(); + b.l.replay_add_line(e, b.cash, Side::Debit, usd("1.00")) + .unwrap(); + b.l.replay_add_line(e, 424_242, Side::Credit, usd("1.00")) + .unwrap(); + assert_eq!( + b.l.get_entry(e).unwrap().validate(b.l.chart()), + Err(LedgerError::UnknownAccount(424_242)) + ); + assert_eq!(b.l.post_entry(e), Err(LedgerError::UnknownAccount(424_242))); + + let e = b.l.try_create_entry(d(2026, 3, 1), "y").unwrap(); + b.l.replay_add_line(e, b.cash, Side::Debit, usd("1.00")) + .unwrap(); + b.l.replay_add_line(e, b.header, Side::Credit, usd("1.00")) + .unwrap(); + let v = b.l.get_entry(e).unwrap().validate(b.l.chart()); + assert!( + matches!(v, Err(LedgerError::PlaceholderAccount(_))), + "{v:?}" + ); + let r = b.l.post_entry(e); + assert!( + matches!(r, Err(LedgerError::PlaceholderAccount(_))), + "{r:?}" + ); + assert_eq!(b.l.posted_entries().count(), 0); +} + +/// Oracle: post_entry INVARIANT 2 / R-11: the log is in posting order, not id +/// order, and posting never changes an earlier posted entry. +#[test] +fn the_log_is_in_posting_order() { + let mut b = books(); + let e1 = + b.l.try_create_entry(d(2026, 3, 1), "first drafted") + .unwrap(); + let e2 = + b.l.try_create_entry(d(2026, 2, 1), "second drafted") + .unwrap(); + for e in [e1, e2] { + b.l.add_line(e, b.exp, Side::Debit, usd("1.00")).unwrap(); + b.l.add_line(e, b.cash, Side::Credit, usd("1.00")).unwrap(); + } + b.l.post_entry(e2).unwrap(); + let e2_then = serde_json::to_value(b.l.get_entry(e2)).unwrap(); + b.l.post_entry(e1).unwrap(); + let order: Vec<_> = b.l.posted_entries().map(|e| e.id()).collect(); + assert_eq!(order, vec![e2, e1]); + assert_eq!(serde_json::to_value(b.l.get_entry(e2)).unwrap(), e2_then); + assert_eq!(b.l.get_entry(e1).unwrap().status(), EntryStatus::Posted); +} + +// --------------------------------------------------------------------------- +// check_reversal / reverse_entry +// --------------------------------------------------------------------------- + +/// Oracle: B-9 (Pacioli's storno): the reversal has the same lines with +/// sides flipped, is posted, dated as asked, linked by `reversal_of`, and +/// returns every balance to what it was. +#[test] +fn a_reversal_mirrors_the_original_and_cancels_it() { + let mut b = books(); + let e = post( + &mut b.l, + d(2026, 3, 1), + &[ + (b.exp, Side::Debit, "30.00"), + (b.cash, Side::Credit, "20.00"), + (b.card, Side::Credit, "10.00"), + ], + ); + let r = b.l.reverse_entry(e, d(2026, 3, 5), "oops").unwrap(); + let rev = b.l.get_entry(r).unwrap(); + assert_eq!(rev.status(), EntryStatus::Posted); + assert_eq!(rev.date(), d(2026, 3, 5)); + assert_eq!(rev.reversal_of(), Some(e)); + assert_eq!( + lines_of(&b.l, r), + vec![ + (b.exp, Side::Credit, dec("30.00")), + (b.cash, Side::Debit, dec("20.00")), + (b.card, Side::Debit, dec("10.00")), + ] + ); + for a in [b.exp, b.cash, b.card] { + assert_eq!(b.l.account_balance(a).unwrap(), Decimal::ZERO); + } + // The original is untouched (B-8): never erased. + assert_eq!(b.l.get_entry(e).unwrap().status(), EntryStatus::Posted); + assert_eq!(b.l.posted_entries().count(), 2); +} + +/// Oracle: B-10 / check_reversal SCENARIO 2: an entry is reversed at most +/// once (EntryAlreadyReversed(entry, by)); reversing the reversal is allowed +/// and re-instates it, and does not reopen the original. +#[test] +fn an_entry_is_reversed_at_most_once() { + let mut b = books(); + let e = post( + &mut b.l, + d(2026, 3, 1), + &[ + (b.exp, Side::Debit, "30.00"), + (b.cash, Side::Credit, "30.00"), + ], + ); + let r1 = b.l.reverse_entry(e, d(2026, 3, 2), "undo").unwrap(); + let before = observe(&b.l); + assert_eq!( + b.l.check_reversal(e, d(2026, 3, 3)), + Err(LedgerError::EntryAlreadyReversed(e, r1)) + ); + assert_eq!( + b.l.reverse_entry(e, d(2026, 3, 3), "undo again"), + Err(LedgerError::EntryAlreadyReversed(e, r1)) + ); + assert_eq!(observe(&b.l), before); + + let r2 = b.l.reverse_entry(r1, d(2026, 3, 4), "redo").unwrap(); + assert_eq!(b.l.account_balance(b.exp).unwrap(), dec("30.00")); + assert_eq!( + b.l.reverse_entry(r1, d(2026, 3, 5), "x"), + Err(LedgerError::EntryAlreadyReversed(r1, r2)) + ); + assert_eq!( + b.l.reverse_entry(e, d(2026, 3, 5), "x"), + Err(LedgerError::EntryAlreadyReversed(e, r1)) + ); +} + +/// Oracle: check_reversal SCENARIO 1: unknown (UnknownEntry) or draft +/// (EntryNotPosted) entries are refused. +#[test] +fn only_posted_entries_are_reversed() { + let mut b = books(); + let draft = b.l.try_create_entry(d(2026, 3, 1), "draft").unwrap(); + b.l.add_line(draft, b.exp, Side::Debit, usd("1.00")) + .unwrap(); + b.l.add_line(draft, b.cash, Side::Credit, usd("1.00")) + .unwrap(); + let before = observe(&b.l); + assert_eq!( + b.l.check_reversal(draft, d(2026, 3, 2)), + Err(LedgerError::EntryNotPosted(draft)) + ); + assert_eq!( + b.l.reverse_entry(draft, d(2026, 3, 2), "x"), + Err(LedgerError::EntryNotPosted(draft)) + ); + assert_eq!( + b.l.reverse_entry(9_999, d(2026, 3, 2), "x"), + Err(LedgerError::UnknownEntry(9_999)) + ); + assert_eq!(observe(&b.l), before); +} + +/// Oracle: B-10 / check_reversal SCENARIO 3: a closing entry is never +/// reversed (ClosingEntryIrreversible), even dated in an open period. +#[test] +fn a_closing_entry_is_never_reversed() { + let mut b = books(); + post( + &mut b.l, + d(2026, 1, 10), + &[ + (b.cash, Side::Debit, "100.00"), + (b.rev, Side::Credit, "100.00"), + ], + ); + let jan = + b.l.create_period("2026-01", d(2026, 1, 1), d(2026, 1, 31)) + .unwrap(); + let c = b.l.close_period(jan).unwrap().expect("something to close"); + let before = observe(&b.l); + assert_eq!( + b.l.check_reversal(c, d(2026, 2, 1)), + Err(LedgerError::ClosingEntryIrreversible(c)) + ); + assert_eq!( + b.l.reverse_entry(c, d(2026, 2, 1), "reopen"), + Err(LedgerError::ClosingEntryIrreversible(c)) + ); + assert_eq!(observe(&b.l), before); +} + +/// Oracle: check_reversal SCENARIO 4 (B-12): a reversal dated in a closed +/// period is refused (ClosedPeriod); dated after it, accepted. +#[test] +fn a_reversal_is_never_dated_in_a_closed_period() { + let mut b = books(); + let e = post( + &mut b.l, + d(2026, 1, 10), + &[ + (b.card, Side::Debit, "40.00"), + (b.cash, Side::Credit, "40.00"), + ], + ); + let jan = + b.l.create_period("2026-01", d(2026, 1, 1), d(2026, 1, 31)) + .unwrap(); + b.l.close_period(jan).unwrap(); + let before = observe(&b.l); + let r = b.l.check_reversal(e, d(2026, 1, 31)); + assert!( + matches!(r, Err(LedgerError::ClosedPeriod(p, _)) if p == jan), + "{r:?}" + ); + let r = b.l.reverse_entry(e, d(2026, 1, 31), "x"); + assert!( + matches!(r, Err(LedgerError::ClosedPeriod(p, _)) if p == jan), + "{r:?}" + ); + assert_eq!(observe(&b.l), before); + assert!(b.l.reverse_entry(e, d(2026, 2, 1), "x").is_ok()); +} + +// --------------------------------------------------------------------------- +// create_period +// --------------------------------------------------------------------------- + +/// Oracle: create_period SCENARIO 1: end before start is refused +/// (InvalidPeriodRange); a one-day period (end == start) is valid. +#[test] +fn a_period_ending_before_it_starts_is_refused() { + let mut b = books(); + let before = observe(&b.l); + let r = b.l.create_period("bad", d(2026, 2, 1), d(2026, 1, 31)); + assert!( + matches!(r, Err(LedgerError::InvalidPeriodRange { .. })), + "{r:?}" + ); + assert_eq!(observe(&b.l), before); + assert!( + b.l.create_period("one day", d(2026, 2, 1), d(2026, 2, 1)) + .is_ok() + ); +} + +/// Oracle: create_period SCENARIO 2: a month in a quarter in a year nest, +/// in either creation order; adjacent periods are accepted. +#[test] +fn periods_nest() { + let mut b = books(); + let q1 = + b.l.create_period("Q1", d(2026, 1, 1), d(2026, 3, 31)) + .unwrap(); + let jan = + b.l.create_period("Jan", d(2026, 1, 1), d(2026, 1, 31)) + .unwrap(); + let year = + b.l.create_period("2026", d(2026, 1, 1), d(2026, 12, 31)) + .unwrap(); + let feb = + b.l.create_period("Feb", d(2026, 2, 1), d(2026, 2, 28)) + .unwrap(); + let q2 = + b.l.create_period("Q2", d(2026, 4, 1), d(2026, 6, 30)) + .unwrap(); + let ids = [q1, jan, year, feb, q2]; + let distinct: BTreeSet = ids.iter().copied().collect(); + assert_eq!(distinct.len(), 5); + let p = b.l.get_period(feb).unwrap(); + assert_eq!((p.start, p.end), (d(2026, 2, 1), d(2026, 2, 28))); + assert_eq!(p.status, PeriodStatus::Open); + assert_eq!(p.closing_entry, None); + assert_eq!(b.l.list_periods().len(), 5); +} + +/// Oracle: create_period SCENARIO 3 (DECISION 2026-10-13): a partial overlap, +/// even by one day, is refused (PeriodsOverlap) and NEVER uses up an id. +#[test] +fn periods_nest_but_never_partially_overlap() { + let mut b = books(); + b.l.create_period("Jan", d(2026, 1, 1), d(2026, 1, 31)) + .unwrap(); + let next = b.l.next_period_id(); + let before = observe(&b.l); + assert_eq!( + b.l.create_period("Bad", d(2026, 1, 31), d(2026, 2, 28)), + Err(LedgerError::PeriodsOverlap { + name: "Bad".into(), + start: d(2026, 1, 31), + end: d(2026, 2, 28), + other: "Jan".into(), + other_start: d(2026, 1, 1), + other_end: d(2026, 1, 31), + }) + ); + let r = b.l.create_period("Bad2", d(2025, 12, 15), d(2026, 1, 15)); + assert!( + matches!(r, Err(LedgerError::PeriodsOverlap { .. })), + "{r:?}" + ); + assert_eq!(observe(&b.l), before); + assert_eq!(b.l.next_period_id(), next); + assert_eq!( + b.l.create_period("Feb", d(2026, 2, 1), d(2026, 2, 28)), + Ok(next) + ); +} + +// --------------------------------------------------------------------------- +// close_period_to +// --------------------------------------------------------------------------- + +/// Books with January revenue 1000.00 and expense 300.00, a February sale of +/// 50.00, and a January period. +fn jan_books() -> (Books, PeriodId, EntryId) { + let mut b = books(); + post( + &mut b.l, + d(2026, 1, 10), + &[ + (b.cash, Side::Debit, "1000.00"), + (b.rev, Side::Credit, "1000.00"), + ], + ); + post( + &mut b.l, + d(2026, 1, 31), + &[ + (b.exp, Side::Debit, "300.00"), + (b.card, Side::Credit, "300.00"), + ], + ); + let feb = post( + &mut b.l, + d(2026, 2, 1), + &[ + (b.cash, Side::Debit, "50.00"), + (b.rev, Side::Credit, "50.00"), + ], + ); + let jan = + b.l.create_period("2026-01", d(2026, 1, 1), d(2026, 1, 31)) + .unwrap(); + (b, jan, feb) +} + +fn assert_balanced(l: &Ledger, id: EntryId) { + let (dr, cr) = + lines_of(l, id) + .into_iter() + .fold( + (Decimal::ZERO, Decimal::ZERO), + |(dr, cr), (_, s, a)| match s { + Side::Debit => (dr + a, cr), + Side::Credit => (dr, cr + a), + }, + ); + assert_eq!(dr, cr, "closing entry must balance"); +} + +/// Oracle: close INVARIANTS 1-3 and B-11: closing to retained earnings +/// zeroes revenue and expense as of Jan 31, credits 3999 (created, equity) +/// with exactly the net 700.00, is dated Jan 31, balances, locks the +/// period, and leaves February's 50.00 sale untouched. +#[test] +fn a_close_sweeps_only_through_the_period_end() { + let (mut b, jan, feb) = jan_books(); + let feb_then = serde_json::to_value(b.l.get_entry(feb)).unwrap(); + let c = b.l.close_period(jan).unwrap().expect("something to close"); + + let ce = b.l.get_entry(c).unwrap(); + assert_eq!(ce.date(), d(2026, 1, 31)); + assert_eq!(ce.status(), EntryStatus::Posted); + assert_balanced(&b.l, c); + let re = b.l.chart().find_by_code("3999").expect("3999 created"); + assert_eq!(re.account_type, AccountType::Equity); + assert!(!re.placeholder); + assert_eq!(entry_movement(&b.l, c, re.id), dec("-700.00")); + assert_eq!(b.l.account_balance(re.id).unwrap(), dec("700.00")); + + assert!(!tb_has_code(&b.l, d(2026, 1, 31), "4000")); + assert!(!tb_has_code(&b.l, d(2026, 1, 31), "6000")); + assert_eq!(b.l.account_balance(b.rev).unwrap(), dec("50.00")); + assert_eq!(b.l.account_balance(b.exp).unwrap(), Decimal::ZERO); + assert_eq!(serde_json::to_value(b.l.get_entry(feb)).unwrap(), feb_then); + + let p = b.l.get_period(jan).unwrap(); + assert_eq!(p.status, PeriodStatus::Closed); + assert_eq!(p.closing_entry, Some(c)); + assert!(b.l.closing_entry_ids().contains(&c)); + assert!(trial_balance(&b.l).balanced()); +} + +/// Oracle: close INVARIANT 2 for a loss: expense 300.00 with no revenue +/// debits retained earnings 300.00. +#[test] +fn a_loss_closes_as_a_debit_to_equity() { + let mut b = books(); + post( + &mut b.l, + d(2026, 1, 5), + &[ + (b.exp, Side::Debit, "300.00"), + (b.card, Side::Credit, "300.00"), + ], + ); + let jan = + b.l.create_period("2026-01", d(2026, 1, 1), d(2026, 1, 31)) + .unwrap(); + let c = b.l.close_period(jan).unwrap().unwrap(); + let re = b.l.chart().find_by_code("3999").unwrap().id; + assert_eq!(entry_movement(&b.l, c, re), dec("300.00")); + assert_eq!(b.l.account_balance(re).unwrap(), dec("-300.00")); + assert_eq!(b.l.account_balance(b.exp).unwrap(), Decimal::ZERO); +} + +/// Oracle: close INVARIANT 5: nothing to close posts nothing and returns +/// None. ASSUMED: the period is still marked closed (step 3 of the doc). +#[test] +fn a_close_with_nothing_to_close_posts_no_entry() { + let mut b = books(); + post( + &mut b.l, + d(2026, 1, 5), + &[ + (b.cash, Side::Debit, "10.00"), + (b.card, Side::Credit, "10.00"), + ], + ); + let jan = + b.l.create_period("2026-01", d(2026, 1, 1), d(2026, 1, 31)) + .unwrap(); + let posted = b.l.posted_entries().count(); + let next = b.l.next_entry_id(); + assert_eq!(b.l.preview_close_period(jan), Ok(None)); + assert_eq!(b.l.close_period(jan), Ok(None)); + assert_eq!(b.l.posted_entries().count(), posted); + assert_eq!(b.l.next_entry_id(), next); + assert_eq!(b.l.get_period(jan).unwrap().status, PeriodStatus::Closed); +} + +/// Oracle: close SCENARIO 1: unknown (UnknownPeriod) or already closed +/// (PeriodAlreadyClosed) periods are refused. +#[test] +fn a_period_closes_once() { + let (mut b, jan, _) = jan_books(); + let before = observe(&b.l); + assert_eq!( + b.l.close_period(4_040), + Err(LedgerError::UnknownPeriod(4_040)) + ); + assert_eq!(observe(&b.l), before); + b.l.close_period(jan).unwrap(); + let before = observe(&b.l); + assert_eq!( + b.l.close_period(jan), + Err(LedgerError::PeriodAlreadyClosed(jan)) + ); + assert_eq!(observe(&b.l), before); +} + +/// Oracle: close SCENARIO 2: a draft dated inside the period blocks the +/// close (DraftsInPeriod). +#[test] +fn a_period_with_drafts_never_closes() { + let (mut b, jan, _) = jan_books(); + b.l.try_create_entry(d(2026, 1, 20), "unfinished").unwrap(); + let before = observe(&b.l); + assert_eq!(b.l.close_period(jan), Err(LedgerError::DraftsInPeriod(jan))); + assert_eq!(observe(&b.l), before); +} + +/// Oracle: close SCENARIO 3 (B-12): closing out of order is refused +/// (CloseOutOfOrder(id, later)). +#[test] +fn periods_close_in_order() { + let (mut b, jan, _) = jan_books(); + let feb = + b.l.create_period("2026-02", d(2026, 2, 1), d(2026, 2, 28)) + .unwrap(); + b.l.close_period(feb).unwrap(); + let before = observe(&b.l); + assert_eq!( + b.l.close_period(jan), + Err(LedgerError::CloseOutOfOrder(jan, feb)) + ); + assert_eq!(observe(&b.l), before); +} + +/// Oracle: close SCENARIO 5 / ControlAccountMismatch: an existing 3999 that +/// is not equity, or is a placeholder, is refused, never posted to. +#[test] +fn retained_earnings_must_be_a_postable_equity_account() { + let (mut b, jan, _) = jan_books(); + b.l.add_account("3999", "Suspense", AccountType::Asset, None, false) + .unwrap(); + let before = observe(&b.l); + let r = b.l.close_period(jan); + assert!( + matches!( + &r, + Err(LedgerError::ControlAccountMismatch { + code, + name, + expected: AccountType::Equity, + found: AccountType::Asset, + placeholder: false, + .. + }) if code == "3999" && name == "Suspense" + ), + "{r:?}" + ); + assert_eq!(observe(&b.l), before); + + let (mut b, jan, _) = jan_books(); + b.l.add_account("3999", "Equity Header", AccountType::Equity, None, true) + .unwrap(); + let before = observe(&b.l); + let r = b.l.close_period(jan); + assert!( + matches!( + &r, + Err(LedgerError::ControlAccountMismatch { + expected: AccountType::Equity, + found: AccountType::Equity, + placeholder: true, + .. + }) + ), + "{r:?}" + ); + assert_eq!(observe(&b.l), before); +} + +/// Oracle: close to an existing postable equity 3999: it is used, not +/// duplicated. +#[test] +fn an_existing_retained_earnings_account_is_used() { + let (mut b, jan, _) = jan_books(); + let re = + b.l.add_account( + "3999", + "Retained Earnings", + AccountType::Equity, + None, + false, + ) + .unwrap(); + let n = b.l.chart().len(); + b.l.close_period(jan).unwrap(); + assert_eq!(b.l.chart().len(), n); + assert_eq!(b.l.account_balance(re).unwrap(), dec("700.00")); +} + +/// Oracle: close INVARIANT 4: a dividend sweep account ends at zero, its +/// balance moved into retained earnings (700.00 net - 200.00 dividends). +#[test] +fn a_sweep_account_closes_into_its_target() { + let (mut b, jan, _) = jan_books(); + let div = + b.l.add_account("3100", "Dividends", AccountType::Equity, None, false) + .unwrap(); + post( + &mut b.l, + d(2026, 1, 20), + &[ + (div, Side::Debit, "200.00"), + (b.cash, Side::Credit, "200.00"), + ], + ); + b.l.close_period_to(jan, &CloseTo::RetainedEarnings { sweep: vec![div] }) + .unwrap(); + let re = b.l.chart().find_by_code("3999").unwrap().id; + assert_eq!(b.l.account_balance(div).unwrap(), Decimal::ZERO); + assert_eq!(b.l.account_balance(re).unwrap(), dec("500.00")); +} + +/// Oracle: CloseTo::Account: a sole proprietor's capital gets the 700.00 +/// net, less 100.00 draws swept into it. +#[test] +fn a_close_into_one_capital_account_carries_draws() { + let (mut b, jan, _) = jan_books(); + post( + &mut b.l, + d(2026, 1, 25), + &[ + (b.dist_a, Side::Debit, "100.00"), + (b.cash, Side::Credit, "100.00"), + ], + ); + let c = + b.l.close_period_to( + jan, + &CloseTo::Account { + into: b.cap_a, + sweep: vec![b.dist_a], + }, + ) + .unwrap() + .unwrap(); + assert_balanced(&b.l, c); + assert_eq!(b.l.account_balance(b.cap_a).unwrap(), dec("600.00")); + assert_eq!(b.l.account_balance(b.dist_a).unwrap(), Decimal::ZERO); + assert!(b.l.chart().find_by_code("3999").is_none()); +} + +fn members(shares: &[(AccountId, &str, Option)]) -> CloseTo { + CloseTo::Members( + shares + .iter() + .map(|(capital, share, distributions)| MemberClose { + capital: *capital, + share: dec(share), + distributions: *distributions, + }) + .collect(), + ) +} + +/// Oracle: B-13 / AGENTS.md (50/50): net 700.00 splits 350.00 / 350.00; each +/// member's distributions close into their own capital. +#[test] +fn a_partnership_close_splits_by_share() { + let (mut b, jan, _) = jan_books(); + post( + &mut b.l, + d(2026, 1, 25), + &[ + (b.dist_a, Side::Debit, "40.00"), + (b.cash, Side::Credit, "40.00"), + ], + ); + let to = members(&[(b.cap_a, "0.5", Some(b.dist_a)), (b.cap_b, "0.5", None)]); + let c = b.l.close_period_to(jan, &to).unwrap().unwrap(); + assert_balanced(&b.l, c); + assert_eq!(b.l.account_balance(b.cap_a).unwrap(), dec("310.00")); + assert_eq!(b.l.account_balance(b.cap_b).unwrap(), dec("350.00")); + assert_eq!(b.l.account_balance(b.dist_a).unwrap(), Decimal::ZERO); + assert_eq!(b.l.account_balance(b.rev).unwrap(), dec("50.00")); +} + +/// Books whose January net income is exactly `net` (revenue only). +fn net_books(net: &str) -> (Books, PeriodId, AccountId) { + let mut b = books(); + let cap_c = + b.l.add_account("3003", "Capital C", AccountType::Equity, None, false) + .unwrap(); + let (side_rev, side_cash) = if net.starts_with('-') { + (Side::Debit, Side::Credit) + } else { + (Side::Credit, Side::Debit) + }; + let amt = net.trim_start_matches('-'); + post( + &mut b.l, + d(2026, 1, 9), + &[(b.cash, side_cash, amt), (b.rev, side_rev, amt)], + ); + let jan = + b.l.create_period("2026-01", d(2026, 1, 1), d(2026, 1, 31)) + .unwrap(); + (b, jan, cap_c) +} + +/// Oracle: B-13 DECISION (largest remainder, ties to the earlier member), +/// hand-computed: 1.05 at 0.3/0.3/0.4 is exactly 0.315/0.315/0.42; rounded +/// down 0.31/0.31/0.42 leaves one cent, remainders .005/.005/0 tie, so the +/// first member gets it: 0.32/0.31/0.42. (The retired last-absorbs rule +/// would give 0.32/0.32/0.41.) +#[test] +fn members_split_by_largest_remainder_ties_to_the_earlier() { + let (mut b, jan, cap_c) = net_books("1.05"); + let to = members(&[ + (b.cap_a, "0.3", None), + (b.cap_b, "0.3", None), + (cap_c, "0.4", None), + ]); + b.l.close_period_to(jan, &to).unwrap().unwrap(); + assert_eq!(b.l.account_balance(b.cap_a).unwrap(), dec("0.32")); + assert_eq!(b.l.account_balance(b.cap_b).unwrap(), dec("0.31")); + assert_eq!(b.l.account_balance(cap_c).unwrap(), dec("0.42")); +} + +/// Oracle: B-13 / kernel::apportion INVARIANT 3, hand-computed: 0.07 at +/// 0.2/0.3/0.5 is exactly 0.014/0.021/0.035; rounded down 0.01/0.02/0.03 +/// (sum 0.06), remainders .004/.001/.005, so the largest remainder (the +/// third) gets the cent: 0.01/0.02/0.04, wherever it sits in the list. +#[test] +fn the_odd_cent_goes_to_the_largest_remainder_not_by_position() { + let (mut b, jan, cap_c) = net_books("0.07"); + let to = members(&[ + (b.cap_a, "0.2", None), + (b.cap_b, "0.3", None), + (cap_c, "0.5", None), + ]); + b.l.close_period_to(jan, &to).unwrap().unwrap(); + assert_eq!(b.l.account_balance(b.cap_a).unwrap(), dec("0.01")); + assert_eq!(b.l.account_balance(b.cap_b).unwrap(), dec("0.02")); + assert_eq!(b.l.account_balance(cap_c).unwrap(), dec("0.04")); + + // Remainders .004/.001/.005 again, but the largest is first this time: + // 0.07 at 0.5/0.3/0.2 = 0.035/0.021/0.014 -> 0.04/0.02/0.01. + let (mut b, jan, cap_c) = net_books("0.07"); + let to = members(&[ + (b.cap_a, "0.5", None), + (b.cap_b, "0.3", None), + (cap_c, "0.2", None), + ]); + b.l.close_period_to(jan, &to).unwrap().unwrap(); + assert_eq!(b.l.account_balance(b.cap_a).unwrap(), dec("0.04")); + assert_eq!(b.l.account_balance(b.cap_b).unwrap(), dec("0.02")); + assert_eq!(b.l.account_balance(cap_c).unwrap(), dec("0.01")); +} + +/// Oracle: periods::allocate doc ("a loss splits as the negation of the same +/// gain"): a 1.05 loss at 0.3/0.3/0.4 is -0.32/-0.31/-0.42. +#[test] +fn a_partnership_loss_splits_as_the_negated_gain() { + let (mut b, jan, cap_c) = net_books("-1.05"); + let to = members(&[ + (b.cap_a, "0.3", None), + (b.cap_b, "0.3", None), + (cap_c, "0.4", None), + ]); + b.l.close_period_to(jan, &to).unwrap().unwrap(); + assert_eq!(b.l.account_balance(b.cap_a).unwrap(), dec("-0.32")); + assert_eq!(b.l.account_balance(b.cap_b).unwrap(), dec("-0.31")); + assert_eq!(b.l.account_balance(cap_c).unwrap(), dec("-0.42")); +} + +/// Oracle: close SCENARIO 4: member shares that don't sum to 1, a share that +/// isn't positive, a non-equity or repeated capital account, or a target +/// that isn't equity are refused, the books unchanged. +#[test] +fn an_invalid_close_target_is_refused() { + let (mut b, jan, _) = jan_books(); + let bad: Vec = vec![ + members(&[(b.cap_a, "0.5", None), (b.cap_b, "0.4", None)]), + members(&[(b.cap_a, "0.5", None), (b.cap_b, "0.6", None)]), + members(&[(b.cap_a, "1.5", None), (b.cap_b, "-0.5", None)]), + members(&[(b.cap_a, "1", None), (b.cap_b, "0", None)]), + members(&[(b.cap_a, "0.5", None), (b.cash, "0.5", None)]), + members(&[(b.cap_a, "0.5", None), (b.cap_a, "0.5", None)]), + members(&[(b.cap_a, "0.5", Some(b.cap_b)), (b.cap_b, "0.5", None)]), + members(&[]), + CloseTo::Account { + into: b.cash, + sweep: vec![], + }, + CloseTo::Account { + into: b.cap_a, + sweep: vec![b.cap_a], + }, + CloseTo::Account { + into: b.cap_a, + sweep: vec![b.exp], + }, + CloseTo::RetainedEarnings { + sweep: vec![b.cash], + }, + ]; + for to in bad { + let before = observe(&b.l); + assert!( + b.l.preview_close_period_to(jan, &to).is_err(), + "preview {to:?}" + ); + assert!(b.l.close_period_to(jan, &to).is_err(), "{to:?}"); + assert_eq!(observe(&b.l), before, "{to:?}"); + } +} + +/// Oracle: preview_close_period_to doc: pure, and returns the id the closing +/// entry will receive. +#[test] +fn a_close_preview_is_pure_and_names_the_closing_entry() { + let (mut b, jan, _) = jan_books(); + let to = members(&[(b.cap_a, "0.5", None), (b.cap_b, "0.5", None)]); + let before = observe(&b.l); + let preview = b.l.preview_close_period_to(jan, &to).unwrap(); + assert_eq!(observe(&b.l), before); + assert_eq!(preview, Some(b.l.next_entry_id())); + assert_eq!(b.l.close_period_to(jan, &to).unwrap(), preview); +} + +// --------------------------------------------------------------------------- +// mark_cleared / reconciliation_report +// --------------------------------------------------------------------------- + +/// Oracle: reconciliation INVARIANTS 1-4, hand-computed: cash +1000.00 +/// (Jan 5, cleared), -200.00 (Jan 10, uncleared), +50.00 (Feb 2, cleared). +/// As of Jan 31 vs a 1000.00 statement: book 850.00, cleared 1000.00, +/// difference 0, outstanding = only the Jan 10 check. +#[test] +fn a_reconciliation_compares_cleared_lines_to_the_statement() { + let mut b = books(); + let dep = post( + &mut b.l, + d(2026, 1, 5), + &[ + (b.cash, Side::Debit, "1000.00"), + (b.cap_a, Side::Credit, "1000.00"), + ], + ); + let chk = post( + &mut b.l, + d(2026, 1, 10), + &[ + (b.exp, Side::Debit, "200.00"), + (b.cash, Side::Credit, "200.00"), + ], + ); + let late = post( + &mut b.l, + d(2026, 2, 2), + &[ + (b.cash, Side::Debit, "50.00"), + (b.rev, Side::Credit, "50.00"), + ], + ); + let tb_before = serde_json::to_value(trial_balance(&b.l)).unwrap(); + let posted_before: Vec<_> = + b.l.posted_entries() + .map(|e| serde_json::to_value(e).unwrap()) + .collect(); + b.l.mark_cleared(dep, 0, true).unwrap(); + b.l.mark_cleared(late, 0, true).unwrap(); + assert!(b.l.is_cleared(dep, 0)); + assert!(!b.l.is_cleared(chk, 1)); + + // Marking changes no amount and no report. + assert_eq!( + serde_json::to_value(trial_balance(&b.l)).unwrap(), + tb_before + ); + let posted_after: Vec<_> = + b.l.posted_entries() + .map(|e| serde_json::to_value(e).unwrap()) + .collect(); + assert_eq!(posted_after, posted_before); + + let r = + b.l.reconciliation_report(b.cash, d(2026, 1, 31), dec("1000.00")) + .unwrap(); + assert_eq!(r.account, b.cash); + assert_eq!(r.book_balance, dec("850.00")); + assert_eq!(r.cleared_balance, dec("1000.00")); + assert_eq!(r.statement_balance, dec("1000.00")); + assert_eq!(r.difference, Decimal::ZERO); + assert_eq!(r.uncleared_lines.len(), 1); + let u = &r.uncleared_lines[0]; + assert_eq!((u.entry_id, u.line_index), (chk, 1)); + assert_eq!(u.date, d(2026, 1, 10)); + assert_eq!(u.side, Side::Credit); + assert_eq!(u.amount, dec("200.00")); + + // Clear the check: cleared 800.00 against 1000.00 is 200.00 short. + b.l.mark_cleared(chk, 1, true).unwrap(); + let r = + b.l.reconciliation_report(b.cash, d(2026, 1, 31), dec("1000.00")) + .unwrap(); + assert_eq!(r.cleared_balance, dec("800.00")); + assert_eq!(r.difference, dec("-200.00")); + assert!(r.uncleared_lines.is_empty()); + + // Un-clearing is allowed and undoes it. + b.l.mark_cleared(chk, 1, false).unwrap(); + assert!(!b.l.is_cleared(chk, 1)); + let r = + b.l.reconciliation_report(b.cash, d(2026, 2, 28), dec("1050.00")) + .unwrap(); + assert_eq!(r.cleared_balance, dec("1050.00")); + assert_eq!(r.difference, Decimal::ZERO); + assert_eq!(r.uncleared_lines.len(), 1); +} + +/// Oracle: mark_cleared SCENARIO 1: unknown entry (UnknownEntry), draft +/// (ClearedOnDraft), or no such line (LineIndexOutOfRange) is refused. +#[test] +fn only_a_posted_line_can_clear() { + let mut b = books(); + let e = post( + &mut b.l, + d(2026, 1, 5), + &[ + (b.cash, Side::Debit, "10.00"), + (b.cap_a, Side::Credit, "10.00"), + ], + ); + let draft = b.l.try_create_entry(d(2026, 1, 6), "draft").unwrap(); + b.l.add_line(draft, b.cash, Side::Debit, usd("1.00")) + .unwrap(); + let before = observe(&b.l); + assert_eq!( + b.l.mark_cleared(5_555, 0, true), + Err(LedgerError::UnknownEntry(5_555)) + ); + assert_eq!( + b.l.mark_cleared(draft, 0, true), + Err(LedgerError::ClearedOnDraft(draft)) + ); + assert_eq!( + b.l.mark_cleared(e, 2, true), + Err(LedgerError::LineIndexOutOfRange(2, e)) + ); + assert_eq!(observe(&b.l), before); +} + +// --------------------------------------------------------------------------- +// account_balance +// --------------------------------------------------------------------------- + +/// Oracle: account_balance INVARIANT 1 / B-5 and trial balance SCENARIO 2: +/// asset debited 100 and credited 30 is 70; credit-normal accounts read +/// credits minus debits; a contra balance is negative. +#[test] +fn balances_read_on_the_normal_side() { + let mut b = books(); + post( + &mut b.l, + d(2026, 1, 1), + &[ + (b.cash, Side::Debit, "100.00"), + (b.rev, Side::Credit, "100.00"), + ], + ); + post( + &mut b.l, + d(2026, 1, 2), + &[ + (b.exp, Side::Debit, "30.00"), + (b.cash, Side::Credit, "30.00"), + ], + ); + post( + &mut b.l, + d(2026, 1, 3), + &[ + (b.card, Side::Debit, "5.00"), + (b.exp, Side::Credit, "45.00"), + (b.cash, Side::Debit, "40.00"), + ], + ); + assert_eq!(b.l.account_balance(b.cash).unwrap(), dec("110.00")); + assert_eq!(b.l.account_balance(b.rev).unwrap(), dec("100.00")); + assert_eq!(b.l.account_balance(b.exp).unwrap(), dec("-15.00")); + assert_eq!(b.l.account_balance(b.card).unwrap(), dec("-5.00")); + assert_eq!(b.l.account_balance(b.cap_a).unwrap(), Decimal::ZERO); + let tb = trial_balance_as_of(&b.l, d(2026, 1, 2)); + let cash = tb.lines.iter().find(|x| x.code == "1000").unwrap(); + assert_eq!((cash.debit, cash.credit), (dec("70.00"), Decimal::ZERO)); +} + +/// Oracle: account_balance INVARIANT 2: never negative zero, for every type, +/// untouched or netted to zero. +#[test] +fn a_zero_balance_is_never_negative_zero() { + let mut b = books(); + for a in [b.cash, b.card, b.cap_a, b.rev, b.exp] { + let z = b.l.account_balance(a).unwrap(); + assert!(z.is_zero() && !z.is_sign_negative(), "untouched {a}: {z:?}"); + } + post( + &mut b.l, + d(2026, 1, 1), + &[(b.card, Side::Debit, "5.00"), (b.rev, Side::Credit, "5.00")], + ); + post( + &mut b.l, + d(2026, 1, 2), + &[(b.rev, Side::Debit, "5.00"), (b.card, Side::Credit, "5.00")], + ); + for a in [b.card, b.rev] { + let z = b.l.account_balance(a).unwrap(); + assert!(z.is_zero() && !z.is_sign_negative(), "netted {a}: {z:?}"); + } +} + +/// Oracle: account_balance SCENARIO 1: an unknown account is refused. +/// ASSUMED: with UnknownAccount (the error the catalog names for it). +#[test] +fn the_balance_of_an_unknown_account_is_refused() { + let b = books(); + assert_eq!( + b.l.account_balance(123_456), + Err(LedgerError::UnknownAccount(123_456)) + ); +} + +// --------------------------------------------------------------------------- +// set_line_class / set_entry_vendor +// --------------------------------------------------------------------------- + +/// Oracle: set_line_class GOAL: a draft line is tagged (and cleared with +/// None); SCENARIO 1 (B-8): a posted line NEVER changes; SCENARIO 2: an +/// unknown line or class is refused. +#[test] +fn only_draft_lines_take_a_class() { + let mut b = books(); + let retail = b.l.add_class("Retail").unwrap(); + let e = b.l.try_create_entry(d(2026, 1, 5), "x").unwrap(); + b.l.add_line(e, b.exp, Side::Debit, usd("9.00")).unwrap(); + b.l.add_line(e, b.cash, Side::Credit, usd("9.00")).unwrap(); + b.l.set_line_class(e, 0, Some(retail)).unwrap(); + assert_eq!(b.l.get_entry(e).unwrap().lines()[0].class_id, Some(retail)); + assert_eq!(b.l.get_entry(e).unwrap().lines()[1].class_id, None); + b.l.set_line_class(e, 0, None).unwrap(); + assert_eq!(b.l.get_entry(e).unwrap().lines()[0].class_id, None); + + let before = observe(&b.l); + assert!(b.l.set_line_class(e, 2, Some(retail)).is_err()); + assert_eq!( + b.l.set_line_class(e, 0, Some(retail + 100)), + Err(LedgerError::UnknownClass(retail + 100)) + ); + assert_eq!( + b.l.set_line_class(8_888, 0, Some(retail)), + Err(LedgerError::UnknownEntry(8_888)) + ); + assert_eq!(observe(&b.l), before); + + b.l.set_line_class(e, 1, Some(retail)).unwrap(); + b.l.post_entry(e).unwrap(); + let before = observe(&b.l); + assert!(b.l.set_line_class(e, 0, Some(retail)).is_err()); + assert!(b.l.set_line_class(e, 1, None).is_err()); + assert_eq!(observe(&b.l), before); + assert_eq!(b.l.get_entry(e).unwrap().lines()[1].class_id, Some(retail)); +} + +/// Oracle: set_entry_vendor SCENARIO 2: tagging a posted entry is allowed +/// and changes no amount; clearing with None removes the tag. +#[test] +fn a_vendor_tag_changes_no_amount() { + let mut b = books(); + let v = b.l.add_vendor("Landlord LLC").unwrap(); + let e = post( + &mut b.l, + d(2026, 1, 5), + &[ + (b.exp, Side::Debit, "2500.00"), + (b.cash, Side::Credit, "2500.00"), + ], + ); + let entry_then = serde_json::to_value(b.l.get_entry(e)).unwrap(); + let tb_then = serde_json::to_value(trial_balance(&b.l)).unwrap(); + b.l.set_entry_vendor(e, Some(v)).unwrap(); + assert_eq!(b.l.entry_vendor(e), Some(v)); + assert_eq!(serde_json::to_value(b.l.get_entry(e)).unwrap(), entry_then); + assert_eq!(serde_json::to_value(trial_balance(&b.l)).unwrap(), tb_then); + b.l.set_entry_vendor(e, None).unwrap(); + assert_eq!(b.l.entry_vendor(e), None); + + let draft = b.l.try_create_entry(d(2026, 1, 6), "draft").unwrap(); + b.l.set_entry_vendor(draft, Some(v)).unwrap(); + assert_eq!(b.l.entry_vendor(draft), Some(v)); +} + +/// Oracle: set_entry_vendor SCENARIO 1: unknown entry (UnknownEntry) or +/// vendor (UnknownVendor) is refused. +#[test] +fn a_vendor_tag_needs_a_real_entry_and_vendor() { + let mut b = books(); + let v = b.l.add_vendor("Landlord LLC").unwrap(); + let e = post( + &mut b.l, + d(2026, 1, 5), + &[(b.exp, Side::Debit, "1.00"), (b.cash, Side::Credit, "1.00")], + ); + let before = observe(&b.l); + assert_eq!( + b.l.set_entry_vendor(6_666, Some(v)), + Err(LedgerError::UnknownEntry(6_666)) + ); + assert_eq!( + b.l.set_entry_vendor(e, Some(v + 50)), + Err(LedgerError::UnknownVendor(v + 50)) + ); + assert_eq!(observe(&b.l), before); +} + +// --------------------------------------------------------------------------- +// control accounts +// --------------------------------------------------------------------------- + +fn one_line(amount: &str) -> Vec { + vec![DocumentLine { + description: "consulting".into(), + amount: dec(amount), + }] +} + +/// Oracle: ControlAccount doc + create_invoice INVARIANT 3: receivables +/// (1200) of the wrong type is refused (ControlAccountMismatch, expected +/// Asset), never posted to, the books unchanged. +#[test] +fn an_invoice_refuses_a_receivables_account_of_the_wrong_type() { + let mut b = books(); + b.l.add_account("1200", "Car Loan", AccountType::Liability, None, false) + .unwrap(); + let c = b.l.add_customer("Acme").unwrap(); + let before = observe(&b.l); + let r = + b.l.create_invoice(c, d(2026, 1, 5), d(2026, 2, 5), one_line("100.00")); + assert!( + matches!( + &r, + Err(LedgerError::ControlAccountMismatch { + code, + expected: AccountType::Asset, + found: AccountType::Liability, + placeholder: false, + .. + }) if code == "1200" + ), + "{r:?}" + ); + assert_eq!(observe(&b.l), before); +} + +/// Oracle: ControlAccount doc: revenue (4000) that exists as an expense is +/// refused (expected Revenue). +#[test] +fn an_invoice_refuses_a_revenue_account_of_the_wrong_type() { + let mut l = Ledger::new(Currency::USD); + l.add_account("4000", "Contractors", AccountType::Expense, None, false) + .unwrap(); + let c = l.add_customer("Acme").unwrap(); + let before = observe(&l); + let r = l.create_invoice(c, d(2026, 1, 5), d(2026, 2, 5), one_line("100.00")); + assert!( + matches!( + &r, + Err(LedgerError::ControlAccountMismatch { + code, + expected: AccountType::Revenue, + found: AccountType::Expense, + .. + }) if code == "4000" + ), + "{r:?}" + ); + assert_eq!(observe(&l), before); +} + +/// Oracle: ControlAccount doc: payables (2000) as an asset, or general +/// expenses (6999) as a placeholder expense, is refused by create_bill. +#[test] +fn a_bill_refuses_control_accounts_of_the_wrong_kind() { + let mut l = Ledger::new(Currency::USD); + l.add_account("2000", "Deposits", AccountType::Asset, None, false) + .unwrap(); + let v = l.add_vendor("Supplier").unwrap(); + let before = observe(&l); + let r = l.create_bill(v, d(2026, 1, 5), d(2026, 2, 5), one_line("80.00")); + assert!( + matches!( + &r, + Err(LedgerError::ControlAccountMismatch { + code, + expected: AccountType::Liability, + found: AccountType::Asset, + .. + }) if code == "2000" + ), + "{r:?}" + ); + assert_eq!(observe(&l), before); + + let mut l = Ledger::new(Currency::USD); + l.add_account("6999", "Expenses", AccountType::Expense, None, true) + .unwrap(); + let v = l.add_vendor("Supplier").unwrap(); + let before = observe(&l); + let r = l.create_bill(v, d(2026, 1, 5), d(2026, 2, 5), one_line("80.00")); + assert!( + matches!( + &r, + Err(LedgerError::ControlAccountMismatch { + code, + placeholder: true, + found: AccountType::Expense, + .. + }) if code == "6999" + ), + "{r:?}" + ); + assert_eq!(observe(&l), before); +} + +/// Oracle: create_invoice INVARIANT 1: an existing control account of the +/// right type is used (Dr 1200 / Cr 4000 for 100.00), not replaced. +#[test] +fn an_invoice_posts_to_existing_control_accounts_of_the_right_type() { + let mut b = books(); + let ar = + b.l.add_account("1200", "Receivables", AccountType::Asset, None, false) + .unwrap(); + let c = b.l.add_customer("Acme").unwrap(); + let n = b.l.chart().len(); + b.l.create_invoice(c, d(2026, 1, 5), d(2026, 2, 5), one_line("100.00")) + .unwrap(); + assert_eq!(b.l.chart().len(), n); + assert_eq!(b.l.account_balance(ar).unwrap(), dec("100.00")); + assert_eq!(b.l.account_balance(b.rev).unwrap(), dec("100.00")); +} + +// --------------------------------------------------------------------------- +// properties +// --------------------------------------------------------------------------- + +/// One random transaction: (day of 2026 from Jan 1, 0..90), debit account +/// index, credit account index, cents. +type Tx = (u32, usize, usize, i64); + +fn txs() -> impl Strategy> { + prop::collection::vec((0u32..90, 0usize..7, 0usize..7, 1i64..1_000_000), 0..20).prop_map(|v| { + v.into_iter() + .map(|(day, dr, cr, c)| (day, dr, if cr == dr { (cr + 1) % 7 } else { cr }, c)) + .collect() + }) +} + +fn postable(b: &Books) -> [AccountId; 7] { + [b.cash, b.card, b.cap_a, b.cap_b, b.dist_a, b.rev, b.exp] +} + +fn day(n: u32) -> NaiveDate { + d(2026, 1, 1) + chrono::Days::new(u64::from(n)) +} + +fn cents(c: i64) -> Decimal { + Decimal::new(c, 2) +} + +fn post_tx(b: &mut Books, t: &Tx) -> EntryId { + let accts = postable(b); + let id = b.l.try_create_entry(day(t.0), "random").unwrap(); + b.l.add_line( + id, + accts[t.1], + Side::Debit, + Money::new(cents(t.3), Currency::USD), + ) + .unwrap(); + b.l.add_line( + id, + accts[t.2], + Side::Credit, + Money::new(cents(t.3), Currency::USD), + ) + .unwrap(); + b.l.post_entry(id).unwrap(); + id +} + +/// The test's own model: signed movement (debit +) per account index, for +/// transactions dated on or before `through`. +fn model(t: &[Tx], through: u32) -> [Decimal; 7] { + let mut m = [Decimal::ZERO; 7]; + for (dd, dr, cr, c) in t { + if *dd <= through { + m[*dr] += cents(*c); + m[*cr] -= cents(*c); + } + } + m +} + +const NORMAL_DEBIT: [bool; 7] = [true, false, false, false, false, false, true]; + +/// Fixture for the refusal law: random books with a closed January, a +/// reversed entry, a posted closing entry, an unbalanced February draft, a +/// draft dated in the closed January, a vendor, a class, a customer, and a +/// wrong-type 1200. +struct Fix { + b: Books, + jan: PeriodId, + feb: PeriodId, + mar: PeriodId, + posted: EntryId, + reversed: EntryId, + closing: EntryId, + feb_draft: EntryId, + jan_draft: EntryId, + class: u32, + customer: u64, +} + +fn fixture(t: &[Tx]) -> Fix { + let mut b = books(); + let reversed = post( + &mut b.l, + d(2026, 1, 15), + &[ + (b.cash, Side::Debit, "100.00"), + (b.rev, Side::Credit, "100.00"), + ], + ); + let mut last = reversed; + for x in t { + last = post_tx(&mut b, x); + } + let jan = + b.l.create_period("Jan", d(2026, 1, 1), d(2026, 1, 31)) + .unwrap(); + let feb = + b.l.create_period("Feb", d(2026, 2, 1), d(2026, 2, 28)) + .unwrap(); + let mar = + b.l.create_period("Mar", d(2026, 3, 1), d(2026, 3, 31)) + .unwrap(); + b.l.reverse_entry(reversed, d(2026, 3, 1), "reverse") + .unwrap(); + // January still has at least the expense or revenue of the fixed entry + // in the original form plus whatever random entries landed there; add a + // sure January expense so the close always has something to sweep. + post( + &mut b.l, + d(2026, 1, 20), + &[(b.exp, Side::Debit, "7.00"), (b.cash, Side::Credit, "7.00")], + ); + let to = members(&[(b.cap_a, "0.5", Some(b.dist_a)), (b.cap_b, "0.5", None)]); + let closing = + b.l.close_period_to(jan, &to) + .unwrap() + .expect("expense to close"); + let feb_draft = b.l.try_create_entry(d(2026, 2, 10), "unbalanced").unwrap(); + b.l.add_line(feb_draft, b.cash, Side::Debit, usd("10.00")) + .unwrap(); + b.l.add_line(feb_draft, b.rev, Side::Credit, usd("5.00")) + .unwrap(); + let jan_draft = b.l.create_entry(d(2026, 1, 25), "backdated"); + let _ = b.l.add_line(jan_draft, b.exp, Side::Debit, usd("3.00")); + let _ = b.l.add_line(jan_draft, b.cash, Side::Credit, usd("3.00")); + b.l.add_vendor("Vendor").unwrap(); + let class = b.l.add_class("Retail").unwrap(); + let customer = b.l.add_customer("Acme").unwrap(); + b.l.add_account("1200", "Car Loan", AccountType::Liability, None, false) + .unwrap(); + Fix { + b, + jan, + feb, + mar, + posted: last, + reversed, + closing, + feb_draft, + jan_draft, + class, + customer, + } +} + +const INVALID_COMMANDS: usize = 30; + +/// Run invalid command `k` against the fixture; it must be refused. +fn invalid_command(f: &mut Fix, k: usize) -> Result<(), LedgerError> { + let Fix { b, .. } = f; + let l = &mut b.l; + let ghost: u64 = 9_000_000; + match k { + 0 => l.post_entry(ghost), + 1 => l.post_entry(f.posted), + 2 => l.post_entry(f.feb_draft), + 3 => l.add_line(f.posted, b.exp, Side::Debit, usd("1.00")), + 4 => l.add_line(f.feb_draft, ghost, Side::Debit, usd("1.00")), + 5 => l.add_line(f.feb_draft, b.header, Side::Debit, usd("1.00")), + 6 => l.add_line( + f.feb_draft, + b.exp, + Side::Debit, + Money::new(dec("1.00"), Currency::GBP), + ), + 7 => l + .add_account("1000", "dup", AccountType::Asset, None, false) + .map(|_| ()), + 8 => l + .add_account("", "empty", AccountType::Asset, None, false) + .map(|_| ()), + 9 => l + .add_account("1999", "orphan", AccountType::Asset, Some(ghost), false) + .map(|_| ()), + 10 => l + .add_account( + "1999", + "mismatch", + AccountType::Expense, + Some(b.header), + false, + ) + .map(|_| ()), + 11 => l + .reverse_entry(f.reversed, d(2026, 3, 15), "again") + .map(|_| ()), + 12 => l + .reverse_entry(f.closing, d(2026, 3, 15), "reopen") + .map(|_| ()), + 13 => l + .reverse_entry(f.feb_draft, d(2026, 3, 15), "draft") + .map(|_| ()), + 14 => l + .reverse_entry(f.posted, d(2026, 1, 31), "into closed") + .map(|_| ()), + 15 => l + .create_period("overlap", d(2026, 1, 15), d(2026, 2, 15)) + .map(|_| ()), + 16 => l + .create_period("backwards", d(2026, 5, 2), d(2026, 5, 1)) + .map(|_| ()), + 17 => l.try_create_entry(d(2026, 1, 1), "closed").map(|_| ()), + 18 => l.close_period(f.jan).map(|_| ()), + 19 => l.close_period(ghost).map(|_| ()), + 20 => l.close_period(f.feb).map(|_| ()), + 21 => { + let to = members(&[(b.cap_a, "0.5", None), (b.cap_b, "0.4", None)]); + l.close_period_to(f.mar, &to).map(|_| ()) + } + 22 => l.mark_cleared(f.feb_draft, 0, true), + 23 => l.mark_cleared(f.posted, 2, true), + 24 => l.set_line_class(f.posted, 0, Some(f.class)), + 25 => l.set_line_class(f.feb_draft, 0, Some(f.class + 1)), + 26 => l.set_entry_vendor(f.posted, Some(ghost)), + 27 => l.add_line_fx( + f.feb_draft, + b.exp, + Side::Debit, + Currency::EUR, + dec("1"), + dec("0"), + ), + 28 => l.post_entry(f.jan_draft), + 29 => l + .create_invoice(f.customer, d(2026, 3, 5), d(2026, 4, 5), one_line("10.00")) + .map(|_| ()), + _ => unreachable!(), + } +} + +proptest! { + #![proptest_config(ProptestConfig::with_cases(64))] + + /// Oracle: post_entry INVARIANT 3 (and the same clause on every + /// command): for random books and a random invalid command, the command + /// is refused and every observable — entries, log, chart, periods, next + /// ids, trial balance, tags, cleared flags — is identical before and + /// after. + #[test] + fn a_refusal_leaves_the_books_unchanged(t in txs(), k in 0..INVALID_COMMANDS) { + let mut f = fixture(&t); + // The account-id allocator is checked by the ignored + // `a_refused_account_never_uses_up_an_id` (a known disagreement). + let before = observe_but_account_ids(&f.b.l); + let r = invalid_command(&mut f, k); + prop_assert!(r.is_err(), "command {} was accepted", k); + prop_assert_eq!(observe_but_account_ids(&f.b.l), before, "command {} changed the books ({:?})", k, r); + } + + /// Oracle: B-1/B-6 and post_entry INVARIANT 2 against the test's own + /// model: every balanced random entry posts, the log is exactly the + /// posting sequence, earlier entries never change, every balance equals + /// the model on its normal side, and the trial balance balances. + #[test] + fn posted_books_match_the_model(t in txs()) { + let mut b = books(); + let mut ids = Vec::new(); + let mut snaps = Vec::new(); + for x in &t { + let id = post_tx(&mut b, x); + for (i, s) in ids.iter().zip(&snaps) { + prop_assert_eq!(&serde_json::to_value(b.l.get_entry(*i)).unwrap(), s); + } + ids.push(id); + snaps.push(serde_json::to_value(b.l.get_entry(id)).unwrap()); + } + let order: Vec<_> = b.l.posted_entries().map(|e| e.id()).collect(); + prop_assert_eq!(order, ids); + let m = model(&t, u32::MAX); + for (i, a) in postable(&b).iter().enumerate() { + let want = if NORMAL_DEBIT[i] { m[i] } else { -m[i] }; + prop_assert_eq!(b.l.account_balance(*a).unwrap(), want); + } + prop_assert!(trial_balance(&b.l).balanced()); + } + + /// Oracle: close INVARIANTS 1-4 and B-13 against the test's own model, + /// for random books closed 50/50 to members at Jan 31: revenue and + /// expense are zero as of Jan 31; what remains is exactly February and + /// March; the members' credits sum to January's net with A taking the + /// odd cent (tie to the earlier member); A's distributions are swept; + /// no earlier entry changes; the closing entry balances and is dated + /// Jan 31. + #[test] + fn a_close_matches_the_model(t in txs()) { + let mut b = books(); + for x in &t { + post_tx(&mut b, x); + } + let jan = b.l.create_period("Jan", d(2026, 1, 1), d(2026, 1, 31)).unwrap(); + let before: Vec<_> = b.l.posted_entries().map(|e| serde_json::to_value(e).unwrap()).collect(); + let to = members(&[(b.cap_a, "0.5", Some(b.dist_a)), (b.cap_b, "0.5", None)]); + let preview = b.l.preview_close_period_to(jan, &to).unwrap(); + let c = b.l.close_period_to(jan, &to).unwrap(); + prop_assert_eq!(c, preview); + + let jan_m = model(&t, 30); + let all_m = model(&t, u32::MAX); + // indices: 2 cap_a, 3 cap_b, 4 dist_a, 5 rev, 6 exp + let net = -(jan_m[5] + jan_m[6]); + let something = !jan_m[4].is_zero() || !jan_m[5].is_zero() || !jan_m[6].is_zero(); + prop_assert_eq!(c.is_some(), something); + + let after: Vec<_> = b.l.posted_entries().take(before.len()).map(|e| serde_json::to_value(e).unwrap()).collect(); + prop_assert_eq!(after, before); + + prop_assert!(!tb_has_code(&b.l, d(2026, 1, 31), "4000")); + prop_assert!(!tb_has_code(&b.l, d(2026, 1, 31), "6000")); + prop_assert!(!tb_has_code(&b.l, d(2026, 1, 31), "3002")); + prop_assert_eq!(b.l.account_balance(b.rev).unwrap(), -(all_m[5] - jan_m[5])); + prop_assert_eq!(b.l.account_balance(b.exp).unwrap(), all_m[6] - jan_m[6]); + prop_assert_eq!(b.l.account_balance(b.dist_a).unwrap(), -(all_m[4] - jan_m[4])); + + // 50/50 by largest remainder: equal remainders, so the first member + // takes the odd cent; a loss is the negated gain. + let n_cents = (net * Decimal::from(100)).abs(); + let half_down = (n_cents / Decimal::from(2)).floor(); + let a_cents = n_cents - half_down; + let sign = if net < Decimal::ZERO { -Decimal::ONE } else { Decimal::ONE }; + let share_a = sign * a_cents / Decimal::from(100); + let share_b = sign * half_down / Decimal::from(100); + prop_assert_eq!(share_a + share_b, net); + prop_assert_eq!(b.l.account_balance(b.cap_a).unwrap(), -all_m[2] + share_a - jan_m[4]); + prop_assert_eq!(b.l.account_balance(b.cap_b).unwrap(), -all_m[3] + share_b); + + if let Some(c) = c { + let ce = b.l.get_entry(c).unwrap(); + prop_assert_eq!(ce.date(), d(2026, 1, 31)); + let (dr, cr) = lines_of(&b.l, c).into_iter().fold((Decimal::ZERO, Decimal::ZERO), |(dr, cr), (_, s, a)| { + if s == Side::Debit { (dr + a, cr) } else { (dr, cr + a) } + }); + prop_assert_eq!(dr, cr); + // Equity is credited with exactly the net (the members' capital + // movement, apart from the distribution sweep). + let cap_net = -(entry_movement(&b.l, c, b.cap_a) + entry_movement(&b.l, c, b.cap_b)) + jan_m[4]; + prop_assert_eq!(cap_net, net); + } + prop_assert!(trial_balance(&b.l).balanced()); + } + + /// Oracle: check_reversal INVARIANTS 1-2: it never mutates, and it + /// accepts exactly what reverse_entry accepts (same refusal otherwise), + /// for random targets and dates around a closed January. + #[test] + fn check_reversal_agrees_with_reverse_entry(t in txs(), target in 0u64..40, dd in 0u32..120) { + let f = fixture(&t); + let before = observe(&f.b.l); + let check = f.b.l.check_reversal(target, day(dd)); + prop_assert_eq!(observe(&f.b.l), before.clone()); + let mut l2 = f.b.l.clone(); + let act = l2.reverse_entry(target, day(dd), "r"); + match (&check, &act) { + (Ok(()), Ok(_)) => {} + (Err(a), Err(b2)) => { + prop_assert_eq!(a, b2); + prop_assert_eq!(observe(&l2), before); + } + _ => prop_assert!(false, "check {:?} but reverse {:?}", check, act), + } + } + + /// Oracle: preview_close_period_to doc: pure, and it succeeds exactly + /// when close_period_to does, naming the same entry id (or None). + #[test] + fn a_close_preview_agrees_with_the_close(t in txs(), which in 0usize..4, target in 0usize..4) { + let f = fixture(&t); + let periods = [f.jan, f.feb, f.mar, 9_999]; + let tos = [ + members(&[(f.b.cap_a, "0.5", None), (f.b.cap_b, "0.5", None)]), + members(&[(f.b.cap_a, "0.7", None), (f.b.cap_b, "0.4", None)]), + CloseTo::RetainedEarnings { sweep: vec![] }, + CloseTo::Account { into: f.b.cap_a, sweep: vec![f.b.dist_a] }, + ]; + let before = observe(&f.b.l); + let preview = f.b.l.preview_close_period_to(periods[which], &tos[target]); + prop_assert_eq!(observe(&f.b.l), before.clone()); + let mut l2 = f.b.l.clone(); + let act = l2.close_period_to(periods[which], &tos[target]); + prop_assert_eq!(&preview, &act); + if act.is_err() { + prop_assert_eq!(observe(&l2), before); + } + } + + /// Oracle: JournalEntry::validate is the gate (post_entry INVARIANT 1): + /// for a random two-line draft (possibly unbalanced, zero, or negative), + /// post succeeds exactly when validate does, with the same reason. + #[test] + fn post_accepts_exactly_what_validate_accepts(dr in -3i64..300, cr in -3i64..300) { + let mut b = books(); + let e = b.l.try_create_entry(d(2026, 3, 1), "x").unwrap(); + // replay_add_line skips the account check only, so amounts that + // add_line might refuse early still reach validate. + let r1 = b.l.replay_add_line(e, b.exp, Side::Debit, Money::new(cents(dr), Currency::USD)); + let r2 = b.l.replay_add_line(e, b.cash, Side::Credit, Money::new(cents(cr), Currency::USD)); + prop_assume!(r1.is_ok() && r2.is_ok()); + let v = b.l.get_entry(e).unwrap().validate(b.l.chart()); + let expected = if dr == 0 || cr == 0 { + Err(LedgerError::ZeroAmountLine) + } else if dr < 0 || cr < 0 { + Err(LedgerError::NegativeAmountLine) + } else if dr != cr { + Err(LedgerError::UnbalancedEntry { debits: cents(dr), credits: cents(cr) }) + } else { + Ok(()) + }; + // Zero and negative may be reported in either order when both occur. + if (dr == 0 || cr == 0) && (dr < 0 || cr < 0) { + prop_assert!(matches!(v, Err(LedgerError::ZeroAmountLine | LedgerError::NegativeAmountLine))); + } else { + prop_assert_eq!(&v, &expected); + } + prop_assert_eq!(b.l.post_entry(e), v); + } +} diff --git a/crates/ledger-core/tests/spec_reports.rs b/crates/ledger-core/tests/spec_reports.rs new file mode 100644 index 0000000..1e3e08c --- /dev/null +++ b/crates/ledger-core/tests/spec_reports.rs @@ -0,0 +1,2535 @@ +//! Spec-derived tests for the reports (`reports.rs`). +//! +//! Written from the spec blocks (GOAL / SOURCE / INVARIANTS / SCENARIOS) and +//! `docs/spec/rules.md` / `docs/requirements.md` only, never from the +//! implementation. Every expected value is either a literal computed by hand +//! from the spec or a law (property) that pins the answer without calling the +//! function under test to compute it. Where the random-book tests need a +//! reference value they use an independent model: the plain sum of the +//! generated lines, which is what every report is defined over. + +use std::collections::{BTreeMap, BTreeSet}; + +use chrono::{Days, NaiveDate}; +use ledger_core::reports::{profit_and_loss, profit_and_loss_by_class, snapshot, trial_balance}; +use ledger_core::{ + AccountId, AccountType, BalanceSheetStatement, CashFlowParams, CashFlowStatement, ClassId, + CloseTo, Currency, EntryId, GeneralLedger, Ledger, MemberCapital, MemberClose, + MemberEquityColumn, MembersEquityStatement, Money, NewAsset, ProfitAndLossStatement, Side, + StatementLine, TrialBalance, balance_sheet_as_of, cash_flow, general_ledger, members_equity, + profit_and_loss_for_period, trial_balance_as_of, +}; +use proptest::prelude::*; +use rust_decimal::Decimal; +use serde_json::Value; + +const USD: Currency = Currency::USD; +const DR: Side = Side::Debit; +const CR: Side = Side::Credit; + +fn d(y: i32, m: u32, day: u32) -> NaiveDate { + NaiveDate::from_ymd_opt(y, m, day).unwrap() +} + +fn c(cents: i64) -> Decimal { + Decimal::new(cents, 2) +} + +fn usd(cents: i64) -> Money { + Money::new(c(cents), USD) +} + +fn post(l: &mut Ledger, date: NaiveDate, desc: &str, lines: &[(AccountId, Side, i64)]) -> EntryId { + let e = l.create_entry(date, desc); + for &(a, s, cents) in lines { + l.add_line(e, a, s, usd(cents)).unwrap(); + } + l.post_entry(e).unwrap(); + e +} + +fn draft(l: &mut Ledger, date: NaiveDate, lines: &[(AccountId, Side, i64)]) -> EntryId { + let e = l.create_entry(date, "draft"); + for &(a, s, cents) in lines { + l.add_line(e, a, s, usd(cents)).unwrap(); + } + e +} + +fn tb_lines(tb: &TrialBalance) -> Vec<(String, Decimal, Decimal)> { + tb.lines + .iter() + .map(|l| (l.code.clone(), l.debit, l.credit)) + .collect() +} + +fn sl(lines: &[StatementLine]) -> Vec<(String, Decimal)> { + lines.iter().map(|l| (l.code.clone(), l.amount)).collect() +} + +fn sl_nonzero(lines: &[StatementLine]) -> Vec<(String, Decimal)> { + lines + .iter() + .filter(|l| !l.amount.is_zero()) + .map(|l| (l.code.clone(), l.amount)) + .collect() +} + +fn s(code: &str, cents: i64) -> (String, Decimal) { + (code.to_string(), c(cents)) +} + +fn debit_normal(t: AccountType) -> bool { + matches!(t, AccountType::Asset | AccountType::Expense) +} + +type PnlKey = (Lines, Lines, Decimal, Decimal); + +fn pnl_key(p: &ProfitAndLossStatement) -> PnlKey { + ( + sl(&p.revenue), + sl(&p.expenses), + p.total_revenue, + p.total_expenses, + ) +} + +fn bs_key(b: &BalanceSheetStatement) -> String { + format!( + "{:?}|{:?}|{:?}|{}|{}|{}|{}", + sl(&b.assets), + sl(&b.liabilities), + sl(&b.equity), + b.current_earnings, + b.total_assets, + b.total_liabilities, + b.total_equity + ) +} + +fn gl_key(g: &GeneralLedger) -> String { + format!("{:?}", g.accounts) +} + +fn cf_key(cf: &CashFlowStatement) -> String { + format!("{cf:?}") +} + +// --------------------------------------------------------------------------- +// A fixed example company for the scenario tests. +// --------------------------------------------------------------------------- + +struct Co { + l: Ledger, + cash: AccountId, + ar: AccountId, + equip: AccountId, + accum: AccountId, + ap: AccountId, + loan: AccountId, + cap_a: AccountId, + cap_b: AccountId, + other_eq: AccountId, + rev: AccountId, + rev2: AccountId, + gain: AccountId, + exp: AccountId, + exp2: AccountId, + depr: AccountId, +} + +fn company() -> Co { + use AccountType::*; + let mut l = Ledger::new(USD); + let header = l + .add_account("6900", "Operating Expenses", Expense, None, true) + .unwrap(); + let cash = l.add_account("1000", "Cash", Asset, None, false).unwrap(); + let ar = l + .add_account("1100", "Accounts Receivable", Asset, None, false) + .unwrap(); + let equip = l + .add_account("1500", "Equipment", Asset, None, false) + .unwrap(); + let accum = l + .add_account("1510", "Accumulated Depreciation", Asset, None, false) + .unwrap(); + let ap = l + .add_account("2000", "Accounts Payable", Liability, None, false) + .unwrap(); + let loan = l + .add_account("2500", "Loan Payable", Liability, None, false) + .unwrap(); + let cap_a = l + .add_account("3000", "Capital A", Equity, None, false) + .unwrap(); + let cap_b = l + .add_account("3001", "Capital B", Equity, None, false) + .unwrap(); + let other_eq = l + .add_account("3500", "Other Equity", Equity, None, false) + .unwrap(); + let rev = l + .add_account("4000", "Sales", Revenue, None, false) + .unwrap(); + let rev2 = l + .add_account("4100", "Services", Revenue, None, false) + .unwrap(); + let gain = l + .add_account("4900", "Gain on Disposal", Revenue, None, false) + .unwrap(); + let exp = l + .add_account("6000", "Supplies", Expense, Some(header), false) + .unwrap(); + let exp2 = l + .add_account("6100", "Rent", Expense, Some(header), false) + .unwrap(); + let depr = l + .add_account("6200", "Depreciation Expense", Expense, None, false) + .unwrap(); + Co { + l, + cash, + ar, + equip, + accum, + ap, + loan, + cap_a, + cap_b, + other_eq, + rev, + rev2, + gain, + exp, + exp2, + depr, + } +} + +fn members_50_50(co: &Co) -> Vec { + vec![ + MemberCapital { + name: "A".into(), + capital_account: co.cap_a, + distribution_account: None, + share: c(50), + }, + MemberCapital { + name: "B".into(), + capital_account: co.cap_b, + distribution_account: None, + share: c(50), + }, + ] +} + +fn col(name: &str, b: i64, ct: i64, ni: i64, di: i64, o: i64, e: i64) -> MemberEquityColumn { + MemberEquityColumn { + name: name.into(), + beginning: c(b), + contributions: c(ct), + net_income: c(ni), + distributions: c(di), + other: c(o), + ending: c(e), + } +} + +// =========================================================================== +// trial_balance / trial_balance_as_of — examples +// =========================================================================== + +/// Oracle: TB SCENARIO 1 — no entries posted by `as_of` gives an empty TB +/// with zero totals (also when the only entries are dated after `as_of`). +#[test] +fn tb_as_of_with_nothing_posted_is_empty_with_zero_totals() { + let mut co = company(); + let tb = trial_balance_as_of(&co.l, d(2026, 6, 30)); + assert!(tb.lines.is_empty()); + assert_eq!(tb.total_debits, Decimal::ZERO); + assert_eq!(tb.total_credits, Decimal::ZERO); + assert!(tb.balanced()); + + let (cash, rev) = (co.cash, co.rev); + post( + &mut co.l, + d(2026, 7, 1), + "later", + &[(cash, DR, 100), (rev, CR, 100)], + ); + let tb = trial_balance_as_of(&co.l, d(2026, 6, 30)); + assert!(tb.lines.is_empty()); + assert_eq!(tb.total_debits, Decimal::ZERO); + assert_eq!(tb.total_credits, Decimal::ZERO); +} + +/// Oracle: TB SCENARIO 2 — an asset debited 100 and credited 30 shows debit +/// 70, credit 0 (hand-computed: 1000 Dr 70, 4000 Cr 100, 6000 Dr 30). +#[test] +fn tb_as_of_asset_debited_100_credited_30_shows_debit_70() { + let mut co = company(); + let (cash, rev, exp) = (co.cash, co.rev, co.exp); + post( + &mut co.l, + d(2026, 1, 5), + "sale", + &[(cash, DR, 10000), (rev, CR, 10000)], + ); + post( + &mut co.l, + d(2026, 1, 10), + "supplies", + &[(exp, DR, 3000), (cash, CR, 3000)], + ); + let tb = trial_balance_as_of(&co.l, d(2026, 1, 31)); + assert_eq!( + tb_lines(&tb), + vec![ + ("1000".to_string(), c(7000), c(0)), + ("4000".to_string(), c(0), c(10000)), + ("6000".to_string(), c(3000), c(0)), + ] + ); + assert_eq!(tb.total_debits, c(10000)); + assert_eq!(tb.total_credits, c(10000)); + assert!(tb.balanced()); +} + +/// Oracle: TB INVARIANT 2 — NEVER list a placeholder (header 6900 with +/// active children) or a zero balance (an account debited and credited +/// equally). +#[test] +fn tb_never_lists_placeholders_or_zero_balances() { + let mut co = company(); + let (cash, rev, exp, exp2) = (co.cash, co.rev, co.exp, co.exp2); + post( + &mut co.l, + d(2026, 2, 1), + "sale", + &[(cash, DR, 5000), (rev, CR, 5000)], + ); + post( + &mut co.l, + d(2026, 2, 2), + "refund", + &[(rev, DR, 5000), (cash, CR, 5000)], + ); + post( + &mut co.l, + d(2026, 2, 3), + "x", + &[(exp, DR, 100), (exp2, CR, 100)], + ); + let tb = trial_balance_as_of(&co.l, d(2026, 12, 31)); + assert_eq!( + tb_lines(&tb), + vec![ + ("6000".to_string(), c(100), c(0)), + ("6100".to_string(), c(0), c(100)), + ] + ); + assert!(tb.lines.iter().all(|l| l.code != "6900")); +} + +/// Oracle: TB INVARIANT 4 — NEVER count a draft (balanced or not). +#[test] +fn tb_never_counts_drafts() { + let mut co = company(); + let (cash, rev, exp) = (co.cash, co.rev, co.exp); + post( + &mut co.l, + d(2026, 3, 1), + "sale", + &[(cash, DR, 2500), (rev, CR, 2500)], + ); + draft(&mut co.l, d(2026, 3, 2), &[(exp, DR, 900), (cash, CR, 900)]); + draft(&mut co.l, d(2026, 3, 2), &[(exp, DR, 900)]); + let tb = trial_balance_as_of(&co.l, d(2026, 3, 31)); + assert_eq!( + tb_lines(&tb), + vec![ + ("1000".to_string(), c(2500), c(0)), + ("4000".to_string(), c(0), c(2500)), + ] + ); + assert_eq!(tb_lines(&trial_balance(&co.l)), tb_lines(&tb)); +} + +/// Oracle: TB INVARIANT 4 — closing entries dated on or before `as_of` +/// count, so after a close it is the post-closing TB (hand-computed: Jan +/// revenue 100, expense 30 -> 1000 Dr 70, 3999 Cr 70). The day before the +/// closing date it is still the pre-closing TB. +#[test] +fn tb_after_close_is_post_closing_trial_balance() { + let mut co = company(); + let (cash, rev, exp) = (co.cash, co.rev, co.exp); + post( + &mut co.l, + d(2026, 1, 5), + "sale", + &[(cash, DR, 10000), (rev, CR, 10000)], + ); + post( + &mut co.l, + d(2026, 1, 10), + "supplies", + &[(exp, DR, 3000), (cash, CR, 3000)], + ); + let p = + co.l.create_period("2026-01", d(2026, 1, 1), d(2026, 1, 31)) + .unwrap(); + co.l.close_period(p).unwrap(); + + let pre = trial_balance_as_of(&co.l, d(2026, 1, 30)); + assert_eq!( + tb_lines(&pre), + vec![ + ("1000".to_string(), c(7000), c(0)), + ("4000".to_string(), c(0), c(10000)), + ("6000".to_string(), c(3000), c(0)), + ] + ); + let post_close = trial_balance_as_of(&co.l, d(2026, 1, 31)); + assert_eq!( + tb_lines(&post_close), + vec![ + ("1000".to_string(), c(7000), c(0)), + ("3999".to_string(), c(0), c(7000)), + ] + ); + assert_eq!(post_close.total_debits, c(7000)); + assert_eq!(post_close.total_credits, c(7000)); + // TB INVARIANT 1 (trial_balance): equals as-of on or after the last date. + assert_eq!(tb_lines(&trial_balance(&co.l)), tb_lines(&post_close)); +} + +// =========================================================================== +// profit_and_loss_for_period — examples +// =========================================================================== + +/// Oracle: P&L INVARIANTS 1-2 — a line per revenue/expense account with its +/// normal-side movement in [start, end] inclusive; balance-sheet accounts +/// and entries outside the range never counted (hand-computed: 4000 70, +/// 4100 250, 6000 45, 6100 60, NI 215). +#[test] +fn pnl_period_is_a_line_per_account_within_the_inclusive_range() { + let mut co = company(); + let (cash, ar, ap, rev, rev2, exp, exp2) = + (co.cash, co.ar, co.ap, co.rev, co.rev2, co.exp, co.exp2); + post( + &mut co.l, + d(2026, 1, 31), + "before", + &[(cash, DR, 99900), (rev, CR, 99900)], + ); + post( + &mut co.l, + d(2026, 2, 1), + "sale", + &[(cash, DR, 10000), (rev, CR, 10000)], + ); + post( + &mut co.l, + d(2026, 2, 10), + "refund", + &[(rev, DR, 3000), (cash, CR, 3000)], + ); + post( + &mut co.l, + d(2026, 2, 14), + "service", + &[(ar, DR, 25000), (rev2, CR, 25000)], + ); + post( + &mut co.l, + d(2026, 2, 20), + "supplies", + &[(exp, DR, 4000), (cash, CR, 4000)], + ); + post( + &mut co.l, + d(2026, 2, 28), + "rent", + &[(exp2, DR, 6000), (ap, CR, 6000)], + ); + post( + &mut co.l, + d(2026, 2, 28), + "more", + &[(exp, DR, 500), (cash, CR, 500)], + ); + post( + &mut co.l, + d(2026, 3, 1), + "after", + &[(exp, DR, 99900), (cash, CR, 99900)], + ); + + let p = profit_and_loss_for_period(&co.l, d(2026, 2, 1), d(2026, 2, 28), None).unwrap(); + assert_eq!(p.start, d(2026, 2, 1)); + assert_eq!(p.end, d(2026, 2, 28)); + assert_eq!(sl(&p.revenue), vec![s("4000", 7000), s("4100", 25000)]); + assert_eq!(sl(&p.expenses), vec![s("6000", 4500), s("6100", 6000)]); + assert_eq!(p.total_revenue, c(32000)); + assert_eq!(p.total_expenses, c(10500)); + assert_eq!(p.net_income(), c(21500)); +} + +/// Oracle: StatementLine contract — a contra movement is negative (a +/// revenue refund alone in March reads -20; NI -20). +#[test] +fn pnl_period_contra_movement_is_negative() { + let mut co = company(); + let (cash, rev) = (co.cash, co.rev); + post( + &mut co.l, + d(2026, 2, 1), + "sale", + &[(cash, DR, 5000), (rev, CR, 5000)], + ); + post( + &mut co.l, + d(2026, 3, 5), + "refund", + &[(rev, DR, 2000), (cash, CR, 2000)], + ); + let p = profit_and_loss_for_period(&co.l, d(2026, 3, 1), d(2026, 3, 31), None).unwrap(); + assert_eq!(sl(&p.revenue), vec![s("4000", -2000)]); + assert!(p.expenses.is_empty()); + assert_eq!(p.total_revenue, c(-2000)); + assert_eq!(p.net_income(), c(-2000)); +} + +/// Oracle: P&L SCENARIO 1 — a closed month still reports its earnings +/// (revenue 100, expenses 30, NI 70), and the closing entry is NEVER counted. +#[test] +fn pnl_closed_month_still_reports_its_earnings() { + let mut co = company(); + let (cash, rev, exp) = (co.cash, co.rev, co.exp); + post( + &mut co.l, + d(2026, 1, 5), + "sale", + &[(cash, DR, 10000), (rev, CR, 10000)], + ); + post( + &mut co.l, + d(2026, 1, 10), + "supplies", + &[(exp, DR, 3000), (cash, CR, 3000)], + ); + let p = + co.l.create_period("2026-01", d(2026, 1, 1), d(2026, 1, 31)) + .unwrap(); + assert!(co.l.close_period(p).unwrap().is_some()); + let pl = profit_and_loss_for_period(&co.l, d(2026, 1, 1), d(2026, 1, 31), None).unwrap(); + assert_eq!(sl(&pl.revenue), vec![s("4000", 10000)]); + assert_eq!(sl(&pl.expenses), vec![s("6000", 3000)]); + assert_eq!(pl.net_income(), c(7000)); + let year = profit_and_loss_for_period(&co.l, d(2026, 1, 1), d(2026, 12, 31), None).unwrap(); + assert_eq!(year.net_income(), c(7000)); +} + +/// Oracle: P&L SCENARIO 2 (ASSUMED in the spec itself) — start after end +/// refuses. +#[test] +fn pnl_period_start_after_end_refuses() { + let co = company(); + assert!(profit_and_loss_for_period(&co.l, d(2026, 3, 2), d(2026, 3, 1), None).is_err()); +} + +/// Oracle: P&L INVARIANT 2 — NEVER count a draft. +#[test] +fn pnl_period_never_counts_drafts() { + let mut co = company(); + let (cash, rev, exp) = (co.cash, co.rev, co.exp); + post( + &mut co.l, + d(2026, 4, 1), + "sale", + &[(cash, DR, 1234), (rev, CR, 1234)], + ); + draft(&mut co.l, d(2026, 4, 2), &[(exp, DR, 500), (cash, CR, 500)]); + draft(&mut co.l, d(2026, 4, 2), &[(rev, CR, 500)]); + let p = profit_and_loss_for_period(&co.l, d(2026, 4, 1), d(2026, 4, 30), None).unwrap(); + assert_eq!(sl(&p.revenue), vec![s("4000", 1234)]); + assert!(p.expenses.is_empty()); +} + +/// Builds the class example: Retail sale 100 + supplies 20, Wholesale sale +/// 50, untagged supplies 30 (its cash line tagged "Ghost"), and a class +/// "Unused" with no lines. Classes are added Wholesale-first to test +/// sorting by name. +fn class_books() -> (Co, ClassId, ClassId) { + let mut co = company(); + let (cash, rev, exp) = (co.cash, co.rev, co.exp); + let wholesale = co.l.add_class("Wholesale").unwrap(); + let retail = co.l.add_class("Retail").unwrap(); + let _unused = co.l.add_class("Unused").unwrap(); + let ghost = co.l.add_class("Ghost").unwrap(); + + let e = draft( + &mut co.l, + d(2026, 2, 5), + &[(cash, DR, 10000), (rev, CR, 10000)], + ); + co.l.set_line_class(e, 1, Some(retail)).unwrap(); + co.l.post_entry(e).unwrap(); + let e = draft( + &mut co.l, + d(2026, 2, 6), + &[(cash, DR, 5000), (rev, CR, 5000)], + ); + co.l.set_line_class(e, 1, Some(wholesale)).unwrap(); + co.l.post_entry(e).unwrap(); + let e = draft( + &mut co.l, + d(2026, 2, 7), + &[(exp, DR, 3000), (cash, CR, 3000)], + ); + co.l.set_line_class(e, 1, Some(ghost)).unwrap(); + co.l.post_entry(e).unwrap(); + let e = draft( + &mut co.l, + d(2026, 2, 8), + &[(exp, DR, 2000), (cash, CR, 2000)], + ); + co.l.set_line_class(e, 0, Some(retail)).unwrap(); + co.l.post_entry(e).unwrap(); + // A tagged draft never counts. + let e = draft(&mut co.l, d(2026, 2, 9), &[(exp, DR, 777), (cash, CR, 777)]); + co.l.set_line_class(e, 0, Some(retail)).unwrap(); + (co, retail, wholesale) +} + +/// Oracle: P&L INVARIANT 5 — with a class, count only lines tagged with it +/// (hand-computed: Retail rev 100 exp 20; Wholesale rev 50; all rev 150 +/// exp 50). +#[test] +fn pnl_period_with_class_counts_only_tagged_lines() { + let (co, retail, wholesale) = class_books(); + let (a, b) = (d(2026, 2, 1), d(2026, 2, 28)); + let r = profit_and_loss_for_period(&co.l, a, b, Some(retail)).unwrap(); + assert_eq!(sl(&r.revenue), vec![s("4000", 10000)]); + assert_eq!(sl(&r.expenses), vec![s("6000", 2000)]); + let w = profit_and_loss_for_period(&co.l, a, b, Some(wholesale)).unwrap(); + assert_eq!(sl(&w.revenue), vec![s("4000", 5000)]); + assert!(sl_nonzero(&w.expenses).is_empty()); + assert_eq!(w.total_expenses, Decimal::ZERO); + let all = profit_and_loss_for_period(&co.l, a, b, None).unwrap(); + assert_eq!(sl(&all.revenue), vec![s("4000", 15000)]); + assert_eq!(sl(&all.expenses), vec![s("6000", 5000)]); +} + +// =========================================================================== +// profit_and_loss_by_class — examples +// =========================================================================== + +/// Oracle: by-class doc — one row per class with P&L lines, sorted by name, +/// unclassified last; classes with no P&L lines (Unused; Ghost tags only a +/// cash line) get no row; rows sum to the whole P&L (hand-computed). +#[test] +fn pnl_by_class_rows_sorted_by_name_unclassified_last() { + let (co, _, _) = class_books(); + let rows = profit_and_loss_by_class(&co.l).unwrap(); + let got: Vec<(Option, Decimal, Decimal)> = rows + .iter() + .map(|r| { + ( + r.class.as_ref().map(|k| k.name.clone()), + r.profit_and_loss.revenue, + r.profit_and_loss.expenses, + ) + }) + .collect(); + assert_eq!( + got, + vec![ + (Some("Retail".to_string()), c(10000), c(2000)), + (Some("Wholesale".to_string()), c(5000), c(0)), + (None, c(0), c(3000)), + ] + ); + let whole = profit_and_loss(&co.l).unwrap(); + assert_eq!(whole.revenue, c(15000)); + assert_eq!(whole.expenses, c(5000)); +} + +/// Oracle: by-class doc — no unclassified row when every P&L line is tagged +/// (an untagged cash line is not a P&L line). +#[test] +fn pnl_by_class_has_no_unclassified_row_when_all_pnl_lines_tagged() { + let mut co = company(); + let (cash, rev) = (co.cash, co.rev); + let k = co.l.add_class("Only").unwrap(); + let e = draft(&mut co.l, d(2026, 2, 5), &[(cash, DR, 100), (rev, CR, 100)]); + co.l.set_line_class(e, 1, Some(k)).unwrap(); + co.l.post_entry(e).unwrap(); + let rows = profit_and_loss_by_class(&co.l).unwrap(); + assert_eq!(rows.len(), 1); + assert_eq!( + rows[0].class.as_ref().map(|k| k.name.as_str()), + Some("Only") + ); + assert_eq!(rows[0].profit_and_loss.revenue, c(100)); +} + +// =========================================================================== +// balance_sheet_as_of — examples +// =========================================================================== + +/// Oracle: BS SCENARIO 1 — nothing posted by `as_of`: empty and balanced. +#[test] +fn bs_with_nothing_posted_is_empty_and_balanced() { + let mut co = company(); + let (cash, cap) = (co.cash, co.cap_a); + post( + &mut co.l, + d(2026, 5, 1), + "later", + &[(cash, DR, 100), (cap, CR, 100)], + ); + let b = balance_sheet_as_of(&co.l, d(2026, 4, 30)).unwrap(); + assert_eq!(b.as_of, d(2026, 4, 30)); + assert!(b.assets.is_empty() && b.liabilities.is_empty() && b.equity.is_empty()); + assert_eq!(b.current_earnings, Decimal::ZERO); + assert_eq!(b.total_assets, Decimal::ZERO); + assert_eq!(b.total_liabilities, Decimal::ZERO); + assert_eq!(b.total_equity, Decimal::ZERO); + assert!(b.balanced()); +} + +/// Oracle: BS INVARIANTS 1, 3, 4, 5 — hand-computed statement: assets 1450 +/// (cash 1250, AR 200), loan 300, capital 500 + 500, current earnings 150, +/// total equity 1150; contra accumulated depreciation reads negative; no +/// revenue or expense lines. +#[test] +fn bs_lines_totals_and_current_earnings_by_hand() { + let mut co = company(); + let (cash, ar, loan, cap_a, cap_b, rev, exp, accum, depr) = ( + co.cash, co.ar, co.loan, co.cap_a, co.cap_b, co.rev, co.exp, co.accum, co.depr, + ); + post( + &mut co.l, + d(2026, 1, 2), + "contributions", + &[(cash, DR, 100000), (cap_a, CR, 50000), (cap_b, CR, 50000)], + ); + post( + &mut co.l, + d(2026, 1, 3), + "loan", + &[(cash, DR, 30000), (loan, CR, 30000)], + ); + post( + &mut co.l, + d(2026, 1, 4), + "invoice", + &[(ar, DR, 20000), (rev, CR, 20000)], + ); + post( + &mut co.l, + d(2026, 1, 5), + "supplies", + &[(exp, DR, 5000), (cash, CR, 5000)], + ); + post( + &mut co.l, + d(2026, 1, 6), + "depr", + &[(depr, DR, 1000), (accum, CR, 1000)], + ); + let b = balance_sheet_as_of(&co.l, d(2026, 1, 31)).unwrap(); + assert_eq!( + sl(&b.assets), + vec![s("1000", 125000), s("1100", 20000), s("1510", -1000)] + ); + assert_eq!(sl(&b.liabilities), vec![s("2500", 30000)]); + assert_eq!(sl(&b.equity), vec![s("3000", 50000), s("3001", 50000)]); + assert_eq!(b.current_earnings, c(14000)); + assert_eq!(b.total_assets, c(144000)); + assert_eq!(b.total_liabilities, c(30000)); + assert_eq!(b.total_equity, c(114000)); + assert!(b.balanced()); +} + +/// Oracle: BS INVARIANTS 2-3 — closing entries count: after closing January +/// (rev 100 Jan 10, exp 30 Jan 20) 3999 holds 70 and current earnings is +/// only February's 40; mid-January (before the closing date) current +/// earnings is 100 and there is no 3999 line. +#[test] +fn bs_after_close_moves_earnings_to_retained_earnings() { + let mut co = company(); + let (cash, rev, exp) = (co.cash, co.rev, co.exp); + post( + &mut co.l, + d(2026, 1, 10), + "sale", + &[(cash, DR, 10000), (rev, CR, 10000)], + ); + post( + &mut co.l, + d(2026, 1, 20), + "supplies", + &[(exp, DR, 3000), (cash, CR, 3000)], + ); + let p = + co.l.create_period("2026-01", d(2026, 1, 1), d(2026, 1, 31)) + .unwrap(); + co.l.close_period(p).unwrap(); + post( + &mut co.l, + d(2026, 2, 3), + "sale", + &[(cash, DR, 4000), (rev, CR, 4000)], + ); + + let mid = balance_sheet_as_of(&co.l, d(2026, 1, 15)).unwrap(); + assert!(sl_nonzero(&mid.equity).is_empty()); + assert_eq!(mid.current_earnings, c(10000)); + + let jan = balance_sheet_as_of(&co.l, d(2026, 1, 31)).unwrap(); + assert_eq!(sl(&jan.equity), vec![s("3999", 7000)]); + assert_eq!(jan.current_earnings, Decimal::ZERO); + assert!(jan.balanced()); + + let feb = balance_sheet_as_of(&co.l, d(2026, 2, 28)).unwrap(); + assert_eq!(sl(&feb.equity), vec![s("3999", 7000)]); + assert_eq!(feb.current_earnings, c(4000)); + assert_eq!(feb.total_equity, c(11000)); + assert_eq!(feb.total_assets, c(11000)); +} + +/// Oracle: BS doc ("every posted entry") — drafts never counted. +#[test] +fn bs_never_counts_drafts() { + let mut co = company(); + let (cash, cap, exp) = (co.cash, co.cap_a, co.exp); + post( + &mut co.l, + d(2026, 1, 2), + "in", + &[(cash, DR, 1000), (cap, CR, 1000)], + ); + draft(&mut co.l, d(2026, 1, 3), &[(exp, DR, 400), (cash, CR, 400)]); + let b = balance_sheet_as_of(&co.l, d(2026, 1, 31)).unwrap(); + assert_eq!(sl(&b.assets), vec![s("1000", 1000)]); + assert_eq!(b.current_earnings, Decimal::ZERO); +} + +// =========================================================================== +// general_ledger — examples +// =========================================================================== + +/// Oracle: GL INVARIANTS 1-3 — hand-computed February ledger: cash begins +/// 1000, Feb 3 credit 40 (split "6000 Supplies") -> 960, Feb 10 debit 300 +/// (split "-Split-") -> 1260; Sales gets credit 200 split "1000 Cash"; +/// Capital A is listed for its beginning balance alone; Rent (only March +/// activity) is not listed. +#[test] +fn gl_hand_computed_february() { + let mut co = company(); + let (cash, cap_a, rev, rev2, exp, exp2) = (co.cash, co.cap_a, co.rev, co.rev2, co.exp, co.exp2); + post( + &mut co.l, + d(2026, 1, 5), + "Contribution", + &[(cash, DR, 100000), (cap_a, CR, 100000)], + ); + let e1 = post( + &mut co.l, + d(2026, 2, 3), + "Supplies", + &[(exp, DR, 4000), (cash, CR, 4000)], + ); + let e2 = post( + &mut co.l, + d(2026, 2, 10), + "Sales", + &[(cash, DR, 30000), (rev, CR, 20000), (rev2, CR, 10000)], + ); + post( + &mut co.l, + d(2026, 3, 1), + "Rent", + &[(exp2, DR, 1000), (cash, CR, 1000)], + ); + draft( + &mut co.l, + d(2026, 2, 15), + &[(exp2, DR, 500), (cash, CR, 500)], + ); + + let g = general_ledger(&co.l, d(2026, 2, 1), d(2026, 2, 28)).unwrap(); + assert_eq!(g.start, d(2026, 2, 1)); + assert_eq!(g.end, d(2026, 2, 28)); + let codes: Vec<&str> = g.accounts.iter().map(|a| a.code.as_str()).collect(); + assert_eq!(codes, vec!["1000", "3000", "4000", "4100", "6000"]); + + let cashacct = &g.accounts[0]; + assert_eq!(cashacct.name, "Cash"); + assert_eq!(cashacct.account_type, AccountType::Asset); + assert_eq!(cashacct.beginning_balance, c(100000)); + let p: Vec<(NaiveDate, EntryId, &str, &str, Decimal, Decimal, Decimal)> = cashacct + .postings + .iter() + .map(|p| { + ( + p.date, + p.entry_id, + p.description.as_str(), + p.split.as_str(), + p.debit, + p.credit, + p.balance, + ) + }) + .collect(); + assert_eq!( + p, + vec![ + ( + d(2026, 2, 3), + e1, + "Supplies", + "6000 Supplies", + c(0), + c(4000), + c(96000) + ), + ( + d(2026, 2, 10), + e2, + "Sales", + "-Split-", + c(30000), + c(0), + c(126000) + ), + ] + ); + assert_eq!(cashacct.total_debits, c(30000)); + assert_eq!(cashacct.total_credits, c(4000)); + assert_eq!(cashacct.ending_balance, c(126000)); + + let capital = &g.accounts[1]; + assert_eq!(capital.beginning_balance, c(100000)); + assert!(capital.postings.is_empty()); + assert_eq!(capital.ending_balance, c(100000)); + + let sales = &g.accounts[2]; + assert_eq!(sales.beginning_balance, Decimal::ZERO); + assert_eq!(sales.postings.len(), 1); + assert_eq!(sales.postings[0].split, "1000 Cash"); + assert_eq!(sales.postings[0].credit, c(20000)); + assert_eq!(sales.postings[0].balance, c(20000)); + assert_eq!(sales.ending_balance, c(20000)); +} + +/// Oracle: GL INVARIANT 1 — postings in date order (then entry), running +/// balance follows that order, even when entries were posted out of date +/// order; a liability's balance runs on its credit side. +#[test] +fn gl_postings_in_date_order_with_normal_side_running_balance() { + let mut co = company(); + let (cash, loan) = (co.cash, co.loan); + let late = post( + &mut co.l, + d(2026, 5, 20), + "draw 2", + &[(cash, DR, 700), (loan, CR, 700)], + ); + let early = post( + &mut co.l, + d(2026, 5, 5), + "draw 1", + &[(cash, DR, 300), (loan, CR, 300)], + ); + let pay = post( + &mut co.l, + d(2026, 5, 20), + "repay", + &[(loan, DR, 100), (cash, CR, 100)], + ); + let g = general_ledger(&co.l, d(2026, 5, 1), d(2026, 5, 31)).unwrap(); + let l = g.accounts.iter().find(|a| a.code == "2500").unwrap(); + let got: Vec<(EntryId, Decimal)> = l.postings.iter().map(|p| (p.entry_id, p.balance)).collect(); + assert!(late < pay); + assert_eq!(got, vec![(early, c(300)), (late, c(1000)), (pay, c(900))]); + assert_eq!(l.ending_balance, c(900)); +} + +/// Oracle: GL doc — closing entries are postings like any other (January +/// close shows on Sales as a debit of 100, ending 0). +#[test] +fn gl_shows_closing_entries_as_postings() { + let mut co = company(); + let (cash, rev) = (co.cash, co.rev); + post( + &mut co.l, + d(2026, 1, 10), + "sale", + &[(cash, DR, 10000), (rev, CR, 10000)], + ); + let p = + co.l.create_period("2026-01", d(2026, 1, 1), d(2026, 1, 31)) + .unwrap(); + let close = co.l.close_period(p).unwrap().unwrap(); + let g = general_ledger(&co.l, d(2026, 1, 1), d(2026, 1, 31)).unwrap(); + let sales = g.accounts.iter().find(|a| a.code == "4000").unwrap(); + assert_eq!(sales.postings.len(), 2); + assert_eq!(sales.postings[1].entry_id, close); + assert_eq!(sales.postings[1].debit, c(10000)); + assert_eq!(sales.ending_balance, Decimal::ZERO); + let re = g.accounts.iter().find(|a| a.code == "3999").unwrap(); + assert_eq!(re.ending_balance, c(10000)); +} + +// =========================================================================== +// members_equity — examples +// =========================================================================== + +/// Oracle: ME SCENARIO 1 — shares that don't sum to 1, or aren't positive, +/// refuse. +#[test] +fn me_refuses_bad_shares() { + let co = company(); + let (a, b) = (d(2026, 1, 1), d(2026, 12, 31)); + for (sa, sb) in [(60, 60), (100, 0), (-50, 150), (40, 50)] { + let mut m = members_50_50(&co); + m[0].share = c(sa); + m[1].share = c(sb); + assert!(members_equity(&co.l, a, b, &m).is_err(), "shares {sa} {sb}"); + } +} + +/// Oracle: ME INVARIANTS 1-3 — hand-computed roll-forward with odd cents +/// split by largest remainder, ties to the earlier member: prior unclosed +/// earnings 100.01 -> A 50.01, B 50.00; period income 201.01 -> A 100.51, +/// B 100.50. Total ending 2251.02 = BS equity. +/// +/// ASSUMED: the beginning (unclosed prior earnings) and the period's income +/// are each split separately (INVARIANT 3 names them separately), not +/// cumulative-then-differenced. +#[test] +fn me_hand_computed_roll_forward_with_largest_remainder() { + let mut co = company(); + let (cash, cap_a, cap_b, rev, exp) = (co.cash, co.cap_a, co.cap_b, co.rev, co.exp); + post( + &mut co.l, + d(2026, 1, 5), + "A in", + &[(cash, DR, 100000), (cap_a, CR, 100000)], + ); + post( + &mut co.l, + d(2026, 1, 20), + "Jan sale", + &[(cash, DR, 10001), (rev, CR, 10001)], + ); + post( + &mut co.l, + d(2026, 2, 2), + "B in", + &[(cash, DR, 100000), (cap_b, CR, 100000)], + ); + post( + &mut co.l, + d(2026, 2, 10), + "Feb sale", + &[(cash, DR, 30101), (rev, CR, 30101)], + ); + post( + &mut co.l, + d(2026, 2, 15), + "supplies", + &[(exp, DR, 10000), (cash, CR, 10000)], + ); + post( + &mut co.l, + d(2026, 3, 1), + "A draw", + &[(cap_a, DR, 5000), (cash, CR, 5000)], + ); + + let st = members_equity(&co.l, d(2026, 2, 1), d(2026, 3, 31), &members_50_50(&co)).unwrap(); + assert_eq!(st.start, d(2026, 2, 1)); + assert_eq!(st.end, d(2026, 3, 31)); + assert_eq!( + st.members, + vec![ + col("A", 105001, 0, 10051, 5000, 0, 110052), + col("B", 5000, 100000, 10050, 0, 0, 115050), + ] + ); + assert_eq!( + st.total, + col("Total", 110001, 100000, 20101, 5000, 0, 225102) + ); + let bs = balance_sheet_as_of(&co.l, d(2026, 3, 31)).unwrap(); + assert_eq!(bs.total_equity, c(225102)); +} + +/// Oracle: ME INVARIANT 4 — an equity change through a non-member account +/// (3500 credited 10) goes in "other", allocated by share (5 / 5), never +/// hidden; ending ties to BS equity 1010. +#[test] +fn me_unexplained_equity_change_goes_in_other() { + let mut co = company(); + let (cash, cap_a, other) = (co.cash, co.cap_a, co.other_eq); + post( + &mut co.l, + d(2026, 1, 5), + "A in", + &[(cash, DR, 100000), (cap_a, CR, 100000)], + ); + post( + &mut co.l, + d(2026, 2, 3), + "grant", + &[(cash, DR, 1000), (other, CR, 1000)], + ); + let st = members_equity(&co.l, d(2026, 2, 1), d(2026, 2, 28), &members_50_50(&co)).unwrap(); + assert_eq!( + st.members, + vec![ + col("A", 100000, 0, 0, 0, 500, 100500), + col("B", 0, 0, 0, 0, 500, 500) + ] + ); + assert_eq!(st.total, col("Total", 100000, 0, 0, 0, 1000, 101000)); +} + +/// Oracle: ME INVARIANTS 1-3 with a members close inside / before the +/// period: the close's credits to capital are allocated earnings, not +/// contributions (hand-computed: Jan income 100.01 closes A 50.01 / B +/// 50.00). +/// +/// ASSUMED: a closing entry's credit to a member's capital account is not +/// reported as a contribution (B-11: a close moves earnings), so January's +/// statement shows contributions 1000 / 0, income 50.01 / 50.00. +#[test] +fn me_after_members_close_does_not_count_close_as_contribution() { + let mut co = company(); + let (cash, cap_a, cap_b, rev) = (co.cash, co.cap_a, co.cap_b, co.rev); + post( + &mut co.l, + d(2026, 1, 5), + "A in", + &[(cash, DR, 100000), (cap_a, CR, 100000)], + ); + post( + &mut co.l, + d(2026, 1, 20), + "Jan sale", + &[(cash, DR, 10001), (rev, CR, 10001)], + ); + let p = + co.l.create_period("2026-01", d(2026, 1, 1), d(2026, 1, 31)) + .unwrap(); + let to = CloseTo::Members(vec![ + MemberClose { + capital: cap_a, + share: c(50), + distributions: None, + }, + MemberClose { + capital: cap_b, + share: c(50), + distributions: None, + }, + ]); + co.l.close_period_to(p, &to).unwrap(); + post( + &mut co.l, + d(2026, 2, 10), + "Feb sale", + &[(cash, DR, 1000), (rev, CR, 1000)], + ); + + let bs_jan = balance_sheet_as_of(&co.l, d(2026, 1, 31)).unwrap(); + assert_eq!(sl(&bs_jan.equity), vec![s("3000", 105001), s("3001", 5000)]); + + let m = members_50_50(&co); + let jan = members_equity(&co.l, d(2026, 1, 1), d(2026, 1, 31), &m).unwrap(); + assert_eq!( + jan.members, + vec![ + col("A", 0, 100000, 5001, 0, 0, 105001), + col("B", 0, 0, 5000, 0, 0, 5000) + ] + ); + let feb = members_equity(&co.l, d(2026, 2, 1), d(2026, 2, 28), &m).unwrap(); + assert_eq!( + feb.members, + vec![ + col("A", 105001, 0, 500, 0, 0, 105501), + col("B", 5000, 0, 500, 0, 0, 5500) + ] + ); + assert_eq!(feb.total.ending, c(111001)); +} + +fn three_members(co: &Co) -> Vec { + vec![ + MemberCapital { + name: "A".into(), + capital_account: co.cap_a, + distribution_account: None, + share: c(50), + }, + MemberCapital { + name: "B".into(), + capital_account: co.cap_b, + distribution_account: None, + share: c(30), + }, + MemberCapital { + name: "C".into(), + capital_account: co.other_eq, + distribution_account: None, + share: c(20), + }, + ] +} + +/// Oracle: ME INVARIANT 3 (largest remainder, DECISION 2026-10-13) — +/// income 0.09 at 50/30/20: exact 0.045/0.027/0.018, floors 0.04/0.02/0.01, +/// two leftover cents to the largest remainders (C .008, B .007): 0.04, +/// 0.03, 0.02. (Last-absorbs would give 0.05/0.03/0.01.) A loss of 0.09 +/// splits as the negation. +#[test] +fn me_three_members_largest_remainder_gain_and_loss() { + let mut co = company(); + let (cash, rev, exp) = (co.cash, co.rev, co.exp); + post( + &mut co.l, + d(2026, 2, 10), + "sale", + &[(cash, DR, 9), (rev, CR, 9)], + ); + post( + &mut co.l, + d(2026, 3, 10), + "cost", + &[(exp, DR, 9), (cash, CR, 9)], + ); + let m = three_members(&co); + let feb = members_equity(&co.l, d(2026, 2, 1), d(2026, 2, 28), &m).unwrap(); + let ni: Vec = feb.members.iter().map(|x| x.net_income).collect(); + assert_eq!(ni, vec![c(4), c(3), c(2)]); + assert_eq!(feb.total.net_income, c(9)); + let mar = members_equity(&co.l, d(2026, 3, 1), d(2026, 3, 31), &m).unwrap(); + let ni: Vec = mar.members.iter().map(|x| x.net_income).collect(); + assert_eq!(ni, vec![c(-4), c(-3), c(-2)]); + // March begins with February's unclosed earnings, split the same way. + let beg: Vec = mar.members.iter().map(|x| x.beginning).collect(); + assert_eq!(beg, vec![c(4), c(3), c(2)]); + assert_eq!(mar.total.ending, Decimal::ZERO); +} + +/// Oracle: MemberCapital doc — a member's distribution account is part of +/// their capital on every statement: A contributes 1000, draws 30 through +/// 3010 -> distributions 30, ending 970 = BS equity. +#[test] +fn me_distribution_account_counts_as_distributions() { + let mut co = company(); + let (cash, cap_a) = (co.cash, co.cap_a); + let draws = + co.l.add_account("3010", "Distributions A", AccountType::Equity, None, false) + .unwrap(); + post( + &mut co.l, + d(2026, 1, 5), + "A in", + &[(cash, DR, 100000), (cap_a, CR, 100000)], + ); + post( + &mut co.l, + d(2026, 2, 5), + "A draw", + &[(draws, DR, 3000), (cash, CR, 3000)], + ); + let mut m = members_50_50(&co); + m[0].distribution_account = Some(draws); + let st = members_equity(&co.l, d(2026, 2, 1), d(2026, 2, 28), &m).unwrap(); + assert_eq!( + st.members, + vec![ + col("A", 100000, 0, 0, 3000, 0, 97000), + col("B", 0, 0, 0, 0, 0, 0) + ] + ); + assert_eq!( + balance_sheet_as_of(&co.l, d(2026, 2, 28)) + .unwrap() + .total_equity, + c(97000) + ); +} + +/// Oracle: ME doc — earnings not yet distributed include retained earnings: +/// after a January close into 3999 (100.01), February begins A 1000 + +/// 50.01, B 50.00 (largest remainder, tie to the earlier), and 3999 is not +/// "other". +#[test] +fn me_beginning_includes_retained_earnings_by_share() { + let mut co = company(); + let (cash, cap_a, rev) = (co.cash, co.cap_a, co.rev); + post( + &mut co.l, + d(2026, 1, 5), + "A in", + &[(cash, DR, 100000), (cap_a, CR, 100000)], + ); + post( + &mut co.l, + d(2026, 1, 20), + "Jan sale", + &[(cash, DR, 10001), (rev, CR, 10001)], + ); + let p = + co.l.create_period("2026-01", d(2026, 1, 1), d(2026, 1, 31)) + .unwrap(); + co.l.close_period(p).unwrap(); + let st = members_equity(&co.l, d(2026, 2, 1), d(2026, 2, 28), &members_50_50(&co)).unwrap(); + assert_eq!( + st.members, + vec![ + col("A", 105001, 0, 0, 0, 0, 105001), + col("B", 5000, 0, 0, 0, 0, 5000) + ] + ); +} + +/// Oracle: CF INVARIANT 1 — a transfer between two designated cash +/// accounts is no cash flow at all (net change 0, every section 0). +#[test] +fn cf_transfer_between_cash_accounts_is_not_a_flow() { + let mut co = company(); + let (cash, ar, cap_a) = (co.cash, co.ar, co.cap_a); + post( + &mut co.l, + d(2026, 1, 5), + "A in", + &[(cash, DR, 100000), (cap_a, CR, 100000)], + ); + post( + &mut co.l, + d(2026, 2, 5), + "sweep", + &[(ar, DR, 40000), (cash, CR, 40000)], + ); + let cf = cash_flow( + &co.l, + &CashFlowParams { + start: d(2026, 2, 1), + end: d(2026, 2, 28), + cash_accounts: vec![cash, ar], + financing_accounts: vec![], + }, + ) + .unwrap(); + assert_eq!(cf.net_change_in_cash, Decimal::ZERO); + assert_eq!(cf.cash_from_operations, Decimal::ZERO); + assert_eq!(cf.cash_from_investing, Decimal::ZERO); + assert_eq!(cf.cash_from_financing, Decimal::ZERO); + assert_eq!(cf.cash_beginning, c(100000)); + assert_eq!(cf.cash_ending, c(100000)); +} + +// =========================================================================== +// cash_flow — examples +// =========================================================================== + +fn cf_example_books() -> Co { + let mut co = company(); + let (cash, ar, ap, loan, cap_a, cap_b, rev, exp) = ( + co.cash, co.ar, co.ap, co.loan, co.cap_a, co.cap_b, co.rev, co.exp, + ); + post( + &mut co.l, + d(2026, 1, 5), + "A in", + &[(cash, DR, 100000), (cap_a, CR, 100000)], + ); + post( + &mut co.l, + d(2026, 2, 2), + "invoice", + &[(ar, DR, 50000), (rev, CR, 50000)], + ); + post( + &mut co.l, + d(2026, 2, 9), + "collect", + &[(cash, DR, 20000), (ar, CR, 20000)], + ); + post( + &mut co.l, + d(2026, 2, 10), + "bill", + &[(exp, DR, 8000), (ap, CR, 8000)], + ); + post( + &mut co.l, + d(2026, 2, 11), + "cash exp", + &[(exp, DR, 2000), (cash, CR, 2000)], + ); + post( + &mut co.l, + d(2026, 2, 12), + "loan", + &[(cash, DR, 30000), (loan, CR, 30000)], + ); + post( + &mut co.l, + d(2026, 2, 13), + "B in", + &[(cash, DR, 10000), (cap_b, CR, 10000)], + ); + co +} + +fn sorted_amounts(lines: &[ledger_core::CashFlowLine]) -> Vec { + let mut v: Vec = lines + .iter() + .map(|l| l.amount) + .filter(|a| !a.is_zero()) + .collect(); + v.sort(); + v +} + +/// Oracle: CF INVARIANTS 1, 2, 4 — hand-computed February (indirect): NI +/// 400; AR up 300 (-300), AP up 80 (+80); CFO 180; financing loan 300 + +/// contribution 100 = 400; net change 580 = cash 1000 -> 1580. +/// +/// ASSUMED: one working-capital / financing line per account that changed. +#[test] +fn cf_hand_computed_with_designated_debt() { + let co = cf_example_books(); + let params = CashFlowParams { + start: d(2026, 2, 1), + end: d(2026, 2, 28), + cash_accounts: vec![co.cash], + financing_accounts: vec![co.loan], + }; + let cf = cash_flow(&co.l, ¶ms).unwrap(); + assert_eq!(cf.start, d(2026, 2, 1)); + assert_eq!(cf.end, d(2026, 2, 28)); + assert_eq!(cf.net_income, c(40000)); + assert_eq!(cf.depreciation, Decimal::ZERO); + assert_eq!( + sorted_amounts(&cf.working_capital), + vec![c(-30000), c(8000)] + ); + assert_eq!(cf.cash_from_operations, c(18000)); + assert_eq!(cf.cash_from_investing, Decimal::ZERO); + assert_eq!(sorted_amounts(&cf.financing), vec![c(10000), c(30000)]); + assert_eq!(cf.cash_from_financing, c(40000)); + assert_eq!(cf.net_change_in_cash, c(58000)); + assert_eq!(cf.cash_beginning, c(100000)); + assert_eq!(cf.cash_ending, c(158000)); +} + +/// Oracle: CashFlowParams doc — by default all liabilities are operating +/// working capital (the loan moves to operating: CFO 480, financing 100). +#[test] +fn cf_undesignated_liability_is_working_capital() { + let co = cf_example_books(); + let params = CashFlowParams { + start: d(2026, 2, 1), + end: d(2026, 2, 28), + cash_accounts: vec![co.cash], + financing_accounts: vec![], + }; + let cf = cash_flow(&co.l, ¶ms).unwrap(); + assert_eq!(cf.cash_from_operations, c(48000)); + assert_eq!(cf.cash_from_financing, c(10000)); + assert_eq!(cf.net_change_in_cash, c(58000)); +} + +/// Oracle: CF INVARIANT 5 / B-14 — closing entries never counted: after +/// closing February into retained earnings the February statement is +/// unchanged (NI still 400, 3999 not in financing). +#[test] +fn cf_never_counts_closing_entries() { + let mut co = cf_example_books(); + let params = CashFlowParams { + start: d(2026, 2, 1), + end: d(2026, 2, 28), + cash_accounts: vec![co.cash], + financing_accounts: vec![co.loan], + }; + let before = cash_flow(&co.l, ¶ms).unwrap(); + let p1 = + co.l.create_period("2026-01", d(2026, 1, 1), d(2026, 1, 31)) + .unwrap(); + co.l.close_period(p1).unwrap(); + let p2 = + co.l.create_period("2026-02", d(2026, 2, 1), d(2026, 2, 28)) + .unwrap(); + co.l.close_period(p2).unwrap(); + let after = cash_flow(&co.l, ¶ms).unwrap(); + assert_eq!(after.net_income, c(40000)); + assert_eq!(cf_key(&after), cf_key(&before)); +} + +/// Oracle: CF INVARIANTS 1-3 — fixed-asset purchase in investing, +/// depreciation added back, disposal gain moved out of operating with the +/// proceeds in investing (hand-computed: Feb NI -100, depr 100, CFO 0, +/// investing -1200; Mar NI -50, depr 100, CFO 0, investing +1050). +/// +/// ASSUMED: depreciation is recognised as credits to a registered asset's +/// accumulated-depreciation account against expense, and the disposal is +/// recognised structurally from the entry alone (dispose_asset not called). +#[test] +fn cf_fixed_asset_purchase_depreciation_and_disposal() { + let mut co = company(); + let (cash, cap_a, equip, accum, depr, gain) = + (co.cash, co.cap_a, co.equip, co.accum, co.depr, co.gain); + post( + &mut co.l, + d(2026, 1, 5), + "A in", + &[(cash, DR, 1000000), (cap_a, CR, 1000000)], + ); + post( + &mut co.l, + d(2026, 2, 1), + "buy", + &[(equip, DR, 120000), (cash, CR, 120000)], + ); + co.l.register_asset(NewAsset { + description: "Laptop".into(), + purchase_date: d(2026, 2, 1), + cost: usd(120000), + salvage_value: usd(0), + useful_life_months: 12, + cost_account: equip, + accum_depr_account: accum, + expense_account: depr, + }) + .unwrap(); + post( + &mut co.l, + d(2026, 2, 28), + "depr Feb", + &[(depr, DR, 10000), (accum, CR, 10000)], + ); + post( + &mut co.l, + d(2026, 3, 31), + "depr Mar", + &[(depr, DR, 10000), (accum, CR, 10000)], + ); + post( + &mut co.l, + d(2026, 3, 31), + "sell", + &[ + (cash, DR, 105000), + (accum, DR, 20000), + (equip, CR, 120000), + (gain, CR, 5000), + ], + ); + let feb = cash_flow( + &co.l, + &CashFlowParams { + start: d(2026, 2, 1), + end: d(2026, 2, 28), + cash_accounts: vec![cash], + financing_accounts: vec![], + }, + ) + .unwrap(); + assert_eq!(feb.net_income, c(-10000)); + assert_eq!(feb.depreciation, c(10000)); + assert_eq!(feb.cash_from_operations, Decimal::ZERO); + assert_eq!(feb.cash_from_investing, c(-120000)); + assert_eq!(feb.cash_from_financing, Decimal::ZERO); + assert_eq!(feb.net_change_in_cash, c(-120000)); + + let mar = cash_flow( + &co.l, + &CashFlowParams { + start: d(2026, 3, 1), + end: d(2026, 3, 31), + cash_accounts: vec![cash], + financing_accounts: vec![], + }, + ) + .unwrap(); + assert_eq!(mar.net_income, c(-5000)); + assert_eq!(mar.depreciation, c(10000)); + assert_eq!(mar.cash_from_operations, Decimal::ZERO); + assert_eq!(mar.cash_from_investing, c(105000)); + assert_eq!(mar.net_change_in_cash, c(105000)); + assert_eq!(mar.cash_ending, c(985000)); +} + +// =========================================================================== +// snapshot — examples +// =========================================================================== + +fn has_float(v: &Value) -> bool { + match v { + Value::Number(n) => n.is_f64(), + Value::Array(a) => a.iter().any(has_float), + Value::Object(o) => o.values().any(has_float), + _ => false, + } +} + +fn decimal_strings(v: &Value, out: &mut Vec) { + match v { + Value::String(s) => { + if let Ok(x) = s.parse::() { + out.push(x); + } + } + Value::Array(a) => a.iter().for_each(|x| decimal_strings(x, out)), + Value::Object(o) => o.values().for_each(|x| decimal_strings(x, out)), + _ => {} + } +} + +/// Oracle: snapshot INVARIANTS 1-2 — no floats; amounts are decimal strings +/// (the hand-computed 70.00 cash balance appears as a string); drafts do +/// not change it; it is deterministic. +#[test] +fn snapshot_uses_decimal_strings_and_ignores_drafts() { + let mut co = company(); + let (cash, rev, exp) = (co.cash, co.rev, co.exp); + post( + &mut co.l, + d(2026, 1, 5), + "sale", + &[(cash, DR, 10000), (rev, CR, 10000)], + ); + post( + &mut co.l, + d(2026, 1, 10), + "supplies", + &[(exp, DR, 3001), (cash, CR, 3001)], + ); + let snap = snapshot(&co.l).unwrap(); + assert!(!has_float(&snap)); + let mut amts = Vec::new(); + decimal_strings(&snap, &mut amts); + assert!(amts.contains(&c(6999)), "{snap}"); + assert!(amts.contains(&c(3001)), "{snap}"); + draft(&mut co.l, d(2026, 1, 11), &[(exp, DR, 5), (cash, CR, 5)]); + assert_eq!(snapshot(&co.l).unwrap(), snap); + assert_eq!(snapshot(&co.l).unwrap(), snap); +} + +// =========================================================================== +// Random balanced books for the properties. +// =========================================================================== + +const N: usize = 10; +const CASH: usize = 0; +const LOAN: usize = 3; +const CAP_A: usize = 4; +const CAP_B: usize = 5; + +/// The random chart: (code, name, type, child of the 6900 header). +const RCHART: [(&str, &str, AccountType, bool); N] = [ + ("1000", "Cash", AccountType::Asset, false), + ("1100", "Receivables", AccountType::Asset, false), + ("2000", "Payables", AccountType::Liability, false), + ("2500", "Loan", AccountType::Liability, false), + ("3000", "Capital A", AccountType::Equity, false), + ("3001", "Capital B", AccountType::Equity, false), + ("4000", "Sales", AccountType::Revenue, false), + ("4100", "Services", AccountType::Revenue, false), + ("6000", "Supplies", AccountType::Expense, true), + ("6100", "Rent", AccountType::Expense, true), +]; + +fn rtype(code: &str) -> Option { + RCHART.iter().find(|r| r.0 == code).map(|r| r.2) +} + +#[derive(Clone, Debug)] +struct Gen { + day: u32, + mode: u8, + picks: [usize; 3], + x: i64, + y: i64, + /// Per-line class choice: 0 untagged, 1 class A, 2 class B. + classes: [u8; 3], +} + +impl Gen { + fn date(&self) -> NaiveDate { + day(self.day) + } + + /// (chart index, side, cents) for each line; accounts distinct. + fn legs(&self, scale: i64) -> Vec<(usize, Side, i64)> { + let mut pool: Vec = (0..N).collect(); + let a = pool.remove(self.picks[0] % pool.len()); + let b = pool.remove(self.picks[1] % pool.len()); + let cc = pool.remove(self.picks[2] % pool.len()); + let (x, y) = (self.x * scale, self.y * scale); + match self.mode { + 0 => vec![(a, DR, x), (b, CR, x)], + 1 => vec![(a, DR, x), (b, DR, y), (cc, CR, x + y)], + _ => vec![(a, CR, x), (b, CR, y), (cc, DR, x + y)], + } + } +} + +fn day(n: u32) -> NaiveDate { + NaiveDate::from_yo_opt(2026, n + 1).unwrap() +} + +fn prev(date: NaiveDate) -> NaiveDate { + date.checked_sub_days(Days::new(1)).unwrap() +} + +fn month_end(month: u32) -> NaiveDate { + if month == 12 { + d(2026, 12, 31) + } else { + prev(d(2026, month + 1, 1)) + } +} + +fn arb_gen() -> impl Strategy { + ( + 0u32..365, + 0u8..3, + (0usize..N, 0usize..N, 0usize..N), + 1i64..=1_000_000, + 1i64..=1_000_000, + (0u8..3, 0u8..3, 0u8..3), + ) + .prop_map(|(day, mode, (p0, p1, p2), x, y, (k0, k1, k2))| Gen { + day, + mode, + picks: [p0, p1, p2], + x, + y, + classes: [k0, k1, k2], + }) +} + +fn arb_books() -> impl Strategy> { + prop::collection::vec(arb_gen(), 0..18) +} + +struct Books { + l: Ledger, + ids: [AccountId; N], + entries: Vec, + class_a: ClassId, + class_b: ClassId, +} + +fn build_ordered(gens: &[Gen], scale: i64, order: &[usize], tag: bool) -> Books { + let mut l = Ledger::new(USD); + let header = l + .add_account( + "6900", + "Operating Expenses", + AccountType::Expense, + None, + true, + ) + .unwrap(); + let mut ids = [0; N]; + for &i in order { + let (code, name, t, child) = RCHART[i]; + ids[i] = l + .add_account(code, name, t, child.then_some(header), false) + .unwrap(); + } + let class_a = l.add_class("Alpha").unwrap(); + let class_b = l.add_class("Beta").unwrap(); + let mut entries = Vec::new(); + for g in gens { + let e = l.create_entry(g.date(), "gen"); + for (n, (i, side, cents)) in g.legs(scale).into_iter().enumerate() { + l.add_line(e, ids[i], side, usd(cents)).unwrap(); + if tag { + let k = match g.classes[n] { + 1 => Some(class_a), + 2 => Some(class_b), + _ => None, + }; + l.set_line_class(e, n, k).unwrap(); + } + } + l.post_entry(e).unwrap(); + entries.push(e); + } + Books { + l, + ids, + entries, + class_a, + class_b, + } +} + +fn build(gens: &[Gen]) -> Books { + build_ordered(gens, 1, &(0..N).collect::>(), false) +} + +fn build_scaled(gens: &[Gen], k: i64) -> Books { + build_ordered(gens, k, &(0..N).collect::>(), false) +} + +fn members_close(b: &Books, share_a: Decimal) -> CloseTo { + CloseTo::Members(vec![ + MemberClose { + capital: b.ids[CAP_A], + share: share_a, + distributions: None, + }, + MemberClose { + capital: b.ids[CAP_B], + share: Decimal::ONE - share_a, + distributions: None, + }, + ]) +} + +/// Close January through `month` as one period (if `month` is given). +fn close_through(b: &mut Books, month: Option, to: &CloseTo) { + if let Some(m) = month { + let p = + b.l.create_period("closed", d(2026, 1, 1), month_end(m)) + .unwrap(); + b.l.close_period_to(p, to).unwrap(); + } +} + +fn re_close() -> CloseTo { + CloseTo::RetainedEarnings { sweep: vec![] } +} + +/// Independent model: debits minus credits per account code over the +/// generated lines dated in [from, to] (closing entries are not generated +/// lines). Optionally only lines whose class choice matches. +fn model( + gens: &[Gen], + from: NaiveDate, + to: NaiveDate, + class: Option, +) -> BTreeMap<&'static str, Decimal> { + let mut m = BTreeMap::new(); + for g in gens { + if g.date() < from || g.date() > to { + continue; + } + for (n, (i, side, cents)) in g.legs(1).into_iter().enumerate() { + if class.is_some_and(|k| g.classes[n] != k) { + continue; + } + let v = if side == DR { c(cents) } else { -c(cents) }; + *m.entry(RCHART[i].0).or_insert(Decimal::ZERO) += v; + } + } + m +} + +fn early() -> NaiveDate { + d(2025, 1, 1) +} + +/// Model P&L lines: (revenue lines, expense lines) on the normal side, +/// nonzero only. +type Lines = Vec<(String, Decimal)>; +fn model_pnl(m: &BTreeMap<&'static str, Decimal>) -> (Lines, Lines) { + let mut rev = Vec::new(); + let mut exp = Vec::new(); + for (code, v) in m { + match rtype(code) { + Some(AccountType::Revenue) if !v.is_zero() => rev.push((code.to_string(), -*v)), + Some(AccountType::Expense) if !v.is_zero() => exp.push((code.to_string(), *v)), + _ => {} + } + } + (rev, exp) +} + +fn model_ni(m: &BTreeMap<&'static str, Decimal>) -> Decimal { + let (r, e) = model_pnl(m); + r.iter().map(|x| x.1).sum::() - e.iter().map(|x| x.1).sum::() +} + +fn sum(lines: &[StatementLine]) -> Decimal { + lines.iter().map(|l| l.amount).sum() +} + +fn three_days() -> impl Strategy { + (0u32..365, 0u32..365, 0u32..365).prop_map(|(a, b, c)| { + let mut v = [a, b, c]; + v.sort(); + (v[0], v[1], v[2]) + }) +} + +fn opt_month() -> impl Strategy> { + prop::option::of(1u32..=12) +} + +// =========================================================================== +// trial_balance / trial_balance_as_of — properties +// =========================================================================== + +proptest! { + #![proptest_config(ProptestConfig::with_cases(64))] + + /// Oracle: TB INVARIANT 1 / B-6 — total debits equal total credits, and + /// each total is its column's sum, for any as-of date, with or without a + /// close. + #[test] + fn prop_tb_columns_always_balance(gens in arb_books(), as_of in 0u32..365, month in opt_month()) { + let mut b = build(&gens); + close_through(&mut b, month, &re_close()); + for tb in [trial_balance_as_of(&b.l, day(as_of)), trial_balance(&b.l)] { + prop_assert!(tb.balanced()); + prop_assert_eq!(tb.total_debits, tb.total_credits); + prop_assert_eq!(tb.total_debits, tb.lines.iter().map(|l| l.debit).sum::()); + prop_assert_eq!(tb.total_credits, tb.lines.iter().map(|l| l.credit).sum::()); + } + } + + /// Oracle: TB INVARIANTS 2-3 — lines strictly in code order, each in + /// exactly one column, positive, never a placeholder. + #[test] + fn prop_tb_lines_ordered_one_column_no_placeholder(gens in arb_books(), as_of in 0u32..365, month in opt_month()) { + let mut b = build(&gens); + close_through(&mut b, month, &re_close()); + let tb = trial_balance_as_of(&b.l, day(as_of)); + for w in tb.lines.windows(2) { + prop_assert!(w[0].code < w[1].code); + } + for l in &tb.lines { + prop_assert!(l.code != "6900"); + prop_assert!(l.debit >= Decimal::ZERO && l.credit >= Decimal::ZERO); + prop_assert!(l.debit.is_zero() != l.credit.is_zero(), "{:?}", l); + } + } + + /// Oracle: TB INVARIANTS 2-4 against the independent model — exactly the + /// accounts with a nonzero net of posted lines dated <= as_of, net on + /// its side. + #[test] + fn prop_tb_matches_net_balance_model(gens in arb_books(), as_of in 0u32..365) { + let b = build(&gens); + let tb = trial_balance_as_of(&b.l, day(as_of)); + let want: Vec<(String, Decimal, Decimal)> = model(&gens, early(), day(as_of), None) + .into_iter() + .filter(|(_, v)| !v.is_zero()) + .map(|(k, v)| if v > Decimal::ZERO { (k.to_string(), v, Decimal::ZERO) } else { (k.to_string(), Decimal::ZERO, -v) }) + .collect(); + prop_assert_eq!(tb_lines(&tb), want); + } + + /// Oracle: trial_balance INVARIANT 1 — equals trial_balance_as_of on or + /// after the last entry's date (with or without a close). + #[test] + fn prop_tb_full_equals_as_of_last_date(gens in arb_books(), month in opt_month()) { + let mut b = build(&gens); + close_through(&mut b, month, &re_close()); + let full = tb_lines(&trial_balance(&b.l)); + prop_assert_eq!(&full, &tb_lines(&trial_balance_as_of(&b.l, d(2026, 12, 31)))); + prop_assert_eq!(&full, &tb_lines(&trial_balance_as_of(&b.l, d(2030, 1, 1)))); + let last = gens.iter().map(|g| g.date()).chain(month.map(month_end)).max(); + if let Some(last) = last { + prop_assert_eq!(&full, &tb_lines(&trial_balance_as_of(&b.l, last))); + } + } + + /// Oracle: metamorphic scaling — every amount times k scales every TB + /// column by k. + #[test] + fn prop_tb_scales_linearly(gens in arb_books(), as_of in 0u32..365, k in 2i64..=7) { + let one = trial_balance_as_of(&build(&gens).l, day(as_of)); + let many = trial_balance_as_of(&build_scaled(&gens, k).l, day(as_of)); + let kk = Decimal::from(k); + let want: Vec<_> = tb_lines(&one).into_iter().map(|(c0, a, b)| (c0, a * kk, b * kk)).collect(); + prop_assert_eq!(tb_lines(&many), want); + prop_assert_eq!(many.total_debits, one.total_debits * kk); + } + + /// Oracle: B-9 metamorphic — adding an entry and its reversal (same + /// date) leaves TB and P&L unchanged at every date. + #[test] + fn prop_entry_plus_reversal_leaves_tb_and_pnl_unchanged(gens in arb_books(), extra in arb_gen(), as_of in 0u32..365) { + let mut b = build(&gens); + let tb0 = tb_lines(&trial_balance_as_of(&b.l, day(as_of))); + let pl0 = pnl_key(&profit_and_loss_for_period(&b.l, d(2026, 1, 1), day(as_of), None).unwrap()); + let e = b.l.create_entry(extra.date(), "extra"); + for (i, side, cents) in extra.legs(1) { + b.l.add_line(e, b.ids[i], side, usd(cents)).unwrap(); + } + b.l.post_entry(e).unwrap(); + b.l.reverse_entry(e, extra.date(), "undo").unwrap(); + prop_assert_eq!(tb_lines(&trial_balance_as_of(&b.l, day(as_of))), tb0); + let pl1 = profit_and_loss_for_period(&b.l, d(2026, 1, 1), day(as_of), None).unwrap(); + prop_assert_eq!(sl_nonzero(&pl1.revenue), pl0.0.into_iter().filter(|x| !x.1.is_zero()).collect::>()); + prop_assert_eq!(sl_nonzero(&pl1.expenses), pl0.1.into_iter().filter(|x| !x.1.is_zero()).collect::>()); + prop_assert_eq!(pl1.total_revenue, pl0.2); + prop_assert_eq!(pl1.total_expenses, pl0.3); + } + + /// Oracle: NEVER-count-drafts clauses (TB 4, P&L 2, BS 2, GL 4, CF via + /// P&L, snapshot) — adding drafts changes no report. + #[test] + fn prop_drafts_change_no_report(gens in arb_books(), drafts in prop::collection::vec(arb_gen(), 1..5), (a, _m, e) in three_days()) { + let mut b = build(&gens); + let (s0, e0) = (day(a), day(e)); + let cfp = CashFlowParams { start: s0, end: e0, cash_accounts: vec![b.ids[CASH]], financing_accounts: vec![] }; + let tb = tb_lines(&trial_balance_as_of(&b.l, e0)); + let pl = pnl_key(&profit_and_loss_for_period(&b.l, s0, e0, None).unwrap()); + let bs = bs_key(&balance_sheet_as_of(&b.l, e0).unwrap()); + let gl = gl_key(&general_ledger(&b.l, s0, e0).unwrap()); + let cf = cf_key(&cash_flow(&b.l, &cfp).unwrap()); + let snap = snapshot(&b.l).unwrap(); + for g in &drafts { + let id = b.l.create_entry(g.date(), "draft"); + for (n, (i, side, cents)) in g.legs(1).into_iter().enumerate() { + if g.mode == 0 && n == 1 { continue; } // some drafts unbalanced + b.l.add_line(id, b.ids[i], side, usd(cents)).unwrap(); + } + } + prop_assert_eq!(tb_lines(&trial_balance_as_of(&b.l, e0)), tb); + prop_assert_eq!(pnl_key(&profit_and_loss_for_period(&b.l, s0, e0, None).unwrap()), pl); + prop_assert_eq!(bs_key(&balance_sheet_as_of(&b.l, e0).unwrap()), bs); + prop_assert_eq!(gl_key(&general_ledger(&b.l, s0, e0).unwrap()), gl); + prop_assert_eq!(cf_key(&cash_flow(&b.l, &cfp).unwrap()), cf); + prop_assert_eq!(snapshot(&b.l).unwrap(), snap); + } +} + +// =========================================================================== +// profit_and_loss_for_period — properties +// =========================================================================== + +proptest! { + #![proptest_config(ProptestConfig::with_cases(64))] + + /// Oracle: P&L INVARIANT 3 — partition: NI[a, c] = NI[a, m] + NI[m+1, c] + /// (also per-total), with or without a close. + #[test] + fn prop_pnl_partitions(gens in arb_books(), (a, m, e) in three_days(), month in opt_month()) { + prop_assume!(m < e); + let mut b = build(&gens); + close_through(&mut b, month, &re_close()); + let whole = profit_and_loss_for_period(&b.l, day(a), day(e), None).unwrap(); + let left = profit_and_loss_for_period(&b.l, day(a), day(m), None).unwrap(); + let right = profit_and_loss_for_period(&b.l, day(m + 1), day(e), None).unwrap(); + prop_assert_eq!(whole.net_income(), left.net_income() + right.net_income()); + prop_assert_eq!(whole.total_revenue, left.total_revenue + right.total_revenue); + prop_assert_eq!(whole.total_expenses, left.total_expenses + right.total_expenses); + } + + /// Oracle: P&L INVARIANTS 1-2 against the model — a line per revenue / + /// expense account with nonzero normal-side movement in the range, in + /// code order; totals are line sums; NI = revenue - expenses; closing + /// entries never counted (model has none). + #[test] + fn prop_pnl_matches_model_and_excludes_closes((a, _m, e) in three_days(), gens in arb_books(), month in opt_month()) { + let mut b = build(&gens); + close_through(&mut b, month, &re_close()); + let p = profit_and_loss_for_period(&b.l, day(a), day(e), None).unwrap(); + let (rev, exp) = model_pnl(&model(&gens, day(a), day(e), None)); + prop_assert_eq!(sl_nonzero(&p.revenue), rev); + prop_assert_eq!(sl_nonzero(&p.expenses), exp); + for l in &p.revenue { prop_assert_eq!(rtype(&l.code), Some(AccountType::Revenue)); } + for l in &p.expenses { prop_assert_eq!(rtype(&l.code), Some(AccountType::Expense)); } + for w in p.revenue.windows(2) { prop_assert!(w[0].code < w[1].code); } + for w in p.expenses.windows(2) { prop_assert!(w[0].code < w[1].code); } + prop_assert_eq!(p.total_revenue, sum(&p.revenue)); + prop_assert_eq!(p.total_expenses, sum(&p.expenses)); + prop_assert_eq!(p.net_income(), p.total_revenue - p.total_expenses); + } + + /// Oracle: P&L INVARIANT 2 / SCENARIO 1 — closing a period changes no + /// period P&L (books built twice, one closed). + #[test] + fn prop_pnl_unchanged_by_close(gens in arb_books(), month in 1u32..=12, (a, _m, e) in three_days()) { + let open = build(&gens); + let mut closed = build(&gens); + close_through(&mut closed, Some(month), &re_close()); + for (s0, e0) in [(day(a), day(e)), (d(2026, 1, 1), month_end(month)), (d(2026, 1, 1), d(2026, 12, 31))] { + prop_assert_eq!( + pnl_key(&profit_and_loss_for_period(&open.l, s0, e0, None).unwrap()), + pnl_key(&profit_and_loss_for_period(&closed.l, s0, e0, None).unwrap()) + ); + } + } + + /// Oracle: P&L INVARIANT 4 — with no close in the range, NI[s, e] equals + /// current earnings at e minus current earnings the day before s. + #[test] + fn prop_pnl_equals_change_in_current_earnings(gens in arb_books(), (a, _m, e) in three_days()) { + let b = build(&gens); + let p = profit_and_loss_for_period(&b.l, day(a), day(e), None).unwrap(); + let ce_end = balance_sheet_as_of(&b.l, day(e)).unwrap().current_earnings; + let ce_before = balance_sheet_as_of(&b.l, prev(day(a))).unwrap().current_earnings; + prop_assert_eq!(p.net_income(), ce_end - ce_before); + } + + /// Oracle: metamorphic scaling — every amount times k scales every P&L + /// line and total by k. + #[test] + fn prop_pnl_scales_linearly(gens in arb_books(), (a, _m, e) in three_days(), k in 2i64..=7) { + let kk = Decimal::from(k); + let one = profit_and_loss_for_period(&build(&gens).l, day(a), day(e), None).unwrap(); + let many = profit_and_loss_for_period(&build_scaled(&gens, k).l, day(a), day(e), None).unwrap(); + let scale = |v: Vec<(String, Decimal)>| v.into_iter().map(|(c0, x)| (c0, x * kk)).collect::>(); + prop_assert_eq!(sl_nonzero(&many.revenue), scale(sl_nonzero(&one.revenue))); + prop_assert_eq!(sl_nonzero(&many.expenses), scale(sl_nonzero(&one.expenses))); + prop_assert_eq!(many.net_income(), one.net_income() * kk); + } + + /// Oracle: P&L INVARIANT 5 against the model — with a class, the + /// statement is exactly the lines tagged with that class. + #[test] + fn prop_pnl_class_filter_matches_model(gens in arb_books(), (a, _m, e) in three_days()) { + let b = build_ordered(&gens, 1, &(0..N).collect::>(), true); + for (k, id) in [(1u8, b.class_a), (2u8, b.class_b)] { + let p = profit_and_loss_for_period(&b.l, day(a), day(e), Some(id)).unwrap(); + let (rev, exp) = model_pnl(&model(&gens, day(a), day(e), Some(k))); + prop_assert_eq!(sl_nonzero(&p.revenue), rev); + prop_assert_eq!(sl_nonzero(&p.expenses), exp); + } + } +} + +// =========================================================================== +// profit_and_loss_by_class — properties +// =========================================================================== + +proptest! { + #![proptest_config(ProptestConfig::with_cases(64))] + + /// Oracle: by-class INVARIANTS 1-2 — conservation: the rows (with + /// unclassified) sum to the whole P&L, each class row equals the model + /// of its tagged lines, the unclassified row the untagged lines; rows by + /// name, unclassified last. + #[test] + fn prop_pnl_by_class_rows_sum_to_whole(gens in arb_books()) { + let b = build_ordered(&gens, 1, &(0..N).collect::>(), true); + let rows = profit_and_loss_by_class(&b.l).unwrap(); + let whole = profit_and_loss(&b.l).unwrap(); + prop_assert_eq!(rows.iter().map(|r| r.profit_and_loss.revenue).sum::(), whole.revenue); + prop_assert_eq!(rows.iter().map(|r| r.profit_and_loss.expenses).sum::(), whole.expenses); + let (wr, we) = model_pnl(&model(&gens, early(), d(2030, 1, 1), None)); + prop_assert_eq!(whole.revenue, wr.iter().map(|x| x.1).sum::()); + prop_assert_eq!(whole.expenses, we.iter().map(|x| x.1).sum::()); + for r in &rows { + let k = match &r.class { + Some(cl) if cl.id == b.class_a => 1u8, + Some(cl) if cl.id == b.class_b => 2, + Some(_) => return Err(TestCaseError::fail("unknown class row")), + None => 0, + }; + let (mr, me) = model_pnl(&model(&gens, early(), d(2030, 1, 1), Some(k))); + prop_assert_eq!(r.profit_and_loss.revenue, mr.iter().map(|x| x.1).sum::()); + prop_assert_eq!(r.profit_and_loss.expenses, me.iter().map(|x| x.1).sum::()); + } + let names: Vec> = rows.iter().map(|r| r.class.as_ref().map(|k| k.name.clone())).collect(); + let mut tagged: Vec> = names.iter().filter(|n| n.is_some()).cloned().collect(); + tagged.sort(); + let unclassified = names.iter().filter(|n| n.is_none()).count(); + prop_assert!(unclassified <= 1); + if unclassified == 1 { prop_assert!(names.last().unwrap().is_none()); } + prop_assert_eq!(&names[..tagged.len()], &tagged[..]); + } +} + +proptest! { + #![proptest_config(ProptestConfig::with_cases(64))] + + /// Oracle: by-class INVARIANT 1 (conservation) — still holds after a + /// period close: the rows sum to the whole P&L. + #[test] + fn prop_pnl_by_class_conserves_after_close(gens in arb_books(), month in 1u32..=12) { + let mut b = build_ordered(&gens, 1, &(0..N).collect::>(), true); + close_through(&mut b, Some(month), &re_close()); + let rows = profit_and_loss_by_class(&b.l).unwrap(); + let whole = profit_and_loss(&b.l).unwrap(); + prop_assert_eq!(rows.iter().map(|r| r.profit_and_loss.revenue).sum::(), whole.revenue); + prop_assert_eq!(rows.iter().map(|r| r.profit_and_loss.expenses).sum::(), whole.expenses); + } +} + +// =========================================================================== +// balance_sheet_as_of — properties +// =========================================================================== + +proptest! { + #![proptest_config(ProptestConfig::with_cases(64))] + + /// Oracle: BS INVARIANT 1 / B-7 — assets = liabilities + equity (with + /// current earnings), exactly; totals are line sums; never a revenue or + /// expense line; with or without a close (RE or members). + #[test] + fn prop_bs_balances_on_every_date(gens in arb_books(), as_of in 0u32..365, month in opt_month(), members in any::()) { + let mut b = build(&gens); + let to = if members { members_close(&b, c(50)) } else { re_close() }; + close_through(&mut b, month, &to); + let s0 = balance_sheet_as_of(&b.l, day(as_of)).unwrap(); + prop_assert!(s0.balanced()); + prop_assert_eq!(s0.total_assets, s0.total_liabilities + s0.total_equity); + prop_assert_eq!(s0.total_assets, sum(&s0.assets)); + prop_assert_eq!(s0.total_liabilities, sum(&s0.liabilities)); + prop_assert_eq!(s0.total_equity, sum(&s0.equity) + s0.current_earnings); + for l in s0.assets.iter().chain(&s0.liabilities).chain(&s0.equity) { + prop_assert!(!matches!(rtype(&l.code), Some(AccountType::Revenue | AccountType::Expense)), "{:?}", l); + prop_assert!(l.code != "6900"); + } + } + + /// Oracle: BS INVARIANTS 2, 3, 5 against the model (no close) — each + /// group's lines are the nonzero normal-side balances in code order; + /// current earnings is revenue minus expenses through as_of. + #[test] + fn prop_bs_lines_match_model(gens in arb_books(), as_of in 0u32..365) { + let b = build(&gens); + let s0 = balance_sheet_as_of(&b.l, day(as_of)).unwrap(); + let m = model(&gens, early(), day(as_of), None); + let group = |t: AccountType| -> Vec<(String, Decimal)> { + m.iter() + .filter(|(k, v)| rtype(k) == Some(t) && !v.is_zero()) + .map(|(k, v)| (k.to_string(), if debit_normal(t) { *v } else { -*v })) + .collect() + }; + prop_assert_eq!(sl_nonzero(&s0.assets), group(AccountType::Asset)); + prop_assert_eq!(sl_nonzero(&s0.liabilities), group(AccountType::Liability)); + prop_assert_eq!(sl_nonzero(&s0.equity), group(AccountType::Equity)); + prop_assert_eq!(s0.current_earnings, model_ni(&m)); + } + + /// Oracle: BS INVARIANTS 2-3 — current earnings is only income not yet + /// closed: after a close through E, at as_of >= E it is income from E+1; + /// before E it is all income to date. + #[test] + fn prop_bs_current_earnings_is_unclosed_income(gens in arb_books(), as_of in 0u32..365, month in 1u32..=12) { + let mut b = build(&gens); + close_through(&mut b, Some(month), &re_close()); + let s0 = balance_sheet_as_of(&b.l, day(as_of)).unwrap(); + let from = if day(as_of) >= month_end(month) { month_end(month).succ_opt().unwrap() } else { early() }; + prop_assert_eq!(s0.current_earnings, model_ni(&model(&gens, from, day(as_of), None))); + if day(as_of) >= month_end(month) { + let re: Decimal = s0.equity.iter().filter(|l| l.code == "3999").map(|l| l.amount).sum(); + prop_assert_eq!(re, model_ni(&model(&gens, early(), month_end(month), None))); + } + } + + /// Oracle: metamorphic scaling — every amount times k scales every BS + /// line and total by k. + #[test] + fn prop_bs_scales_linearly(gens in arb_books(), as_of in 0u32..365, k in 2i64..=7) { + let kk = Decimal::from(k); + let one = balance_sheet_as_of(&build(&gens).l, day(as_of)).unwrap(); + let many = balance_sheet_as_of(&build_scaled(&gens, k).l, day(as_of)).unwrap(); + let scale = |v: Vec<(String, Decimal)>| v.into_iter().map(|(c0, x)| (c0, x * kk)).collect::>(); + prop_assert_eq!(sl_nonzero(&many.assets), scale(sl_nonzero(&one.assets))); + prop_assert_eq!(sl_nonzero(&many.liabilities), scale(sl_nonzero(&one.liabilities))); + prop_assert_eq!(sl_nonzero(&many.equity), scale(sl_nonzero(&one.equity))); + prop_assert_eq!(many.current_earnings, one.current_earnings * kk); + prop_assert_eq!(many.total_assets, one.total_assets * kk); + } +} + +// =========================================================================== +// general_ledger — properties +// =========================================================================== + +proptest! { + #![proptest_config(ProptestConfig::with_cases(64))] + + /// Oracle: GL INVARIANTS 1-2 — each account rolls forward (beginning + + /// normal-side postings = running balances = ending), totals are posting + /// sums, postings in range in (date, entry) order, one column each; + /// ending ties to the TB at `end` and beginning to the TB the day + /// before `start`; accounts in strict code order, every TB account at + /// `end` listed. + #[test] + fn prop_gl_rolls_forward_and_ties_to_tb(gens in arb_books(), (a, _m, e) in three_days(), month in opt_month()) { + let mut b = build(&gens); + close_through(&mut b, month, &re_close()); + let (s0, e0) = (day(a), day(e)); + let g = general_ledger(&b.l, s0, e0).unwrap(); + let tb_end: BTreeMap = trial_balance_as_of(&b.l, e0).lines.into_iter().map(|l| (l.code, (l.debit, l.credit))).collect(); + let tb_beg: BTreeMap = trial_balance_as_of(&b.l, prev(s0)).lines.into_iter().map(|l| (l.code, (l.debit, l.credit))).collect(); + for w in g.accounts.windows(2) { prop_assert!(w[0].code < w[1].code); } + let listed: BTreeSet<&str> = g.accounts.iter().map(|x| x.code.as_str()).collect(); + for code in tb_end.keys().chain(tb_beg.keys()) { prop_assert!(listed.contains(code.as_str()), "missing {}", code); } + for acct in &g.accounts { + let dn = debit_normal(acct.account_type); + let normal = |dr: Decimal, cr: Decimal| if dn { dr - cr } else { cr - dr }; + let mut bal = acct.beginning_balance; + let (mut td, mut tc) = (Decimal::ZERO, Decimal::ZERO); + let mut last: Option<(NaiveDate, EntryId)> = None; + for p in &acct.postings { + prop_assert!(p.date >= s0 && p.date <= e0); + prop_assert!(p.debit >= Decimal::ZERO && p.credit >= Decimal::ZERO); + prop_assert!(p.debit.is_zero() != p.credit.is_zero()); + if let Some(l) = last { prop_assert!(l <= (p.date, p.entry_id)); } + last = Some((p.date, p.entry_id)); + bal += normal(p.debit, p.credit); + prop_assert_eq!(p.balance, bal); + td += p.debit; + tc += p.credit; + } + prop_assert_eq!(acct.total_debits, td); + prop_assert_eq!(acct.total_credits, tc); + prop_assert_eq!(acct.ending_balance, bal); + let (ed, ec) = tb_end.get(&acct.code).copied().unwrap_or_default(); + prop_assert_eq!(acct.ending_balance, normal(ed, ec)); + let (bd, bc) = tb_beg.get(&acct.code).copied().unwrap_or_default(); + prop_assert_eq!(acct.beginning_balance, normal(bd, bc)); + } + } + + /// Oracle: GL INVARIANT 3 against the generated entries — each posting + /// is one generated line in range, with split "CODE Name" of the one + /// account on the other side, or "-Split-" when that side has several. + #[test] + fn prop_gl_postings_and_splits_match_entries(gens in arb_books(), (a, _m, e) in three_days()) { + let b = build(&gens); + let (s0, e0) = (day(a), day(e)); + let g = general_ledger(&b.l, s0, e0).unwrap(); + // Expected postings: (code, entry, debit, credit, split). + let mut want: Vec<(String, EntryId, Decimal, Decimal, String)> = Vec::new(); + for (g0, &id) in gens.iter().zip(&b.entries) { + if g0.date() < s0 || g0.date() > e0 { continue; } + let legs = g0.legs(1); + for &(i, side, cents) in &legs { + let others: Vec = legs.iter().filter(|l| l.1 != side).map(|l| l.0).collect(); + let split = if others.len() == 1 { format!("{} {}", RCHART[others[0]].0, RCHART[others[0]].1) } else { "-Split-".to_string() }; + let (dr, cr) = if side == DR { (c(cents), Decimal::ZERO) } else { (Decimal::ZERO, c(cents)) }; + want.push((RCHART[i].0.to_string(), id, dr, cr, split)); + } + } + let mut got: Vec<(String, EntryId, Decimal, Decimal, String)> = g.accounts.iter() + .flat_map(|acct| acct.postings.iter().map(move |p| (acct.code.clone(), p.entry_id, p.debit, p.credit, p.split.clone()))) + .collect(); + want.sort(); + got.sort(); + prop_assert_eq!(got, want); + } +} + +// =========================================================================== +// members_equity — properties +// =========================================================================== + +fn me_members(b: &Books, share_a: Decimal) -> Vec { + vec![ + MemberCapital { + name: "A".into(), + capital_account: b.ids[CAP_A], + distribution_account: None, + share: share_a, + }, + MemberCapital { + name: "B".into(), + capital_account: b.ids[CAP_B], + distribution_account: None, + share: Decimal::ONE - share_a, + }, + ] +} + +fn rolls(col: &MemberEquityColumn) -> bool { + col.beginning + col.contributions + col.net_income - col.distributions + col.other == col.ending +} + +fn total_is_sum(st: &MembersEquityStatement) -> bool { + let f = |g: fn(&MemberEquityColumn) -> Decimal| st.members.iter().map(g).sum::(); + st.total.beginning == f(|x| x.beginning) + && st.total.contributions == f(|x| x.contributions) + && st.total.net_income == f(|x| x.net_income) + && st.total.distributions == f(|x| x.distributions) + && st.total.other == f(|x| x.other) + && st.total.ending == f(|x| x.ending) +} + +proptest! { + #![proptest_config(ProptestConfig::with_cases(64))] + + /// Oracle: ME INVARIANTS 1-2 — every row (and the total) rolls forward + /// exactly; the total is the sum of the members; total ending equals BS + /// equity at `end`, total beginning equals BS equity the day before + /// `start`; with or without a members close. + #[test] + fn prop_me_rolls_forward_and_ties_to_bs(gens in arb_books(), (a, _m, e) in three_days(), pct in 1i64..100, month in opt_month()) { + let mut b = build(&gens); + let share_a = Decimal::new(pct, 2); + let to = members_close(&b, share_a); + close_through(&mut b, month, &to); + let st = members_equity(&b.l, day(a), day(e), &me_members(&b, share_a)).unwrap(); + prop_assert_eq!(st.members.len(), 2); + prop_assert_eq!(st.members[0].name.as_str(), "A"); + prop_assert_eq!(st.members[1].name.as_str(), "B"); + for col in st.members.iter().chain(std::iter::once(&st.total)) { prop_assert!(rolls(col), "{:?}", col); } + prop_assert!(total_is_sum(&st), "{:?}", st); + prop_assert_eq!(st.total.ending, balance_sheet_as_of(&b.l, day(e)).unwrap().total_equity); + prop_assert_eq!(st.total.beginning, balance_sheet_as_of(&b.l, prev(day(a))).unwrap().total_equity); + } + + /// Oracle: ME INVARIANT 3 — the period's income (closing entries + /// excluded: the model) is split by share to the cent by largest + /// remainder: parts sum to it and each is within one cent of its exact + /// share, and the larger exact remainder never gets less rounding. + #[test] + fn prop_me_period_income_split_by_share(gens in arb_books(), (a, _m, e) in three_days(), pct in 1i64..100, month in opt_month()) { + let mut b = build(&gens); + let share_a = Decimal::new(pct, 2); + let to = members_close(&b, share_a); + close_through(&mut b, month, &to); + let st = members_equity(&b.l, day(a), day(e), &me_members(&b, share_a)).unwrap(); + let ni = model_ni(&model(&gens, day(a), day(e), None)); + prop_assert_eq!(st.total.net_income, ni); + let cent = c(1); + for (col, share) in st.members.iter().zip([share_a, Decimal::ONE - share_a]) { + prop_assert!((col.net_income - ni * share).abs() < cent, "{:?} share {}", col, share); + } + } +} + +// =========================================================================== +// cash_flow — properties +// =========================================================================== + +proptest! { + #![proptest_config(ProptestConfig::with_cases(64))] + + /// Oracle: CF INVARIANTS 1-2, 4 — operating + investing + financing = + /// net change = ending - beginning cash (model), NI is the period P&L + /// (model, closes excluded), section totals are their line sums, CFO = + /// NI + depreciation + working capital; with or without a close and a + /// designated loan. + /// + /// ASSUMED: with no registered fixed assets, depreciation and investing + /// are zero (receivables are current, so working capital). + #[test] + fn prop_cf_sections_sum_to_change_in_cash(gens in arb_books(), (a, _m, e) in three_days(), month in opt_month(), designate in any::()) { + let mut b = build(&gens); + close_through(&mut b, month, &re_close()); + let params = CashFlowParams { + start: day(a), + end: day(e), + cash_accounts: vec![b.ids[CASH]], + financing_accounts: if designate { vec![b.ids[LOAN]] } else { vec![] }, + }; + let cf = cash_flow(&b.l, ¶ms).unwrap(); + prop_assert_eq!(cf.cash_from_operations + cf.cash_from_investing + cf.cash_from_financing, cf.net_change_in_cash); + prop_assert_eq!(cf.cash_ending - cf.cash_beginning, cf.net_change_in_cash); + let beg = model(&gens, early(), prev(day(a)), None).get("1000").copied().unwrap_or_default(); + let end = model(&gens, early(), day(e), None).get("1000").copied().unwrap_or_default(); + prop_assert_eq!(cf.cash_beginning, beg); + prop_assert_eq!(cf.cash_ending, end); + prop_assert_eq!(cf.net_income, model_ni(&model(&gens, day(a), day(e), None))); + prop_assert_eq!(cf.depreciation, Decimal::ZERO); + prop_assert_eq!(cf.cash_from_investing, Decimal::ZERO); + let wc: Decimal = cf.working_capital.iter().map(|l| l.amount).sum(); + prop_assert_eq!(cf.cash_from_operations, cf.net_income + cf.depreciation + wc); + prop_assert_eq!(cf.cash_from_investing, cf.investing.iter().map(|l| l.amount).sum::()); + prop_assert_eq!(cf.cash_from_financing, cf.financing.iter().map(|l| l.amount).sum::()); + } + + /// Oracle: CF INVARIANT 4 against the model — financing is the change in + /// equity accounts (other than earnings) plus designated debt; the rest + /// of the non-cash balance sheet is working capital. + #[test] + fn prop_cf_financing_is_equity_plus_designated_debt(gens in arb_books(), (a, _m, e) in three_days(), designate in any::()) { + let b = build(&gens); + let params = CashFlowParams { + start: day(a), + end: day(e), + cash_accounts: vec![b.ids[CASH]], + financing_accounts: if designate { vec![b.ids[LOAN]] } else { vec![] }, + }; + let cf = cash_flow(&b.l, ¶ms).unwrap(); + let m = model(&gens, day(a), day(e), None); + let credit = |code: &str| -m.get(code).copied().unwrap_or_default(); + let mut fin = credit("3000") + credit("3001"); + if designate { fin += credit("2500"); } + prop_assert_eq!(cf.cash_from_financing, fin); + } + + /// Oracle: CF INVARIANT 5 — closing a period changes no cash flow + /// statement (books built twice, one closed). + #[test] + fn prop_cf_unchanged_by_close(gens in arb_books(), month in 1u32..=12, (a, _m, e) in three_days()) { + let open = build(&gens); + let mut closed = build(&gens); + close_through(&mut closed, Some(month), &re_close()); + let p = |b: &Books| CashFlowParams { start: day(a), end: day(e), cash_accounts: vec![b.ids[CASH]], financing_accounts: vec![b.ids[LOAN]] }; + let x = cash_flow(&open.l, &p(&open)).unwrap(); + let y = cash_flow(&closed.l, &p(&closed)).unwrap(); + prop_assert_eq!(x.net_income, y.net_income); + prop_assert_eq!(x.cash_from_operations, y.cash_from_operations); + prop_assert_eq!(x.cash_from_investing, y.cash_from_investing); + prop_assert_eq!(x.cash_from_financing, y.cash_from_financing); + prop_assert_eq!(x.net_change_in_cash, y.net_change_in_cash); + } + + /// Oracle: metamorphic scaling — every amount times k scales every cash + /// flow total by k. + #[test] + fn prop_cf_scales_linearly(gens in arb_books(), (a, _m, e) in three_days(), k in 2i64..=7) { + let kk = Decimal::from(k); + let one = build(&gens); + let many = build_scaled(&gens, k); + let p = |b: &Books| CashFlowParams { start: day(a), end: day(e), cash_accounts: vec![b.ids[CASH]], financing_accounts: vec![] }; + let x = cash_flow(&one.l, &p(&one)).unwrap(); + let y = cash_flow(&many.l, &p(&many)).unwrap(); + prop_assert_eq!(y.net_income, x.net_income * kk); + prop_assert_eq!(y.cash_from_operations, x.cash_from_operations * kk); + prop_assert_eq!(y.cash_from_financing, x.cash_from_financing * kk); + prop_assert_eq!(y.cash_beginning, x.cash_beginning * kk); + prop_assert_eq!(y.cash_ending, x.cash_ending * kk); + } +} + +// =========================================================================== +// snapshot — properties +// =========================================================================== + +proptest! { + #![proptest_config(ProptestConfig::with_cases(48))] + + /// Oracle: snapshot INVARIANT 1 — identical JSON whatever order the + /// accounts were added in (ids differ, books the same), with or without + /// a close. + #[test] + fn prop_snapshot_invariant_under_account_order( + gens in arb_books(), + order in Just((0..N).collect::>()).prop_shuffle(), + month in opt_month(), + ) { + let mut x = build(&gens); + let mut y = build_ordered(&gens, 1, &order, false); + close_through(&mut x, month, &re_close()); + close_through(&mut y, month, &re_close()); + prop_assert_eq!(snapshot(&x.l).unwrap(), snapshot(&y.l).unwrap()); + } + + /// Oracle: snapshot INVARIANT 2 — never a float anywhere in the JSON. + #[test] + fn prop_snapshot_has_no_floats(gens in arb_books(), month in opt_month()) { + let mut b = build(&gens); + close_through(&mut b, month, &re_close()); + let snap = snapshot(&b.l).unwrap(); + prop_assert!(!has_float(&snap), "{}", snap); + } +} diff --git a/crates/ledger-store/src/store.rs b/crates/ledger-store/src/store.rs index 5762e76..8af53c1 100644 --- a/crates/ledger-store/src/store.rs +++ b/crates/ledger-store/src/store.rs @@ -2929,18 +2929,18 @@ pub(crate) fn replay_line_into_ledger( let foreign_amount: Decimal = get_str(p, "foreign_amount")? .parse() .map_err(|_| StoreError::JournalCorrupt("bad foreign amount".into()))?; - let fx_rate: Decimal = get_str(p, "fx_rate")? - .parse() - .map_err(|_| StoreError::JournalCorrupt("bad fx rate".into()))?; + // The journaled base amount is the record: lines booked + // before 2026-10-13 were not rounded to the cent, and + // replay keeps them exactly as booked. ledger.replay_add_line_fx( id, account, side, + Money::new(amount, currency), Currency::new(fc).map_err(|_| { StoreError::JournalCorrupt("bad foreign currency".into()) })?, foreign_amount, - fx_rate, )?; } None => { diff --git a/crates/ledger-store/tests/store.rs b/crates/ledger-store/tests/store.rs index 6615ca7..b0d0cbd 100644 --- a/crates/ledger-store/tests/store.rs +++ b/crates/ledger-store/tests/store.rs @@ -1043,6 +1043,13 @@ fn books_written_under_older_rules_still_open() { json!({"id": 1, "account": 999, "side": "debit", "amount": "10.00", "currency": "USD"}), ) .unwrap(); + // A foreign line booked before rounding to the cent (2026-10-13). + w.append( + "entry.line_added", + json!({"id": 1, "account": cash, "side": "credit", "amount": "108.34567", "currency": "USD", + "foreign_currency": "EUR", "foreign_amount": "100.00", "fx_rate": "1.0834567"}), + ) + .unwrap(); drop(w); // Opening rebuilds from the journal; it must accept all of it. let _ = std::fs::remove_file(dir.join(ledger_store::DB_FILE)); @@ -1050,5 +1057,11 @@ fn books_written_under_older_rules_still_open() { store.verify().unwrap(); assert_eq!(store.ledger().chart().len(), 2); assert_eq!(store.ledger().list_periods().len(), 2); - assert_eq!(store.ledger().get_entry(1).unwrap().lines().len(), 1); + let lines = store.ledger().get_entry(1).unwrap().lines(); + assert_eq!(lines.len(), 2); + // Replayed exactly as booked, not re-rounded. + assert_eq!( + lines[1].amount.amount, + "108.34567".parse::().unwrap() + ); } diff --git a/docs/oracle.md b/docs/oracle.md index adfb415..fbd87d1 100644 --- a/docs/oracle.md +++ b/docs/oracle.md @@ -79,7 +79,15 @@ periods, closing to net income), and a property test bridges the two. `just prove`; see `docs/verification.md`. Its first pass found three engine bugs that Layers 1–4 had all missed. -**Layer 5 — Cross-implementation diff harness (roadmap phase 3).** A +**Layer 5 — Cross-implementation diff harness (live: GnuCash).** +`just oracle` builds GnuCash 5.10 in a pinned container +(`oracle/gnucash/`) and runs `tests/oracle_gnucash.rs`: 200 random books +built through wanderbooks (entries, foreign lines, reversals, closes), +every account's balance at month ends and random dates, and each month's +P&L, compared exactly (15,800 comparisons). Its first run found one real +difference, adjudicated from principles: wanderbooks kept sub-cent +amounts on foreign lines; it now books in cents (rules.md B-3). The +original plan follows. A `ledger-oracle` crate will run fixture books through our engine and through other implementations (GnuCash headless first), then diff `snapshot()` output automatically: `cargo xtask oracle`. This is the direct analog of diff --git a/docs/spec/rules.md b/docs/spec/rules.md index c917086..8d9016d 100644 --- a/docs/spec/rules.md +++ b/docs/spec/rules.md @@ -59,8 +59,17 @@ that every line reads one way (2026-10-13, found by the formal pass). functional currency of the recording entity by use of the exchange rate in effect at that date." +A foreign line is booked in the base currency's smallest unit (ISO 4217 +minor units, `sources/iso-4217.md`), half away from zero; a ledger in one +money holds no fractions of its smallest unit, and its cash can then +match a bank statement: DECISION (Stephen, 2026-10-13), after the GnuCash +cross-check found wanderbooks kept $108.34567 where GnuCash booked +$108.35. Lines booked before then replay as booked. + **Enforced:** `Ledger::add_line` (base currency only), -`Ledger::add_line_fx` (base amount = foreign amount × rate, original kept). +`Ledger::add_line_fx` (base amount = foreign amount × rate, rounded to +the minor unit; original kept). **Checked:** +`a_foreign_line_is_booked_in_whole_base_units`; the GnuCash oracle. **Checked:** `foreign_currency_line_is_rejected`, `fx_overflow_is_an_error`. ## B-4 Five elements, closed list diff --git a/docs/spec/sources/iso-4217.md b/docs/spec/sources/iso-4217.md new file mode 100644 index 0000000..c44f54e --- /dev/null +++ b/docs/spec/sources/iso-4217.md @@ -0,0 +1,24 @@ +# ISO 4217: currency minor units + +Governs `Currency::minor_units`, which sets the smallest unit a ledger +books in (rules.md B-3): foreign-currency lines are rounded to it. + +Source: ISO 4217 "list one" (current currency & funds), published +2026-09-17 by SIX Financial Information, the ISO 4217 maintenance agency. +, +retrieved 2026-10-13, SHA-256 +`33139b438657d1cee116ba737807ea71d19d6de4b90f799a09c56f0cc6a1b0ff`. + +The list itself is ISO's, so it is not reproduced here; these are the facts +the code uses, generated from it (the `CcyMnrUnts` of each `Ccy`): + +| Minor units | Currencies | +|---|---| +| 0 | BIF CLP DJF GNF ISK JPY KMF KRW PYG RWF UGX UYI VND VUV XAF XOF XPF | +| 3 | BHD IQD JOD KWD LYD OMR TND | +| 4 | CLF UYW | +| none (N.A.) | XAG XAU XBA XBB XBC XBD XDR XPD XPT XSU XTS XUA XXX | +| 2 | the other 139 codes | + +To update: fetch the list again, regenerate the table and +`Currency::minor_units`, and record the new publication date and hash. diff --git a/justfile b/justfile index 688284f..c0383d1 100644 --- a/justfile +++ b/justfile @@ -40,6 +40,12 @@ prove: fi echo "Lean: $(wc -l <<<"$audit") headline theorems, standard axioms only" +# Cross-check against GnuCash (an oracle, never the spec): random books through +# wanderbooks and through GnuCash 5.10 in a pinned container. Needs Docker. +oracle: + docker build -q -t wanderbooks-gnucash-oracle oracle/gnucash + cargo test -p ledger-core --test oracle_gnucash -- --ignored --nocapture + # Build the release binaries (CLI, MCP server, GUI) build: cargo build --release -p ledger-cli -p ledger-mcp -p ledger-gui diff --git a/oracle/gnucash/Dockerfile b/oracle/gnucash/Dockerfile new file mode 100644 index 0000000..85d37a2 --- /dev/null +++ b/oracle/gnucash/Dockerfile @@ -0,0 +1,10 @@ +# GnuCash as a cross-check oracle (docs/oracle.md, "Who is right"): one +# independent implementation of double entry we diff against. Its answers +# are evidence, never the spec. Pinned so every run uses the same GnuCash. +FROM debian@sha256:a29215f6a35e51e22adffa17f89e9d2ef06214e64a2bad10d765c46aea49f11f +RUN apt-get update \ + && apt-get install -y --no-install-recommends \ + gnucash=1:5.10-0.1+b1 python3-gnucash=1:5.10-0.1+b1 \ + && rm -rf /var/lib/apt/lists/* +COPY books.py /oracle/books.py +ENTRYPOINT ["python3", "/oracle/books.py"] diff --git a/oracle/gnucash/books.py b/oracle/gnucash/books.py new file mode 100644 index 0000000..75cff3d --- /dev/null +++ b/oracle/gnucash/books.py @@ -0,0 +1,96 @@ +"""Build books in GnuCash and report its balances. + +stdin: a JSON list of books: + {"accounts": [{"id": 1, "code": "1000", "name": "Cash", "type": "Asset"}], + "entries": [{"date": "2026-01-15", "description": "...", + "lines": [{"account": 1, "side": "debit", "amount": "12.34"}]}], + "as_of": ["2026-01-31", ...]} +stdout: a JSON list, one per book: + {"balances": {"2026-01-31": {"1": "12.34", ...}, ...}} +Balances are GnuCash's own: the sum of split values on the account through +the end of that day, debits positive, as an exact decimal string. +""" +import datetime, json, os, sys, tempfile +from decimal import Decimal +import gnucash +from gnucash import Account, GncNumeric, Session, SessionOpenMode, Split, Transaction + +TYPES = { + "Asset": gnucash.ACCT_TYPE_ASSET, + "Liability": gnucash.ACCT_TYPE_LIABILITY, + "Equity": gnucash.ACCT_TYPE_EQUITY, + "Revenue": gnucash.ACCT_TYPE_INCOME, + "Expense": gnucash.ACCT_TYPE_EXPENSE, +} + + +def numeric(amount: str) -> GncNumeric: + d = Decimal(amount) + sign, digits, exp = d.as_tuple() + denom = 10 ** max(0, -exp) + num = int(d * denom) + return GncNumeric(num, denom) + + +def decimal_of(n) -> str: + return str(Decimal(n.num()) / Decimal(n.denom())) + + +def end_of_day(date: str) -> int: + d = datetime.date.fromisoformat(date) + return int(datetime.datetime(d.year, d.month, d.day, 23, 59, 59, + tzinfo=datetime.timezone.utc).timestamp()) + + +def run(book_spec, path): + s = Session(f"xml://{path}", SessionOpenMode.SESSION_NEW_STORE) + try: + book = s.book + root = book.get_root_account() + usd = book.get_table().lookup("CURRENCY", "USD") + accounts = {} + for a in book_spec["accounts"]: + acct = Account(book) + acct.SetName(f'{a["code"]} {a["name"]}') + acct.SetCode(a["code"]) + acct.SetType(TYPES[a["type"]]) + acct.SetCommodity(usd) + root.append_child(acct) + accounts[a["id"]] = acct + for e in book_spec["entries"]: + d = datetime.date.fromisoformat(e["date"]) + tx = Transaction(book) + tx.BeginEdit() + tx.SetCurrency(usd) + tx.SetDate(d.day, d.month, d.year) + tx.SetDescription(e.get("description", "")) + for line in e["lines"]: + value = numeric(line["amount"]) + if line["side"] == "credit": + value = value.neg() + sp = Split(book) + sp.SetParent(tx) + sp.SetAccount(accounts[line["account"]]) + sp.SetValue(value) + sp.SetAmount(value) + tx.CommitEdit() + out = {} + for date in book_spec["as_of"]: + t = end_of_day(date) + out[date] = {str(i): decimal_of(a.GetBalanceAsOfDate(t)) for i, a in accounts.items()} + return {"balances": out} + finally: + s.end() + + +def main(): + books = json.load(sys.stdin) + results = [] + with tempfile.TemporaryDirectory() as tmp: + for i, b in enumerate(books): + results.append(run(b, os.path.join(tmp, f"book{i}.gnucash"))) + json.dump(results, sys.stdout) + + +if __name__ == "__main__": + main()