From 11e33edc6013f02402afe8173649b0489a6e2330 Mon Sep 17 00:00:00 2001 From: Stephen Waits Date: Tue, 13 Oct 2026 21:17:02 -0600 Subject: [PATCH] import: spec-blind suite (40 tests); POSIX two-digit years; exact sub-cent text tests/spec_import.rs, written from the import crate's specs and sources alone: every statement foots as printed on random books (P&L, balance sheet with and without members, cash flow, trial balance, members' equity), format_amount round-trips, bank CSV round-trips in both layouts with date-order scenarios, IIF transactions sum to zero with every account declared and ASCII fields, XLSX cells read back through a small zip/DEFLATE reader, PDF labels with Typst markup render as text and the bytes are deterministic, chart CSV and templates. Its two findings, both fixed: - "1/15/69" parsed as 2069: chrono's %y cuts over at 70, POSIX strptime at 69 (69-99 are 1900s). Two-digit years are computed by POSIX now. - An amount finer than a cent went into an XLSX as rounded text ("1.01" for 1.005); it is written as its exact text now. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01GWVo7x3UHw5kwCUaWxiejX --- crates/ledger-import/src/bank_csv.rs | 22 +- crates/ledger-import/src/xlsx.rs | 22 +- crates/ledger-import/tests/spec_import.rs | 2356 +++++++++++++++++++++ 3 files changed, 2391 insertions(+), 9 deletions(-) create mode 100644 crates/ledger-import/tests/spec_import.rs diff --git a/crates/ledger-import/src/bank_csv.rs b/crates/ledger-import/src/bank_csv.rs index 4269039..eb64e97 100644 --- a/crates/ledger-import/src/bank_csv.rs +++ b/crates/ledger-import/src/bank_csv.rs @@ -195,15 +195,21 @@ fn parse_date(s: &str, order: DateOrder) -> Result { if let Ok(d) = NaiveDate::parse_from_str(s, "%Y-%m-%d") { return Ok(d); } - let (four, two) = match order { - DateOrder::MonthFirst => ("%m/%d/%Y", "%m/%d/%y"), - DateOrder::DayFirst => ("%d/%m/%Y", "%d/%m/%y"), + let four = match order { + DateOrder::MonthFirst => "%m/%d/%Y", + DateOrder::DayFirst => "%d/%m/%Y", }; - // A four-digit year first; a two-digit year follows POSIX (69-99 are - // 1900s), as chrono's %y does. - let year_digits = s.rsplit('/').next().map_or(0, str::len); - let format = if year_digits == 2 { two } else { four }; - NaiveDate::parse_from_str(s, format).map_err(|_| BankCsvError::BadDate(s.to_string())) + let bad = || BankCsvError::BadDate(s.to_string()); + let (rest, year) = s.rsplit_once('/').ok_or_else(bad)?; + if year.len() != 2 { + return NaiveDate::parse_from_str(s, four).map_err(|_| bad()); + } + // A two-digit year follows POSIX strptime %y: 69-99 are 1900s, 00-68 + // 2000s. (chrono's %y puts the cutoff at 70; found by the spec-blind + // suite, 2026-10-13.) + let yy: i32 = year.parse().map_err(|_| bad())?; + let full = if yy >= 69 { 1900 + yy } else { 2000 + yy }; + NaiveDate::parse_from_str(&format!("{rest}/{full}"), four).map_err(|_| bad()) } fn parse_amount(s: &str) -> Result { diff --git a/crates/ledger-import/src/xlsx.rs b/crates/ledger-import/src/xlsx.rs index e55643c..8092056 100644 --- a/crates/ledger-import/src/xlsx.rs +++ b/crates/ledger-import/src/xlsx.rs @@ -155,7 +155,7 @@ fn number( None => sheet.write_string_with_format( row, col, - crate::report::format_amount(d), + exact_text(d), &fmt.clone().set_align(rust_xlsxwriter::FormatAlign::Right), )?, }; @@ -395,6 +395,26 @@ fn general_journal( } /// Decimal to f64 at the presentation boundary. Rounded to cents first. +/// An amount as exact text: thousands grouped, every decimal it has (at +/// least two), a negative in parentheses. Never rounded. +fn exact_text(d: Decimal) -> String { + let places = d.normalize().scale().max(2) as usize; + let fixed = format!("{:.*}", places, d.abs()); + let (int, frac) = fixed.split_once('.').expect("decimals"); + let mut grouped = String::new(); + for (i, c) in int.chars().enumerate() { + if i > 0 && (int.len() - i) % 3 == 0 { + grouped.push(','); + } + grouped.push(c); + } + if d.is_sign_negative() && !d.is_zero() { + format!("({grouped}.{frac})") + } else { + format!("{grouped}.{frac}") + } +} + /// The double a cell holds for `d`, when Excel will show it exactly: whole /// cents, at most 15 significant digits. None otherwise. fn exact_f64(d: Decimal) -> Option { diff --git a/crates/ledger-import/tests/spec_import.rs b/crates/ledger-import/tests/spec_import.rs new file mode 100644 index 0000000..731e4b4 --- /dev/null +++ b/crates/ledger-import/tests/spec_import.rs @@ -0,0 +1,2356 @@ +//! Spec-blind tests for `ledger-import`. +//! +//! Every expected value here comes from the written specs (the ```text +//! GOAL/SOURCE/INVARIANTS/SCENARIOS blocks and the public doc comments) and +//! the sources they cite (docs/spec/sources/intuit-bank-csv.md, +//! intuit-iif.md, xlsx-numbers.md, irs-tax-lines.md), never from the +//! implementation. Each test names the clause it checks. +//! +//! The XLSX checks open the workbook with a small zip reader and DEFLATE +//! decoder written here (RFC 1951), so the cells themselves are the +//! evidence, not the writer's word. + +use std::collections::{BTreeMap, HashMap, HashSet}; + +use chrono::{Datelike, NaiveDate}; +use ledger_core::reports::{balance_sheet_as_of, profit_and_loss_for_period}; +use ledger_core::{ + AccountId, AccountType, CashFlowParams, Currency, EntryId, JournalEntry, Ledger, MemberCapital, + Money, Side, cash_flow, general_ledger, members_equity, trial_balance_as_of, +}; +use ledger_import::chart_csv::ChartCsvError; +use ledger_import::report::{self, Member, ReportMeta, Row, RowKind, Statement, YearEarnings}; +use ledger_import::{ + BankCsvError, BankTransaction, DateOrder, EntityType, IifAccount, IifError, QbAccountType, + XlsxReport, chart_template, format_amount, iif_accounts, journal_to_iif, parse_bank_csv, + parse_bank_csv_with, parse_chart_csv, report_to_xlsx, statement_pdf, +}; +use proptest::prelude::*; +use rust_decimal::{Decimal, RoundingStrategy}; + +// --------------------------------------------------------------------------- +// Shared helpers +// --------------------------------------------------------------------------- + +fn d(y: i32, m: u32, day: u32) -> NaiveDate { + NaiveDate::from_ymd_opt(y, m, day).unwrap() +} + +fn dec(s: &str) -> Decimal { + s.parse().unwrap() +} + +fn cents(c: i64) -> Decimal { + Decimal::new(c, 2) +} + +fn usd(c: i64) -> Money { + Money::new(cents(c), Currency::USD) +} + +fn meta() -> ReportMeta { + ReportMeta { + company: "Spec Test LLC".into(), + basis: "Accrual".into(), + prepared_on: d(2026, 10, 13), + journal_seq: 42, + } +} + +/// The chart every random book uses: every account type, a subaccount, a +/// name holding a colon, non-ASCII punctuation and letters, two members' +/// capital and distributions accounts, and one equity account that isn't a +/// member's (it feeds "Other changes"). +const CHART: &[(&str, &str, AccountType, Option<&str>)] = &[ + ("1000", "Cash", AccountType::Asset, None), + ("1010", "Reserve: Savings", AccountType::Asset, Some("1000")), + ("1100", "Receivables", AccountType::Asset, None), + ("1500", "Equipment", AccountType::Asset, None), + ("2000", "Card", AccountType::Liability, None), + ("2100", "Loan", AccountType::Liability, None), + ("3000", "Capital — Ada", AccountType::Equity, None), + ("3001", "Capital — Grace", AccountType::Equity, None), + ("3100", "Distributions — Ada", AccountType::Equity, None), + ("3101", "Distributions — Grace", AccountType::Equity, None), + ("3900", "Other equity", AccountType::Equity, None), + ("4000", "Sales", AccountType::Revenue, None), + ("4100", "Interest “income”", AccountType::Revenue, None), + ("6000", "Rent", AccountType::Expense, None), + ("6010", "Café meals", AccountType::Expense, None), + ("6020", "Fees", AccountType::Expense, None), +]; +const OTHER_EQUITY: usize = 10; + +/// One random balanced entry: a day offset from 2025-07-01, distinct +/// accounts (the first `k` debited, the rest credited), the debit amounts in +/// cents, and seeds that split their total across the credits. +#[derive(Clone, Debug)] +struct EntrySpec { + day: i64, + accts: Vec, + k: usize, + debits: Vec, + seeds: Vec, + memo: String, +} + +fn entry_spec() -> impl Strategy { + ( + 0i64..549, + proptest::sample::subsequence((0..CHART.len()).collect::>(), 2..=4), + any::(), + proptest::collection::vec(100i64..10_000_000_000, 3), + proptest::collection::vec(any::(), 3), + "[A-Za-z0-9 :\t\n\r#*$é—“”’]{0,16}", + ) + .prop_map(|(day, mut accts, shuffle, debits, seeds, memo)| { + // Rotate so either side may hold any account. + let r = (shuffle % accts.len() as u64) as usize; + accts.rotate_left(r); + let k = 1 + (shuffle / 7 % (accts.len() as u64 - 1)) as usize; + EntrySpec { + day, + accts, + k, + debits, + seeds, + memo, + } + }) +} + +struct Books { + ledger: Ledger, + ids: Vec, +} + +fn build_books(specs: &[EntrySpec], other_equity: bool) -> Books { + let mut l = Ledger::new(Currency::USD); + let mut ids = Vec::new(); + for (code, name, ty, parent) in CHART { + let p = parent.map(|pc| l.chart().find_by_code(pc).unwrap().id); + ids.push(l.add_account(code, name, *ty, p, false).unwrap()); + } + for s in specs { + if !other_equity && s.accts.contains(&OTHER_EQUITY) { + continue; + } + let date = d(2025, 7, 1) + chrono::Duration::days(s.day); + let e = l.create_entry(date, &s.memo); + let dr = &s.accts[..s.k]; + let cr = &s.accts[s.k..]; + let mut total = 0i64; + for (i, a) in dr.iter().enumerate() { + l.add_line(e, ids[*a], Side::Debit, usd(s.debits[i])) + .unwrap(); + total += s.debits[i]; + } + let m = cr.len(); + let mut remaining = total; + for (i, a) in cr.iter().enumerate() { + let part = if i + 1 == m { + remaining + } else { + let max = remaining - (m - 1 - i) as i64; + 1 + (s.seeds[i] % max as u64) as i64 + }; + remaining -= part; + l.add_line(e, ids[*a], Side::Credit, usd(part)).unwrap(); + } + l.post_entry(e).unwrap(); + } + Books { ledger: l, ids } +} + +fn members() -> Vec { + vec![ + Member { + name: "Ada".into(), + capital_code: "3000".into(), + distribution_code: Some("3100".into()), + }, + Member { + name: "Grace".into(), + capital_code: "3001".into(), + distribution_code: Some("3101".into()), + }, + ] +} + +fn member_capitals(b: &Books) -> Vec { + let half = dec("0.5"); + vec![ + MemberCapital { + name: "Ada".into(), + capital_account: b.ids[6], + distribution_account: Some(b.ids[8]), + share: half, + }, + MemberCapital { + name: "Grace".into(), + capital_account: b.ids[7], + distribution_account: Some(b.ids[9]), + share: half, + }, + ] +} + +fn amt(r: &Row, col: usize) -> Decimal { + r.amounts + .get(col) + .copied() + .flatten() + .unwrap_or(Decimal::ZERO) +} + +fn find<'a>(st: &'a Statement, kind: RowKind, label: &str) -> Option<&'a Row> { + st.rows.iter().find(|r| r.kind == kind && r.label == label) +} + +/// Footing, the reader's check: every subtotal that closes a section opened +/// by a Section heading equals the sum of the Line rows printed in that +/// section, column by column. Returns the (section, subtotal) pairs it +/// checked. +fn assert_sections_foot(st: &Statement) -> Vec<(String, String)> { + let mut checked = Vec::new(); + let mut section: Option = None; + let mut sums: Vec = vec![Decimal::ZERO; st.columns.len()]; + for r in &st.rows { + match r.kind { + RowKind::Section => { + section = Some(r.label.clone()); + sums = vec![Decimal::ZERO; st.columns.len()]; + } + RowKind::Line => { + for (c, s) in sums.iter_mut().enumerate() { + *s += amt(r, c); + } + } + RowKind::Subtotal | RowKind::Total => { + if let Some(sec) = section.take() { + for (c, s) in sums.iter().enumerate() { + if st.percent_from.is_some_and(|p| c >= p) { + continue; + } + assert_eq!( + amt(r, c), + *s, + "{}: lines of {sec:?} don't foot to {:?} (col {c})", + st.title, + r.label + ); + } + checked.push((sec, r.label.clone())); + } + sums = vec![Decimal::ZERO; st.columns.len()]; + } + RowKind::Subheading | RowKind::Spacer => {} + } + } + checked +} + +/// The Line rows of the section headed `label`. +fn section_lines<'a>(st: &'a Statement, label_contains: &str) -> Vec<&'a Row> { + let mut out = Vec::new(); + let mut inside = false; + for r in &st.rows { + match r.kind { + RowKind::Section => inside = r.label.contains(label_contains), + RowKind::Subtotal | RowKind::Total => inside = false, + RowKind::Line if inside => out.push(r), + _ => {} + } + } + out +} + +// --------------------------------------------------------------------------- +// report::format_amount +// --------------------------------------------------------------------------- + +fn unformat(s: &str) -> Decimal { + let neg = s.starts_with('(') && s.ends_with(')'); + let body: String = s + .trim_start_matches('(') + .trim_end_matches(')') + .chars() + .filter(|c| *c != ',') + .collect(); + let v: Decimal = body.parse().unwrap_or_else(|_| panic!("unparsable {s:?}")); + if neg { -v } else { v } +} + +fn assert_accounting_shape(s: &str) { + assert!(!s.contains('-'), "{s:?}: a negative must be in parentheses"); + let inner = s.trim_start_matches('(').trim_end_matches(')'); + assert_eq!( + s.starts_with('('), + s.ends_with(')'), + "{s:?}: unbalanced parentheses" + ); + let (int, frac) = inner.split_once('.').expect("a decimal point"); + assert_eq!(frac.len(), 2, "{s:?}: exactly two decimals"); + assert!(frac.chars().all(|c| c.is_ascii_digit())); + let groups: Vec<&str> = int.split(',').collect(); + assert!( + !groups[0].is_empty() && groups[0].len() <= 3, + "{s:?}: bad first group" + ); + assert!(groups[0].chars().all(|c| c.is_ascii_digit())); + assert!( + groups.len() == 1 || !groups[0].starts_with('0'), + "{s:?}: leading zero before a comma" + ); + for g in &groups[1..] { + assert_eq!(g.len(), 3, "{s:?}: thousands groups are three digits"); + assert!(g.chars().all(|c| c.is_ascii_digit())); + } +} + +/// format_amount INVARIANTS 1-3: two decimals rounded half away from zero, +/// commas by thousands, negatives in parentheses; never "(0.00)"; stripping +/// commas and parentheses gives back the amount rounded to the cent. +#[test] +fn format_amount_examples_from_the_spec() { + assert_eq!(format_amount(dec("1234.56")), "1,234.56"); + assert_eq!(format_amount(dec("-207.68")), "(207.68)"); + assert_eq!(format_amount(Decimal::ZERO), "0.00"); + // INVARIANT 2's own finding: -0.004 printed "(0.00)". + assert_eq!(format_amount(dec("-0.004")), "0.00"); + assert_eq!(format_amount(dec("-0.00")), "0.00"); + // Half away from zero, both signs. + assert_eq!(format_amount(dec("0.005")), "0.01"); + assert_eq!(format_amount(dec("-0.005")), "(0.01)"); + assert_eq!(format_amount(dec("2.675")), "2.68"); + assert_eq!(format_amount(dec("-1234567.895")), "(1,234,567.90)"); + assert_eq!(format_amount(dec("999.995")), "1,000.00"); + assert_eq!(format_amount(dec("100000")), "100,000.00"); + assert_eq!(format_amount(dec("12")), "12.00"); +} + +proptest! { + #![proptest_config(ProptestConfig::with_cases(2000))] + + /// format_amount INVARIANTS 1-3 over random amounts of any scale. + #[test] + fn format_amount_round_trips_to_the_cent( + m in -100_000_000_000_000_000_000i128..100_000_000_000_000_000_000i128, + scale in 0u32..=8, + ) { + let x = Decimal::from_i128_with_scale(m, scale); + let s = format_amount(x); + assert_accounting_shape(&s); + let rounded = x.round_dp_with_strategy(2, RoundingStrategy::MidpointAwayFromZero); + prop_assert_eq!(unformat(&s), rounded); + prop_assert!(s != "(0.00)"); + prop_assert_eq!(s.starts_with('('), rounded < Decimal::ZERO); + } +} + +// --------------------------------------------------------------------------- +// report::statement (finishing marks) +// --------------------------------------------------------------------------- + +#[derive(Clone, Debug)] +enum RowGen { + Section, + Subheading, + Line(Vec), + Subtotal(Vec), + Spacer, +} + +fn row_gen(cols: usize) -> impl Strategy { + prop_oneof![ + Just(RowGen::Section), + Just(RowGen::Subheading), + proptest::collection::vec(-1_000_000i64..1_000_000, cols).prop_map(RowGen::Line), + proptest::collection::vec(-1_000_000i64..1_000_000, cols).prop_map(RowGen::Subtotal), + Just(RowGen::Spacer), + ] +} + +proptest! { + #![proptest_config(ProptestConfig::with_cases(500))] + + /// statement INVARIANTS 1-3: currency signs exactly on subtotals and + /// totals; a rule above every total, and above a subtotal only when a + /// line or subtotal sits above it in its section; rows, labels, and + /// amounts kept exactly. + #[test] + fn statement_finishing_marks( + cols in 1usize..=3, + gens in proptest::collection::vec(row_gen(3), 0..30), + flags in proptest::collection::vec(any::<(bool, bool)>(), 31), + with_total in any::(), + ) { + let mut rows: Vec = gens + .iter() + .enumerate() + .map(|(i, g)| { + let (kind, amounts) = match g { + RowGen::Section => (RowKind::Section, vec![]), + RowGen::Subheading => (RowKind::Subheading, vec![]), + RowGen::Spacer => (RowKind::Spacer, vec![]), + RowGen::Line(a) => (RowKind::Line, a[..cols].iter().map(|c| Some(cents(*c))).collect()), + RowGen::Subtotal(a) => (RowKind::Subtotal, a[..cols].iter().map(|c| Some(cents(*c))).collect()), + }; + Row { kind, label: format!("row {i} *#$["), amounts, currency: flags[i].0, rule_above: flags[i].1 } + }) + .collect(); + if with_total { + rows.push(Row { + kind: RowKind::Total, + label: "Grand total".into(), + amounts: vec![Some(cents(1)); cols], + currency: false, + rule_above: false, + }); + } + let col_names: Vec = (0..cols).map(|c| format!("C{c}")).collect(); + let col_refs: Vec<&str> = col_names.iter().map(String::as_str).collect(); + let st = report::statement("Title", "Period".into(), &col_refs, rows.clone()); + prop_assert_eq!(&st.title, "Title"); + prop_assert_eq!(&st.period, "Period"); + prop_assert_eq!(&st.columns, &col_names); + prop_assert_eq!(st.rows.len(), rows.len()); + let mut amounts_in_section = false; + for (got, want) in st.rows.iter().zip(&rows) { + prop_assert_eq!(got.kind, want.kind); + prop_assert_eq!(&got.label, &want.label); + prop_assert_eq!(&got.amounts, &want.amounts); + let is_sum = matches!(got.kind, RowKind::Subtotal | RowKind::Total); + prop_assert_eq!(got.currency, is_sum, "currency on {:?}", got.label); + match got.kind { + RowKind::Total => prop_assert!(got.rule_above), + RowKind::Subtotal => prop_assert_eq!(got.rule_above, amounts_in_section, "rule on {:?}", got.label), + _ => {} + } + match got.kind { + RowKind::Section => amounts_in_section = false, + RowKind::Line | RowKind::Subtotal => amounts_in_section = true, + _ => {} + } + } + } +} + +/// statement INVARIANT 2: a subtotal straight under its heading (nothing to +/// sum) gets no rule. +#[test] +fn statement_subtotal_with_nothing_above_has_no_rule() { + let row = |kind, label: &str, a: Vec>| Row { + kind, + label: label.into(), + amounts: a, + currency: false, + rule_above: true, + }; + let st = report::statement( + "T", + "P".into(), + &["Total"], + vec![ + row(RowKind::Section, "Investing", vec![]), + row( + RowKind::Subtotal, + "Net investing", + vec![Some(Decimal::ZERO)], + ), + ], + ); + assert!(!st.rows[1].rule_above); + assert!(st.rows[1].currency); + assert!(!st.rows[0].currency); +} + +// --------------------------------------------------------------------------- +// The five statements, from random books +// --------------------------------------------------------------------------- + +fn books_strategy() -> impl Strategy, bool, i64)> { + ( + proptest::collection::vec(entry_spec(), 0..25), + any::(), + 0i64..365, + ) +} + +proptest! { + #![proptest_config(ProptestConfig::with_cases(128))] + + /// profit_and_loss INVARIANTS 1-2: revenue lines foot to "Total revenue", + /// expense lines to "Total expenses", "Net income (loss)" = revenue - + /// expenses; one "CODE Name" line per account in the statement's order. + #[test] + fn pl_foots_as_printed((specs, oe, end_off) in books_strategy()) { + let b = build_books(&specs, oe); + let end = d(2026, 1, 1) + chrono::Duration::days(end_off); + let pl = profit_and_loss_for_period(&b.ledger, d(2026, 1, 1), end, None).unwrap(); + let st = report::profit_and_loss(&pl); + assert_sections_foot(&st); + let rev = find(&st, RowKind::Subtotal, "Total revenue").map_or(Decimal::ZERO, |r| amt(r, 0)); + let exp = find(&st, RowKind::Subtotal, "Total expenses").map_or(Decimal::ZERO, |r| amt(r, 0)); + let net = find(&st, RowKind::Total, "Net income (loss)").expect("a net income total"); + prop_assert_eq!(amt(net, 0), rev - exp); + prop_assert_eq!(rev, pl.total_revenue); + prop_assert_eq!(exp, pl.total_expenses); + // INVARIANT 2: one line per account, "CODE Name", in order. + let want: Vec<(String, Decimal)> = pl.revenue.iter().chain(&pl.expenses) + .map(|l| (format!("{} {}", l.code, l.name), l.amount)).collect(); + let got: Vec<(String, Decimal)> = st.rows.iter().filter(|r| r.kind == RowKind::Line) + .map(|r| (r.label.clone(), amt(r, 0))).collect(); + prop_assert_eq!(got, want); + } + + /// balance_sheet INVARIANTS 1-3 (with and without members): every + /// section foots; total L+E = total liabilities + total equity = total + /// assets; members get "Members' equity" with each capital account + /// listed, otherwise "Retained earnings" and the year's "Net income + /// (loss)" (prose: RE plus earlier years' unclosed earnings; the year's + /// unclosed income). + #[test] + fn balance_sheet_foots_and_balances((specs, oe, end_off) in books_strategy()) { + let b = build_books(&specs, oe); + let as_of = d(2026, 1, 1) + chrono::Duration::days(end_off); + let bs = balance_sheet_as_of(&b.ledger, as_of).unwrap(); + let ytd = profit_and_loss_for_period(&b.ledger, d(2026, 1, 1), as_of, None).unwrap().net_income(); + // No closes in these books: the whole year is unclosed. + let ye = YearEarnings { net_income_ytd: ytd, unclosed: ytd }; + for with_members in [false, true] { + let ms = if with_members { members() } else { vec![] }; + let st = report::balance_sheet(&bs, ye, &ms); + let checked = assert_sections_foot(&st); + let (eq_section, eq_total_label, total_label) = if with_members { + ("Members' equity", "Total members' equity", "Total liabilities and members' equity") + } else { + ("Equity", "Total equity", "Total liabilities and equity") + }; + prop_assert!(checked.iter().any(|(s, _)| s == eq_section), "{:?}", checked); + let ta = amt(find(&st, RowKind::Subtotal, "Total assets").expect("Total assets"), 0); + let tl = amt(find(&st, RowKind::Subtotal, "Total liabilities").expect("Total liabilities"), 0); + let te = checked.iter().find(|(s, _)| s == eq_section).map(|(_, t)| t.clone()).unwrap(); + prop_assert_eq!(&te, eq_total_label); + let te = amt(find(&st, RowKind::Subtotal, &te).unwrap(), 0); + let tle = amt(find(&st, RowKind::Total, total_label).expect("L+E total"), 0); + prop_assert_eq!(tle, tl + te); + prop_assert_eq!(tle, ta); + prop_assert_eq!(ta, bs.total_assets); + prop_assert_eq!(te, bs.total_equity); + // INVARIANT 3 and the prose on the earnings lines. + let eq_lines = section_lines(&st, eq_section); + let earnings = |label: &str| eq_lines.iter().find(|r| r.label == label).map_or(Decimal::ZERO, |r| amt(r, 0)); + let prior_label = if with_members { "Undistributed earnings, prior years" } else { "Retained earnings" }; + prop_assert_eq!(earnings("Net income (loss)"), ytd); + prop_assert_eq!(earnings(prior_label), bs.current_earnings - ytd); + for l in &bs.equity { + prop_assert!(eq_lines.iter().any(|r| r.label == format!("{} {}", l.code, l.name) && amt(r, 0) == l.amount), + "equity account {} {} not listed", l.code, l.name); + } + } + } + + /// cash_flow INVARIANTS 1-3: operating lines (net income, depreciation, + /// working capital) foot to "Net cash from operating activities", + /// investing and financing to theirs; the three sum to "Net increase + /// (decrease) in cash"; beginning cash plus it is "Cash at end of + /// period"; a member's capital/distributions line is "Member + /// contributions — Name" (cash in) or "Member distributions — Name" + /// (cash out). + #[test] + fn cash_flow_foots_and_reconciles((specs, oe, end_off) in books_strategy()) { + let b = build_books(&specs, oe); + let end = d(2026, 1, 1) + chrono::Duration::days(end_off); + let cf = cash_flow(&b.ledger, &CashFlowParams { + start: d(2026, 1, 1), + end, + cash_accounts: vec![b.ids[0], b.ids[1]], + financing_accounts: vec![b.ids[5]], + investing_accounts: vec![b.ids[3]], + }).unwrap(); + let ms = members(); + let st = report::cash_flow(&cf, &ms); + let checked = assert_sections_foot(&st); + prop_assert_eq!(checked.len(), 3, "three sections: {:?}", checked); + let op = find(&st, RowKind::Subtotal, "Net cash from operating activities").expect("operating subtotal"); + prop_assert!(checked.iter().any(|(_, t)| t == &op.label)); + let three: Decimal = checked.iter().map(|(_, t)| amt(st.rows.iter().find(|r| &r.label == t).unwrap(), 0)).sum(); + let net = st.rows.iter().find(|r| r.label == "Net increase (decrease) in cash").expect("net change row"); + prop_assert_eq!(amt(net, 0), three); + let begin = st.rows.iter().find(|r| r.kind == RowKind::Line && r.label.to_lowercase().contains("beginning")).expect("beginning cash"); + let endrow = st.rows.iter().find(|r| r.label == "Cash at end of period").expect("ending cash"); + prop_assert_eq!(amt(begin, 0) + amt(net, 0), amt(endrow, 0)); + // Ties to the core numbers it lays out. + prop_assert_eq!(amt(op, 0), cf.cash_from_operations); + prop_assert_eq!(amt(begin, 0), cf.cash_beginning); + prop_assert_eq!(amt(endrow, 0), cf.cash_ending); + // INVARIANT 3: members named on their capital/distributions lines. + let mut fin: Vec<(String, Decimal)> = section_lines(&st, "Financing").iter().map(|r| (r.label.clone(), amt(r, 0))).collect(); + for line in &cf.financing { + let Some(code) = &line.account else { continue }; + let Some(m) = ms.iter().find(|m| &m.capital_code == code || m.distribution_code.as_ref() == Some(code)) else { continue }; + let want = if line.amount > Decimal::ZERO { + format!("Member contributions — {}", m.name) + } else { + format!("Member distributions — {}", m.name) + }; + let pos = fin.iter().position(|(l, a)| *l == want && *a == line.amount); + prop_assert!(pos.is_some(), "no {:?} {} among {:?}", want, line.amount, fin); + fin.remove(pos.unwrap()); + } + } + + /// trial_balance INVARIANTS 1-2: each column's lines foot to its total, + /// the totals are equal (balanced books), and a line shows its balance + /// on one side only, the other blank. + #[test] + fn trial_balance_foots((specs, oe, end_off) in books_strategy()) { + let b = build_books(&specs, oe); + let as_of = d(2026, 1, 1) + chrono::Duration::days(end_off); + let tb = trial_balance_as_of(&b.ledger, as_of); + let st = report::trial_balance(&tb, as_of); + prop_assert_eq!(&st.columns, &vec!["Debit".to_string(), "Credit".to_string()]); + let lines: Vec<&Row> = st.rows.iter().filter(|r| r.kind == RowKind::Line).collect(); + prop_assert_eq!(lines.len(), tb.lines.len()); + for (r, l) in lines.iter().zip(&tb.lines) { + prop_assert_eq!(r.amounts.len(), 2); + prop_assert!(r.amounts[0].is_some() != r.amounts[1].is_some(), "one side only: {:?}", r); + prop_assert_eq!(amt(r, 0), l.debit); + prop_assert_eq!(amt(r, 1), l.credit); + } + let total = st.rows.iter().find(|r| r.kind == RowKind::Total).expect("a total row"); + let dr: Decimal = lines.iter().map(|r| amt(r, 0)).sum(); + let cr: Decimal = lines.iter().map(|r| amt(r, 1)).sum(); + prop_assert_eq!(amt(total, 0), dr); + prop_assert_eq!(amt(total, 1), cr); + prop_assert_eq!(dr, cr); + } + + /// members_equity INVARIANTS 1-3: in every column beginning + + /// contributions + net income + distributions (printed negative) + other + /// = ending; members' columns sum to Total on every row; "Other changes" + /// appears only when some column has any. + #[test] + fn members_equity_rolls_forward((specs, oe, end_off) in books_strategy()) { + let b = build_books(&specs, oe); + let end = d(2026, 1, 1) + chrono::Duration::days(end_off); + let me = members_equity(&b.ledger, d(2026, 1, 1), end, &member_capitals(&b)).unwrap(); + let st = report::members_equity(&me); + prop_assert_eq!(st.columns.len(), 3); + prop_assert_eq!(&st.columns[0], "Ada"); + prop_assert_eq!(&st.columns[1], "Grace"); + let with_amounts: Vec<&Row> = st.rows.iter().filter(|r| r.amounts.iter().any(Option::is_some)).collect(); + let (ending, flows) = with_amounts.split_last().expect("rows"); + for c in 0..3 { + let sum: Decimal = flows.iter().map(|r| amt(r, c)).sum(); + prop_assert_eq!(sum, amt(ending, c), "column {} doesn't roll forward", c); + } + for r in &with_amounts { + prop_assert_eq!(amt(r, 0) + amt(r, 1), amt(r, 2), "row {:?} doesn't cross-foot", r.label); + } + let cols: Vec<_> = me.members.iter().chain(std::iter::once(&me.total)).collect(); + let dist = st.rows.iter().find(|r| r.label.starts_with("Distributions")).expect("distributions row"); + for (c, col) in cols.iter().enumerate() { + prop_assert_eq!(amt(flows[0], c), col.beginning); + prop_assert_eq!(amt(ending, c), col.ending); + prop_assert_eq!(amt(dist, c), -col.distributions, "distributions print as reductions"); + } + let any_other = cols.iter().any(|c| !c.other.is_zero()); + prop_assert_eq!(st.rows.iter().any(|r| r.label.starts_with("Other changes")), any_other); + } +} + +/// members_equity INVARIANT 3, the absent case: books with no other equity +/// movement print no "Other changes" row. +#[test] +fn members_equity_without_other_changes_has_no_other_row() { + let b = build_books(&[], false); + let mut l = b.ledger; + let e = l.create_entry(d(2026, 2, 1), "contribution"); + l.add_line(e, b.ids[0], Side::Debit, usd(10_000)).unwrap(); + l.add_line(e, b.ids[6], Side::Credit, usd(10_000)).unwrap(); + l.post_entry(e).unwrap(); + let b = Books { + ledger: l, + ids: b.ids, + }; + let me = members_equity( + &b.ledger, + d(2026, 1, 1), + d(2026, 12, 31), + &member_capitals(&b), + ) + .unwrap(); + let st = report::members_equity(&me); + assert!(!st.rows.iter().any(|r| r.label.starts_with("Other changes"))); +} + +// --------------------------------------------------------------------------- +// bank_csv +// --------------------------------------------------------------------------- + +fn group(int: u64) -> String { + let s = int.to_string(); + let mut out = String::new(); + for (i, ch) in s.chars().enumerate() { + if i > 0 && (s.len() - i).is_multiple_of(3) { + out.push(','); + } + out.push(ch); + } + out +} + +/// A positive cent amount in one of the spellings bank exports use: +/// plain, thousands commas, a dollar sign. +fn spell_pos(c: i64, style: u8) -> String { + let (i, f) = (c as u64 / 100, c as u64 % 100); + match style % 4 { + 0 => format!("{i}.{f:02}"), + 1 => format!("{}.{f:02}", group(i)), + 2 => format!("${}.{f:02}", group(i)), + _ => format!("${i}.{f:02}"), + } +} + +/// A signed amount for the 3-column layout: negatives as "-" or in +/// parentheses. +fn spell_signed(c: i64, style: u8) -> String { + if c >= 0 { + return spell_pos(c, style); + } + let p = spell_pos(-c, style); + match (style / 4) % 3 { + 0 if !p.starts_with('$') => format!("-{p}"), + _ => format!("({p})"), + } +} + +#[derive(Clone, Debug)] +struct Txn { + date: NaiveDate, + desc: String, + cents: i64, + style: u8, +} + +fn txn() -> impl Strategy { + ( + 0i64..3000, + "[A-Za-z0-9][A-Za-z0-9 ,\"&'#]{0,18}[A-Za-z0-9]", + prop_oneof![1i64..100_000_000_000, -100_000_000_000i64..0], + any::(), + ) + .prop_map(|(day, desc, cents, style)| Txn { + date: d(2020, 1, 1) + chrono::Duration::days(day), + desc, + cents, + style, + }) +} + +#[derive(Clone, Copy, Debug)] +enum DateStyle { + Iso, + MonthFirst, + DayFirst, +} + +fn spell_date(dt: NaiveDate, s: DateStyle) -> String { + match s { + DateStyle::Iso => dt.format("%Y-%m-%d").to_string(), + DateStyle::MonthFirst => format!("{}/{}/{}", dt.month(), dt.day(), dt.year()), + DateStyle::DayFirst => format!("{}/{}/{}", dt.day(), dt.month(), dt.year()), + } +} + +/// Write `rows` as CSV text (the csv crate quotes as needed). +fn csv_text(rows: &[Vec]) -> String { + let mut w = csv::WriterBuilder::new().flexible(true).from_writer(vec![]); + for r in rows { + w.write_record(r).unwrap(); + } + String::from_utf8(w.into_inner().unwrap()).unwrap() +} + +fn expected(txns: &[Txn]) -> Vec { + txns.iter() + .map(|t| BankTransaction { + date: t.date, + description: t.desc.clone(), + amount: cents(t.cents), + }) + .collect() +} + +/// Make the file settle its own order: one date with a day over 12. +fn settle(txns: &mut Vec) { + if !txns.iter().any(|t| t.date.day() > 12) { + txns.push(Txn { + date: d(2026, 1, 25), + desc: "Settler".into(), + cents: 1234, + style: 0, + }); + } +} + +proptest! { + #![proptest_config(ProptestConfig::with_cases(300))] + + /// bank_csv INVARIANT 1 (3 columns: payment negative, deposit positive), + /// INVARIANT 2 (zero rows skipped), INVARIANT 3 (a part over 12 settles + /// the order; a caller order that contradicts it is refused), and the + /// CONVENTION ($, thousands commas, parentheses; optional header). + #[test] + fn bank_csv_three_columns_round_trip( + mut txns in proptest::collection::vec(txn(), 1..30), + style in 0u8..3, + header in any::(), + zeros in proptest::collection::vec((0usize..40, 0u8..3), 0..4), + ) { + let ds = [DateStyle::Iso, DateStyle::MonthFirst, DateStyle::DayFirst][style as usize]; + if !matches!(ds, DateStyle::Iso) { settle(&mut txns); } + let mut rows: Vec> = txns.iter() + .map(|t| vec![spell_date(t.date, ds), t.desc.clone(), spell_signed(t.cents, t.style)]) + .collect(); + for (at, z) in &zeros { + let zero = ["0.00", "0", "$0.00"][*z as usize]; + rows.insert(at % (rows.len() + 1), vec![spell_date(d(2026, 1, 28), ds), "zero".into(), zero.into()]); + } + if header { rows.insert(0, vec!["Date".into(), "Description".into(), "Amount".into()]); } + let text = csv_text(&rows); + let want = expected(&txns); + prop_assert_eq!(parse_bank_csv(&text).unwrap(), want.clone()); + match ds { + DateStyle::Iso => {} + DateStyle::MonthFirst => { + prop_assert_eq!(parse_bank_csv_with(&text, Some(DateOrder::MonthFirst)).unwrap(), want); + prop_assert!(parse_bank_csv_with(&text, Some(DateOrder::DayFirst)).is_err()); + } + DateStyle::DayFirst => { + prop_assert_eq!(parse_bank_csv_with(&text, Some(DateOrder::DayFirst)).unwrap(), want); + prop_assert!(parse_bank_csv_with(&text, Some(DateOrder::MonthFirst)).is_err()); + } + } + } + + /// bank_csv INVARIANT 1 (4 columns: money out third, money in fourth, + /// each positive with the other blank, read by position whatever the + /// header says) and INVARIANT 2 (a zero row, written blank or as 0.00, + /// is skipped). + #[test] + fn bank_csv_four_columns_round_trip( + txns in proptest::collection::vec(txn(), 1..30), + header in 0u8..3, + zeros in proptest::collection::vec((0usize..40, any::()), 0..4), + ) { + let mut rows: Vec> = txns.iter() + .map(|t| { + let a = spell_pos(t.cents.abs(), t.style); + let (out, inn) = if t.cents < 0 { (a, String::new()) } else { (String::new(), a) }; + vec![spell_date(t.date, DateStyle::Iso), t.desc.clone(), out, inn] + }) + .collect(); + for (at, blank) in &zeros { + let z = if *blank { "" } else { "0.00" }; + rows.insert(at % (rows.len() + 1), vec!["2026-01-28".into(), "zero".into(), z.into(), "".into()]); + } + match header { + 1 => rows.insert(0, vec!["Date".into(), "Description".into(), "Credit".into(), "Debit".into()]), + 2 => rows.insert(0, vec!["Date".into(), "Description".into(), "Debit".into(), "Credit".into()]), + _ => {} + } + prop_assert_eq!(parse_bank_csv(&csv_text(&rows)).unwrap(), expected(&txns)); + } + + /// bank_csv INVARIANT 3: a file whose slash dates all read both ways + /// needs the caller's order, and then reads in it. + #[test] + fn bank_csv_ambiguous_dates_need_the_callers_order( + days in proptest::collection::vec((1u32..=12, 1u32..=12), 1..10), + month_first in any::(), + ) { + let mut days = days; + days.push((3, 4)); // at least one date that reads both ways + let order = if month_first { DateOrder::MonthFirst } else { DateOrder::DayFirst }; + let rows: Vec> = days.iter() + .map(|(m, dd)| { + let dt = d(2026, *m, *dd); + let ds = if month_first { DateStyle::MonthFirst } else { DateStyle::DayFirst }; + vec![spell_date(dt, ds), "x".into(), "1.00".into()] + }) + .collect(); + let text = csv_text(&rows); + prop_assert!(matches!(parse_bank_csv(&text), Err(BankCsvError::DateOrderUnknown(_)))); + let got = parse_bank_csv_with(&text, Some(order)).unwrap(); + let want: Vec = days.iter().map(|(m, dd)| d(2026, *m, *dd)).collect(); + prop_assert_eq!(got.iter().map(|t| t.date).collect::>(), want); + } +} + +/// bank_csv SOURCE: Intuit's own 3-column and 4-column example tables, +/// transcribed in intuit-bank-csv.md (payment -100.00, deposit 200.00; in 4 +/// columns the payment sits under Credit, the deposit under Debit). +#[test] +fn bank_csv_intuit_examples() { + let three = "Date,Description,Amount\n1/1/2018,Example of a payment,-100.00\n1/1/2018,Example of a deposit,200.00\n"; + let four = "Date,Description,Credit,Debit\n1/1/2018,Example of a payment,100.00,\n1/1/2018,Example of a deposit,,200.00\n"; + let want = vec![ + BankTransaction { + date: d(2018, 1, 1), + description: "Example of a payment".into(), + amount: dec("-100.00"), + }, + BankTransaction { + date: d(2018, 1, 1), + description: "Example of a deposit".into(), + amount: dec("200.00"), + }, + ]; + for order in [DateOrder::DayFirst, DateOrder::MonthFirst] { + assert_eq!(parse_bank_csv_with(three, Some(order)).unwrap(), want); + assert_eq!(parse_bank_csv_with(four, Some(order)).unwrap(), want); + } +} + +/// bank_csv INVARIANT 3 scenarios: a part over 12 settles the order for the +/// whole file (so 3/4 follows it); a file settling both ways is refused; +/// ISO dates need no order. +#[test] +fn bank_csv_date_order_scenarios() { + let day_first = "13/4/2026,a,1.00\n3/4/2026,b,2.00\n"; + let got = parse_bank_csv(day_first).unwrap(); + assert_eq!(got[0].date, d(2026, 4, 13)); + assert_eq!(got[1].date, d(2026, 4, 3)); + + let month_first = "4/13/2026,a,1.00\n3/4/2026,b,2.00\n"; + let got = parse_bank_csv(month_first).unwrap(); + assert_eq!(got[0].date, d(2026, 4, 13)); + assert_eq!(got[1].date, d(2026, 3, 4)); + + let both = "13/4/2026,a,1.00\n4/13/2026,b,2.00\n"; + assert!(matches!( + parse_bank_csv(both), + Err(BankCsvError::DatesDisagree(_, _)) + )); + assert!(parse_bank_csv_with(both, Some(DateOrder::MonthFirst)).is_err()); + assert!(parse_bank_csv_with(both, Some(DateOrder::DayFirst)).is_err()); + + let ambiguous = "3/4/2026,a,1.00\n"; + assert!(matches!( + parse_bank_csv(ambiguous), + Err(BankCsvError::DateOrderUnknown(_)) + )); + assert_eq!( + parse_bank_csv_with(ambiguous, Some(DateOrder::MonthFirst)).unwrap()[0].date, + d(2026, 3, 4) + ); + assert_eq!( + parse_bank_csv_with(ambiguous, Some(DateOrder::DayFirst)).unwrap()[0].date, + d(2026, 4, 3) + ); + + let iso = "2026-03-04,a,1.00\n"; + assert_eq!(parse_bank_csv(iso).unwrap()[0].date, d(2026, 3, 4)); +} + +fn posix_mismatches(years: impl Iterator) -> Vec<(i32, NaiveDate)> { + years + .filter_map(|yy| { + let text = format!("1/15/{yy:02},a,1.00\n"); + let got = parse_bank_csv(&text).unwrap()[0].date; + let want = d(if yy >= 69 { 1900 + yy } else { 2000 + yy }, 1, 15); + (got != want).then_some((yy, got)) + }) + .collect() +} + +/// bank_csv INVARIANT 4: a two-digit year follows POSIX (69-99 are 1900s, +/// 00-68 2000s): every year but the 69 boundary. +#[test] +fn bank_csv_two_digit_years_follow_posix() { + assert_eq!(posix_mismatches((0..69).chain(70..100)), vec![]); +} + +/// bank_csv INVARIANT 4 at its boundary: "69" is 1969. +#[test] +fn bank_csv_two_digit_year_69_is_1969() { + assert_eq!(posix_mismatches(69..70), vec![]); +} + +/// bank_csv INVARIANTS 2 and 5: never a fraction of a cent, a double +/// negative ("(-5)", "--5"), or both of a 4-column row's amounts. +#[test] +fn bank_csv_refuses_what_it_must() { + for bad in [ + "1.005", + "-0.001", + "$1,234.567", + "(-5)", + "--5", + "(-5.00)", + "--5.00", + ] { + let text = format!("2026-01-05,x,\"{bad}\"\n"); + assert!( + matches!(parse_bank_csv(&text), Err(BankCsvError::BadAmount(_))), + "accepted {bad:?}: {:?}", + parse_bank_csv(&text) + ); + } + let both = "2026-01-05,x,5.00,6.00\n"; + assert!( + parse_bank_csv(both).is_err(), + "accepted both 4-column amounts" + ); + let both_hdr = "Date,Description,Credit,Debit\n2026-01-05,x,5.00,6.00\n"; + assert!(parse_bank_csv(both_hdr).is_err()); +} + +/// bank_csv INVARIANT 2: a zero row is skipped (3-col zero, 4-col blank +/// cells); the rest keep file order. +#[test] +fn bank_csv_zero_rows_are_skipped() { + let three = "Date,Description,Amount\n2026-01-01,a,0.00\n2026-01-02,b,-3.50\n2026-01-03,c,0\n"; + let got = parse_bank_csv(three).unwrap(); + assert_eq!(got.len(), 1); + assert_eq!(got[0].amount, dec("-3.50")); + let four = "2026-01-01,a,,\n2026-01-02,b,,3.50\n2026-01-03,c,0.00,\n"; + let got = parse_bank_csv(four).unwrap(); + assert_eq!(got.len(), 1); + assert_eq!(got[0].amount, dec("3.50")); +} + +/// bank_csv INVARIANT 1: neither 3 nor 4 columns is refused with the count. +#[test] +fn bank_csv_wrong_column_count() { + assert_eq!( + parse_bank_csv("2026-01-01,a\n"), + Err(BankCsvError::BadColumnCount(2)) + ); + assert_eq!( + parse_bank_csv("2026-01-01,a,1.00,,x\n"), + Err(BankCsvError::BadColumnCount(5)) + ); +} + +/// bank_csv prose: the header is the first non-blank row when its first cell +/// says "date"; leading blank lines don't count. +#[test] +fn bank_csv_header_after_blank_lines() { + let text = "\n\nDate,Description,Amount\n2026-02-03,Coffee,\"($1,234.56)\"\n"; + let got = parse_bank_csv(text).unwrap(); + assert_eq!( + got, + vec![BankTransaction { + date: d(2026, 2, 3), + description: "Coffee".into(), + amount: dec("-1234.56"), + }] + ); +} + +// --------------------------------------------------------------------------- +// iif +// --------------------------------------------------------------------------- + +fn qb(a: &ledger_core::Account) -> QbAccountType { + match a.account_type { + AccountType::Asset if a.code.starts_with("10") => QbAccountType::Bank, + AccountType::Asset if a.code == "1500" => QbAccountType::FixedAsset, + AccountType::Asset => QbAccountType::OtherCurrentAsset, + AccountType::Liability if a.code == "2000" => QbAccountType::CreditCard, + AccountType::Liability => QbAccountType::OtherCurrentLiability, + AccountType::Equity => QbAccountType::Equity, + AccountType::Revenue => QbAccountType::Income, + AccountType::Expense => QbAccountType::Expense, + } +} + +/// Check an IIF document against iif.rs INVARIANTS 1-4 and the layout, +/// for `entries` written with `accounts`. +fn check_iif(text: &str, accounts: &[IifAccount], entries: &[&JournalEntry]) { + // INVARIANT 4: ASCII only. + assert!(text.is_ascii(), "non-ASCII in IIF"); + let lines: Vec<&str> = text + .split('\n') + .map(|l| l.strip_suffix('\r').unwrap_or(l)) + .filter(|l| !l.is_empty()) + .collect(); + for l in &lines { + assert!(!l.contains('\r'), "stray CR inside a line: {l:?}"); + } + let keywords = [ + "!ACCNT", "ACCNT", "!TRNS", "!SPL", "!ENDTRNS", "TRNS", "SPL", "ENDTRNS", + ]; + let mut headers: HashMap<&str, Vec<&str>> = HashMap::new(); + let mut declared: HashSet = HashSet::new(); + let mut seen_trns_header = false; + let mut txns: Vec>> = Vec::new(); + let mut current: Option>> = None; + for l in &lines { + let f: Vec<&str> = l.split('\t').collect(); + let kw = f[0]; + // INVARIANT 4: no line break inside a field means every line starts + // with a keyword. + assert!(keywords.contains(&kw), "line starts with {kw:?}: {l:?}"); + if let Some(h) = kw.strip_prefix('!') { + headers.insert(h, f.clone()); + if kw == "!TRNS" { + seen_trns_header = true; + } + continue; + } + // INVARIANT 4: no tab inside a field: as many fields as the header. + let h = headers + .get(kw) + .unwrap_or_else(|| panic!("{kw} before its header")); + assert_eq!( + f.len(), + h.len(), + "{kw} has {} fields, header {}: {l:?}", + f.len(), + h.len() + ); + match kw { + "ACCNT" => { + // INVARIANT 1: every account precedes the transactions. + assert!(!seen_trns_header, "ACCNT after !TRNS"); + let name = f[h.iter().position(|c| *c == "NAME").unwrap()]; + declared.insert(name.to_string()); + } + "TRNS" => { + assert!(current.is_none(), "TRNS inside a transaction"); + current = Some(vec![f]); + } + "SPL" => current.as_mut().expect("SPL outside TRNS").push(f), + "ENDTRNS" => txns.push(current.take().expect("ENDTRNS without TRNS")), + _ => unreachable!(), + } + } + assert!(current.is_none(), "unterminated transaction"); + assert_eq!( + declared.len(), + accounts.len(), + "every account declared once" + ); + let col = |kw: &str, name: &str| -> usize { + headers[kw] + .iter() + .position(|c| *c == name) + .unwrap_or_else(|| panic!("{kw} lacks {name}")) + }; + // ACCNT rows carry each account's path, QuickBooks type, and number. + let accnt_rows: Vec> = lines + .iter() + .map(|l| l.split('\t').collect::>()) + .filter(|f| f[0] == "ACCNT") + .collect(); + for a in accounts { + let row = accnt_rows + .iter() + .find(|f| f[col("ACCNT", "NAME")] == a.path) + .unwrap_or_else(|| panic!("{} not declared", a.path)); + assert_eq!(row[col("ACCNT", "ACCNTTYPE")], a.qb_type.keyword()); + assert_eq!(row[col("ACCNT", "ACCNUM")], a.number); + } + let path_of: HashMap = + accounts.iter().map(|a| (a.id, a.path.as_str())).collect(); + assert_eq!(txns.len(), entries.len(), "one transaction per entry"); + for (i, (t, e)) in txns.iter().zip(entries).enumerate() { + let mut sum = Decimal::ZERO; + let mut got: Vec<(String, Decimal)> = Vec::new(); + for (j, f) in t.iter().enumerate() { + let kw = if j == 0 { "TRNS" } else { "SPL" }; + // INVARIANT 3: GENERAL JOURNAL, dated M/D/YYYY. + assert_eq!(f[col(kw, "TRNSTYPE")], "GENERAL JOURNAL"); + let dt = e.date(); + assert_eq!( + f[col(kw, "DATE")], + format!("{}/{}/{}", dt.month(), dt.day(), dt.year()) + ); + assert_eq!(f[col(kw, "DOCNUM")], (i + 1).to_string(), "DOCNUM from 1"); + let acct = f[col(kw, "ACCNT")]; + // INVARIANT 1/5: every account used is declared. + assert!(declared.contains(acct), "{acct:?} used but not declared"); + let a: Decimal = f[col(kw, "AMOUNT")].parse().unwrap(); + assert_eq!(a.scale(), 2, "two decimal places: {}", f[col(kw, "AMOUNT")]); + sum += a; + got.push((acct.to_string(), a)); + } + // INVARIANT 3: debits positive, credits negative, sum zero. + assert_eq!( + sum, + Decimal::ZERO, + "transaction {} doesn't sum to zero", + i + 1 + ); + let mut want: Vec<(String, Decimal)> = e + .lines() + .iter() + .map(|l| { + let a = l.amount.amount; + let signed = if l.side == Side::Debit { a } else { -a }; + (path_of[&l.account].to_string(), signed) + }) + .collect(); + got.sort(); + want.sort(); + assert_eq!(got, want, "entry {} lines", e.id()); + } +} + +/// The colon path an account should have: its own name and its parents', +/// highest first (each with any colon replaced, so the depth is exact). +fn depth(ledger: &Ledger, mut id: AccountId) -> usize { + let mut n = 0; + while let Some(p) = ledger.chart().get(id).unwrap().parent { + n += 1; + id = p; + } + n +} + +proptest! { + #![proptest_config(ProptestConfig::with_cases(128))] + + /// iif INVARIANTS 1-4 over random books: accounts declared before the + /// transactions; colon paths; TRNS/SPL/ENDTRNS, GENERAL JOURNAL, + /// M/D/YYYY, debits positive and credits negative summing to zero; + /// ASCII only, no tab or line break inside a field. + #[test] + fn iif_round_trips_random_books((specs, oe, _) in books_strategy()) { + let b = build_books(&specs, oe); + let accounts = iif_accounts(&b.ledger, qb); + prop_assert_eq!(accounts.len(), b.ledger.chart().len()); + let entries: Vec<&JournalEntry> = b.ledger.posted_entries().collect(); + let text = journal_to_iif(&accounts, &entries).unwrap(); + check_iif(&text, &accounts, &entries); + // INVARIANT 2: a subaccount's NAME is its path, parents first, one + // colon per level, the parent's path as its prefix. + for a in &accounts { + let acct = b.ledger.chart().get(a.id).unwrap(); + prop_assert_eq!(&a.number, &acct.code); + prop_assert_eq!(a.qb_type, qb(acct)); + prop_assert_eq!(a.path.matches(':').count(), depth(&b.ledger, a.id), "{:?}", a.path); + if let Some(p) = acct.parent { + let pp = &accounts.iter().find(|x| x.id == p).unwrap().path; + prop_assert!(a.path.starts_with(&format!("{pp}:")), "{:?} under {:?}", a.path, pp); + } + } + } +} + +/// iif INVARIANT 2: "Reserve: Savings" under "Cash" is one level down, its +/// own colon replaced; INVARIANT 4: common punctuation is transliterated +/// (curly quotes, em dash), anything else becomes '?'. +#[test] +fn iif_paths_and_transliteration() { + let b = build_books(&[], true); + let accounts = iif_accounts(&b.ledger, qb); + let path = |code: &str| { + let id = b.ledger.chart().find_by_code(code).unwrap().id; + accounts.iter().find(|a| a.id == id).unwrap().path.clone() + }; + let reserve = path("1010"); + assert!(reserve.starts_with("Cash:"), "{reserve:?}"); + assert_eq!(reserve.matches(':').count(), 1, "{reserve:?}"); + assert!(reserve.ends_with("Savings")); + for a in &accounts { + assert!(a.path.is_ascii(), "{:?}", a.path); + } + // Curly quotes are common punctuation: transliterated, not '?'. + assert_eq!(path("4100"), "Interest \"income\""); + // The em dash too. + let cap = path("3000"); + assert!( + cap.starts_with("Capital ") && cap.ends_with(" Ada"), + "{cap:?}" + ); + assert!(!cap.contains('?'), "em dash became '?': {cap:?}"); + // A letter with no ASCII form is not punctuation: '?'. + assert_eq!(path("6010"), "Caf? meals"); +} + +/// iif layout example (module docs): a two-line sale on 1/15/2026 writes +/// TRNS Cash 1000.00 then SPL Revenue -1000.00, DOCNUM 1, memo "Sale", each +/// line's tabs matching its header. +#[test] +fn iif_module_layout_example() { + let mut l = Ledger::new(Currency::USD); + let cash = l + .add_account("1000", "Cash", AccountType::Asset, None, false) + .unwrap(); + let rev = l + .add_account("4000", "Revenue", AccountType::Revenue, None, false) + .unwrap(); + let e = l.create_entry(d(2026, 1, 15), "Sale"); + l.add_line(e, cash, Side::Debit, usd(100_000)).unwrap(); + l.add_line(e, rev, Side::Credit, usd(100_000)).unwrap(); + l.post_entry(e).unwrap(); + let accounts = iif_accounts(&l, |a| { + if a.code == "1000" { + QbAccountType::Bank + } else { + QbAccountType::Income + } + }); + let entries: Vec<&JournalEntry> = l.posted_entries().collect(); + let text = journal_to_iif(&accounts, &entries).unwrap(); + let lines: Vec<&str> = text.lines().map(|l| l.trim_end_matches('\r')).collect(); + assert_eq!(lines[0], "!ACCNT\tNAME\tACCNTTYPE\tACCNUM"); + assert!(lines.contains(&"ACCNT\tCash\tBANK\t1000")); + assert!(lines.contains(&"ACCNT\tRevenue\tINC\t4000")); + assert!(lines.contains(&"TRNS\t\tGENERAL JOURNAL\t1/15/2026\tCash\t\t1000.00\t1\tSale")); + assert!(lines.contains(&"SPL\t\tGENERAL JOURNAL\t1/15/2026\tRevenue\t\t-1000.00\t1\tSale")); + check_iif(&text, &accounts, &entries); +} + +/// iif INVARIANT 4: a memo holding tabs, line breaks, and non-ASCII still +/// writes one ASCII line per record with the header's field count. +#[test] +fn iif_memo_with_tabs_and_breaks() { + let mut l = Ledger::new(Currency::USD); + let cash = l + .add_account("1000", "Cash", AccountType::Asset, None, false) + .unwrap(); + let rev = l + .add_account("4000", "Revenue", AccountType::Revenue, None, false) + .unwrap(); + let e = l.create_entry(d(2026, 3, 9), "a\tb\nc\r\nd — “é” ’"); + l.add_line(e, cash, Side::Debit, usd(1)).unwrap(); + l.add_line(e, rev, Side::Credit, usd(1)).unwrap(); + l.post_entry(e).unwrap(); + let accounts = iif_accounts(&l, |_| QbAccountType::Bank); + let entries: Vec<&JournalEntry> = l.posted_entries().collect(); + let text = journal_to_iif(&accounts, &entries).unwrap(); + check_iif(&text, &accounts, &entries); +} + +/// iif INVARIANT 5: an entry whose account wasn't declared is refused, +/// naming the account. +#[test] +fn iif_refuses_an_undeclared_account() { + let spec = EntrySpec { + day: 200, + accts: vec![0, 11], + k: 1, + debits: vec![500, 0, 0], + seeds: vec![0, 0, 0], + memo: "x".into(), + }; + let b = build_books(&[spec], false); + let mut accounts = iif_accounts(&b.ledger, qb); + let sales = b.ids[11]; + accounts.retain(|a| a.id != sales); + let entries: Vec<&JournalEntry> = b.ledger.posted_entries().collect(); + let eid: EntryId = entries[0].id(); + assert_eq!( + journal_to_iif(&accounts, &entries), + Err(IifError::UndeclaredAccount { + entry: eid, + account: sales + }) + ); +} + +/// QbAccountType::keyword: the IIF ACCNTTYPE keywords the doc comments name. +#[test] +fn qb_account_type_keywords() { + use QbAccountType::*; + let want = [ + (Bank, "BANK"), + (AccountsReceivable, "AR"), + (OtherCurrentAsset, "OCASSET"), + (FixedAsset, "FIXASSET"), + (OtherAsset, "OASSET"), + (AccountsPayable, "AP"), + (CreditCard, "CCARD"), + (OtherCurrentLiability, "OCLIAB"), + (LongTermLiability, "LTLIAB"), + (Equity, "EQUITY"), + (Income, "INC"), + (CostOfGoodsSold, "COGS"), + (Expense, "EXP"), + (OtherIncome, "EXINC"), + (OtherExpense, "EXEXP"), + ]; + for (t, k) in want { + assert_eq!(t.keyword(), k); + } +} + +// --------------------------------------------------------------------------- +// XLSX: a minimal zip reader and DEFLATE decoder (RFC 1951) +// --------------------------------------------------------------------------- + +struct Bits<'a> { + data: &'a [u8], + pos: usize, + buf: u32, + n: u32, +} + +impl Bits<'_> { + fn need(&mut self, k: u32) -> u32 { + while self.n < k { + self.buf |= (self.data[self.pos] as u32) << self.n; + self.pos += 1; + self.n += 8; + } + let v = if k == 0 { + 0 + } else { + self.buf & ((1u32 << k) - 1) + }; + self.buf = if k >= 32 { 0 } else { self.buf >> k }; + self.n -= k; + v + } +} + +struct Huff { + counts: [i32; 16], + symbols: Vec, +} + +impl Huff { + fn new(lengths: &[u8]) -> Huff { + let mut counts = [0i32; 16]; + for &l in lengths { + counts[l as usize] += 1; + } + counts[0] = 0; + let mut offs = [0usize; 16]; + for i in 1..16 { + offs[i] = offs[i - 1] + counts[i - 1] as usize; + } + let mut symbols = vec![0u16; lengths.len()]; + for (s, &l) in lengths.iter().enumerate() { + if l != 0 { + symbols[offs[l as usize]] = s as u16; + offs[l as usize] += 1; + } + } + Huff { counts, symbols } + } + + fn decode(&self, b: &mut Bits) -> u16 { + let (mut code, mut first, mut index) = (0i32, 0i32, 0i32); + for len in 1..16 { + code |= b.need(1) as i32; + let count = self.counts[len]; + if code - count < first { + return self.symbols[(index + (code - first)) as usize]; + } + index += count; + first += count; + first <<= 1; + code <<= 1; + } + panic!("bad huffman code"); + } +} + +const LBASE: [u16; 29] = [ + 3, 4, 5, 6, 7, 8, 9, 10, 11, 13, 15, 17, 19, 23, 27, 31, 35, 43, 51, 59, 67, 83, 99, 115, 131, + 163, 195, 227, 258, +]; +const LEXT: [u8; 29] = [ + 0, 0, 0, 0, 0, 0, 0, 0, 1, 1, 1, 1, 2, 2, 2, 2, 3, 3, 3, 3, 4, 4, 4, 4, 5, 5, 5, 5, 0, +]; +const DBASE: [u16; 30] = [ + 1, 2, 3, 4, 5, 7, 9, 13, 17, 25, 33, 49, 65, 97, 129, 193, 257, 385, 513, 769, 1025, 1537, + 2049, 3073, 4097, 6145, 8193, 12289, 16385, 24577, +]; +const DEXT: [u8; 30] = [ + 0, 0, 0, 0, 1, 1, 2, 2, 3, 3, 4, 4, 5, 5, 6, 6, 7, 7, 8, 8, 9, 9, 10, 10, 11, 11, 12, 12, 13, + 13, +]; + +fn inflate(data: &[u8]) -> Vec { + let mut out: Vec = Vec::new(); + let mut b = Bits { + data, + pos: 0, + buf: 0, + n: 0, + }; + loop { + let last = b.need(1); + match b.need(2) { + 0 => { + b.buf = 0; + b.n = 0; + let len = u16::from_le_bytes([data[b.pos], data[b.pos + 1]]) as usize; + b.pos += 4; + out.extend_from_slice(&data[b.pos..b.pos + len]); + b.pos += len; + } + t @ (1 | 2) => { + let (lit, dist) = if t == 1 { + let mut l = [0u8; 288]; + for (i, x) in l.iter_mut().enumerate() { + *x = match i { + 0..=143 => 8, + 144..=255 => 9, + 256..=279 => 7, + _ => 8, + }; + } + (Huff::new(&l), Huff::new(&[5u8; 30])) + } else { + let hlit = b.need(5) as usize + 257; + let hdist = b.need(5) as usize + 1; + let hclen = b.need(4) as usize + 4; + const ORDER: [usize; 19] = [ + 16, 17, 18, 0, 8, 7, 9, 6, 10, 5, 11, 4, 12, 3, 13, 2, 14, 1, 15, + ]; + let mut cl = [0u8; 19]; + for &o in ORDER.iter().take(hclen) { + cl[o] = b.need(3) as u8; + } + let clh = Huff::new(&cl); + let mut lens: Vec = Vec::new(); + while lens.len() < hlit + hdist { + let sym = clh.decode(&mut b); + match sym { + 0..=15 => lens.push(sym as u8), + 16 => { + let prev = *lens.last().unwrap(); + for _ in 0..3 + b.need(2) { + lens.push(prev); + } + } + 17 => lens.extend(std::iter::repeat_n(0, 3 + b.need(3) as usize)), + _ => lens.extend(std::iter::repeat_n(0, 11 + b.need(7) as usize)), + } + } + (Huff::new(&lens[..hlit]), Huff::new(&lens[hlit..])) + }; + loop { + let sym = lit.decode(&mut b) as usize; + if sym < 256 { + out.push(sym as u8); + } else if sym == 256 { + break; + } else { + let i = sym - 257; + let len = LBASE[i] as usize + b.need(LEXT[i] as u32) as usize; + let ds = dist.decode(&mut b) as usize; + let dd = DBASE[ds] as usize + b.need(DEXT[ds] as u32) as usize; + let start = out.len() - dd; + for k in 0..len { + out.push(out[start + k]); + } + } + } + } + _ => panic!("bad block type"), + } + if last == 1 { + return out; + } + } +} + +fn le16(b: &[u8], at: usize) -> usize { + u16::from_le_bytes([b[at], b[at + 1]]) as usize +} +fn le32(b: &[u8], at: usize) -> usize { + u32::from_le_bytes([b[at], b[at + 1], b[at + 2], b[at + 3]]) as usize +} + +/// Every file in a zip, by name, read through the central directory. +fn unzip(z: &[u8]) -> BTreeMap> { + let eocd = (0..=z.len() - 22) + .rev() + .find(|&i| z[i..i + 4] == [0x50, 0x4b, 0x05, 0x06]) + .expect("end of central directory"); + let count = le16(z, eocd + 10); + let mut p = le32(z, eocd + 16); + let mut files = BTreeMap::new(); + for _ in 0..count { + assert_eq!(&z[p..p + 4], &[0x50, 0x4b, 0x01, 0x02]); + let method = le16(z, p + 10); + let csize = le32(z, p + 20); + let (nl, el, cl) = (le16(z, p + 28), le16(z, p + 30), le16(z, p + 32)); + let local = le32(z, p + 42); + let name = String::from_utf8(z[p + 46..p + 46 + nl].to_vec()).unwrap(); + let data_at = local + 30 + le16(z, local + 26) + le16(z, local + 28); + let raw = &z[data_at..data_at + csize]; + let body = match method { + 0 => raw.to_vec(), + 8 => inflate(raw), + m => panic!("zip method {m}"), + }; + files.insert(name, body); + p += 46 + nl + el + cl; + } + files +} + +fn unescape(s: &str) -> String { + s.replace("<", "<") + .replace(">", ">") + .replace(""", "\"") + .replace("'", "'") + .replace("&", "&") +} + +/// Text of every `` inside `xml`, concatenated. +fn t_text(xml: &str) -> String { + let mut out = String::new(); + let mut rest = xml; + while let Some(i) = rest.find("') || after.starts_with(' ')) { + rest = after; + continue; + } + let open_end = after.find('>').unwrap(); + if after[..open_end].ends_with('/') { + rest = &after[open_end + 1..]; + continue; + } + let body = &after[open_end + 1..]; + let close = body.find("").unwrap(); + out.push_str(&unescape(&body[..close])); + rest = &body[close + 4..]; + } + out +} + +#[derive(Debug, Clone, PartialEq)] +enum Cell { + Num(String), + Text(String), +} + +/// The first worksheet's cells: (row, column index from 0) -> value. +fn sheet_cells(xlsx: &[u8]) -> BTreeMap<(u32, u32), Cell> { + let files = unzip(xlsx); + let shared: Vec = files + .get("xl/sharedStrings.xml") + .map(|b| { + let s = String::from_utf8(b.clone()).unwrap(); + s.split("").skip(1).map(t_text).collect() + }) + .unwrap_or_default(); + let sheet = String::from_utf8(files["xl/worksheets/sheet1.xml"].clone()).unwrap(); + let mut cells = BTreeMap::new(); + let mut rest = sheet.as_str(); + while let Some(i) = rest.find("').unwrap(); + let attrs = &after[..tag_end]; + let attr = |name: &str| -> Option { + let k = format!("{name}=\""); + attrs + .find(&k) + .map(|p| attrs[p + k.len()..].split('"').next().unwrap().to_string()) + }; + let r = attr("r").unwrap(); + let letters: String = r.chars().take_while(|c| c.is_ascii_alphabetic()).collect(); + let row: u32 = r[letters.len()..].parse().unwrap(); + let col = letters + .chars() + .fold(0u32, |a, c| a * 26 + (c as u32 - 'A' as u32 + 1)) + - 1; + if attrs.ends_with('/') { + rest = &after[tag_end..]; + continue; + } + let body_all = &after[tag_end + 1..]; + let close = body_all.find("").unwrap(); + let body = &body_all[..close]; + let v = body + .find("") + .map(|p| unescape(&body[p + 3..p + 3 + body[p + 3..].find("").unwrap()])); + let cell = match attr("t").as_deref() { + Some("s") => Some(Cell::Text( + shared[v.unwrap().parse::().unwrap()].clone(), + )), + Some("inlineStr") | Some("str") => Some(Cell::Text(t_text(body))), + None | Some("n") => v.map(Cell::Num), + Some(_) => None, + }; + if let Some(c) = cell { + cells.insert((row, col), c); + } + rest = &body_all[close..]; + } + cells +} + +fn parse_num(v: &str) -> Decimal { + if v.contains(['e', 'E']) { + Decimal::from_scientific(v).unwrap() + } else { + v.parse().unwrap() + } +} + +/// A money text cell back to its amount: commas, '$', spaces dropped, +/// parentheses as a minus. +fn parse_money_text(s: &str) -> Option { + let t = s.trim(); + let neg = t.starts_with('(') && t.ends_with(')'); + let body: String = t + .chars() + .filter(|c| !matches!(c, ',' | '$' | ' ' | '(' | ')')) + .collect(); + let v: Decimal = body.parse().ok()?; + Some(if neg { -v } else { v }) +} + +/// Excel shows a whole-cent amount exactly within 15 significant digits +/// (xlsx-numbers.md): definitely safe below 10^13, definitely not when +/// finer than a cent or more than 15 significant digits. +fn excel_safe(x: Decimal) -> Option { + let whole_cents = x == x.round_dp(2); + let sig = x.normalize().mantissa().unsigned_abs().to_string().len(); + if !whole_cents || sig > 15 { + Some(false) + } else if x.abs() < Decimal::from(10_000_000_000_000i64) { + Some(true) + } else { + None + } +} + +/// XLSX output and its cells; checks the zip signature (INVARIANT 3). +fn render(st: &Statement) -> BTreeMap<(u32, u32), Cell> { + let bytes = report_to_xlsx(&XlsxReport::Statement(st), &meta()).unwrap(); + assert_eq!(&bytes[..2], b"PK", "an XLSX is a zip"); + let files = unzip(&bytes); + assert!( + files.contains_key("xl/workbook.xml"), + "a zip holding a workbook" + ); + sheet_cells(&bytes) +} + +/// The spreadsheet row holding `label` in some text cell, after `from`. +fn row_of(cells: &BTreeMap<(u32, u32), Cell>, label: &str, from: u32) -> Option { + cells + .iter() + .find(|((r, _), c)| *r > from && **c == Cell::Text(label.to_string())) + .map(|((r, _), _)| *r) +} + +/// report_to_xlsx INVARIANTS 1-2 for one statement: every row, in order, +/// with its label; each amount a number cell equal to it when Excel can +/// show it exactly, its exact text when not; no number cell anywhere that +/// Excel would show wrong. +fn check_xlsx(st: &Statement) { + let cells = render(st); + for ((r, c), cell) in &cells { + if let Cell::Num(v) = cell { + let x = parse_num(v); + assert!( + excel_safe(x).unwrap_or(true), + "number cell {r},{c} = {v} Excel can't show exactly" + ); + } + } + let mut at = 0u32; + for row in &st.rows { + if row.label.is_empty() { + continue; + } + let r = row_of(&cells, &row.label, at) + .unwrap_or_else(|| panic!("row {:?} missing or out of order", row.label)); + at = r; + let in_row: Vec<&Cell> = cells + .iter() + .filter(|((rr, _), c)| *rr == r && **c != Cell::Text(row.label.clone())) + .map(|(_, c)| c) + .collect(); + for a in row.amounts.iter().flatten() { + match excel_safe(*a) { + Some(true) => assert!( + in_row + .iter() + .any(|c| matches!(c, Cell::Num(v) if parse_num(v) == *a)), + "{:?}: {a} not a number cell (row has {in_row:?})", + row.label + ), + Some(false) => assert!( + in_row + .iter() + .any(|c| matches!(c, Cell::Text(t) if parse_money_text(t) == Some(*a))), + "{:?}: {a} not written as its exact text (row has {in_row:?})", + row.label + ), + None => assert!( + in_row.iter().any(|c| match c { + Cell::Num(v) => parse_num(v) == *a, + Cell::Text(t) => parse_money_text(t) == Some(*a), + }), + "{:?}: {a} missing", + row.label + ), + } + } + // Nothing else numeric in the row: no rounded stand-in. + for c in &in_row { + if let Cell::Num(v) = c { + let x = parse_num(v); + assert!( + row.amounts.iter().flatten().any(|a| *a == x), + "{:?}: number {v} isn't one of its amounts", + row.label + ); + } + } + } +} + +/// Whole-cent amounts, from small to past 15 significant digits. +fn amount_strategy() -> impl Strategy { + prop_oneof![ + 4 => (-10_000_000_000_000i64..10_000_000_000_000).prop_map(cents), + 1 => (-10_000_000_000_000_000_000i128..10_000_000_000_000_000_000, 0u32..=2) + .prop_map(|(m, s)| Decimal::from_i128_with_scale(m, s)), + ] +} + +/// Amounts finer than a cent. +fn subcent_strategy() -> impl Strategy { + (-100_000_000i64..100_000_000, 3u32..=6) + .prop_map(|(m, s)| Decimal::new(m, s)) + .prop_filter("finer than a cent", |x| *x != x.round_dp(2)) +} + +fn random_statement(rows: Vec<(u8, Vec>)>) -> Statement { + let rows: Vec = rows + .into_iter() + .enumerate() + .map(|(i, (k, a))| { + let kind = [RowKind::Line, RowKind::Subtotal, RowKind::Section][k as usize]; + let amounts = if kind == RowKind::Section { vec![] } else { a }; + Row { + kind, + label: format!("Label {i} & "), + amounts, + currency: false, + rule_above: false, + } + }) + .collect(); + report::statement("Random", "As of now".into(), &["A", "B"], rows) +} + +proptest! { + #![proptest_config(ProptestConfig::with_cases(48))] + + /// report_to_xlsx INVARIANTS 1-3 over random statements of whole-cent + /// amounts, including ones past 15 significant digits (exact text). + #[test] + fn xlsx_never_shows_a_wrong_number( + rows in proptest::collection::vec( + (0u8..3, proptest::collection::vec(proptest::option::of(amount_strategy()), 2)), + 1..20, + ) + ) { + check_xlsx(&random_statement(rows)); + } + + /// report_to_xlsx INVARIANT 1: an amount finer than a cent is "its exact + /// text", never a rounded number or rounded text. + #[test] + fn xlsx_subcent_amounts_are_exact_text( + rows in proptest::collection::vec( + (0u8..2, proptest::collection::vec(proptest::option::of(subcent_strategy()), 2)), + 1..10, + ) + ) { + check_xlsx(&random_statement(rows)); + } +} + +/// report_to_xlsx INVARIANT 1 at the boundaries xlsx-numbers.md derives: +/// 13 digits before the cents show exactly; a 16-significant-digit amount +/// or a fraction of a cent is written as exact text. +#[test] +fn xlsx_precision_boundaries() { + let line = |label: &str, a: &str| Row { + kind: RowKind::Line, + label: label.into(), + amounts: vec![Some(dec(a))], + currency: false, + rule_above: false, + }; + let st = report::statement( + "Edges", + "x".into(), + &["Total"], + vec![ + line("max exact", "9999999999999.99"), + line("neg max exact", "-9999999999999.99"), + line("sixteen digits", "12345678901234.56"), + line("neg sixteen digits", "-12345678901234.56"), + line("plain", "-207.68"), + ], + ); + check_xlsx(&st); +} + +/// report_to_xlsx INVARIANT 1: a fraction of a cent is written as its exact +/// text ("1.005"), not rounded. +#[test] +fn xlsx_subcent_boundaries() { + let line = |label: &str, a: &str| Row { + kind: RowKind::Line, + label: label.into(), + amounts: vec![Some(dec(a))], + currency: false, + rule_above: false, + }; + let st = report::statement( + "Edges", + "x".into(), + &["Total"], + vec![line("half cent", "1.005"), line("tiny", "-0.001")], + ); + check_xlsx(&st); +} + +/// report_to_xlsx over every laid-out statement from real books (rows in +/// order, amounts as numbers) and the general ledger and journal reports +/// (a valid XLSX whose number cells Excel shows exactly). +#[test] +fn xlsx_renders_every_statement_and_ledger_report() { + let specs: Vec = (0..12) + .map(|i| EntrySpec { + day: 190 + i * 13, + accts: vec![ + (i as usize) % CHART.len(), + (i as usize * 5 + 3) % CHART.len(), + ], + k: 1, + debits: vec![1_234_567 + i * 101, 0, 0], + seeds: vec![0; 3], + memo: format!("entry {i}"), + }) + .filter(|s| s.accts[0] != s.accts[1]) + .collect(); + let b = build_books(&specs, true); + let (s, e) = (d(2026, 1, 1), d(2026, 12, 31)); + let pl = profit_and_loss_for_period(&b.ledger, s, e, None).unwrap(); + let bs = balance_sheet_as_of(&b.ledger, e).unwrap(); + let ye = YearEarnings { + net_income_ytd: pl.net_income(), + unclosed: pl.net_income(), + }; + let cf = cash_flow( + &b.ledger, + &CashFlowParams { + start: s, + end: e, + cash_accounts: vec![b.ids[0]], + financing_accounts: vec![], + investing_accounts: vec![], + }, + ) + .unwrap(); + let me = members_equity(&b.ledger, s, e, &member_capitals(&b)).unwrap(); + for st in [ + report::profit_and_loss(&pl), + report::balance_sheet(&bs, ye, &members()), + report::cash_flow(&cf, &members()), + report::trial_balance(&trial_balance_as_of(&b.ledger, e), e), + report::members_equity(&me), + ] { + check_xlsx(&st); + } + let gl = general_ledger(&b.ledger, s, e).unwrap(); + for rep in [ + XlsxReport::GeneralLedger(&gl), + XlsxReport::GeneralJournal(&b.ledger, s, e), + ] { + let bytes = report_to_xlsx(&rep, &meta()).unwrap(); + assert_eq!(&bytes[..2], b"PK"); + for cell in sheet_cells(&bytes).values() { + if let Cell::Num(v) = cell { + assert_ne!(excel_safe(parse_num(v)), Some(false), "{v}"); + } + } + } +} + +// --------------------------------------------------------------------------- +// PDF +// --------------------------------------------------------------------------- + +const MARKUP: [&str; 9] = [ + "#panic()", + "#{1+1}", + "*bold*", + "$x$", + "[", + "]", + "\\", + "#let x = 1; _under_ `raw` @ref // c", + "/* open comment", +]; + +fn markup_statement() -> Statement { + let mut rows = vec![Row { + kind: RowKind::Section, + label: "Section #panic(\"boom\")".into(), + amounts: vec![], + currency: false, + rule_above: false, + }]; + for (i, m) in MARKUP.iter().enumerate() { + rows.push(Row { + kind: RowKind::Line, + label: (*m).into(), + amounts: vec![Some(cents(i as i64 * 12_345 - 40_000))], + currency: false, + rule_above: false, + }); + } + rows.push(Row { + kind: RowKind::Subtotal, + label: "Total $[*]$".into(), + amounts: vec![Some(dec("-1234567.89"))], + currency: false, + rule_above: false, + }); + rows.push(Row { + kind: RowKind::Spacer, + label: String::new(), + amounts: vec![], + currency: false, + rule_above: false, + }); + rows.push(Row { + kind: RowKind::Total, + label: "Net #{1+1}".into(), + amounts: vec![Some(Decimal::ZERO)], + currency: false, + rule_above: false, + }); + report::statement("Title #panic()", "Period *x*".into(), &["Total ["], rows) +} + +fn strings_in(v: &serde_json::Value, out: &mut Vec) { + match v { + serde_json::Value::String(s) => out.push(s.clone()), + serde_json::Value::Array(a) => a.iter().for_each(|x| strings_in(x, out)), + serde_json::Value::Object(o) => o.values().for_each(|x| strings_in(x, out)), + _ => {} + } +} + +/// The array in `v` with one element per statement row. +fn rows_array(v: &serde_json::Value, n: usize) -> Option<&Vec> { + match v { + serde_json::Value::Array(a) if a.len() == n && a.iter().all(|x| x.is_object()) => Some(a), + serde_json::Value::Array(a) => a.iter().find_map(|x| rows_array(x, n)), + serde_json::Value::Object(o) => o.values().find_map(|x| rows_array(x, n)), + _ => None, + } +} + +/// statement_pdf INVARIANTS 1-3: labels holding Typst syntax render +/// without running (Ok, a PDF), the same input gives the same bytes, and +/// template_data carries each row's label verbatim, its kind, and its +/// amounts split for alignment, in order. +#[test] +fn pdf_is_deterministic_and_labels_are_data() { + let st = markup_statement(); + let m = ReportMeta { + company: "#panic() *Co* $x$ [ ] \\".into(), + ..meta() + }; + let a = statement_pdf(&st, &m).expect("labels with markup render"); + let b = statement_pdf(&st, &m).expect("again"); + assert!(a.starts_with(b"%PDF"), "a PDF"); + assert_eq!(a, b, "deterministic bytes"); + + let data = ledger_import::pdf::template_data(&st, &m); + let rows = rows_array(&data, st.rows.len()).expect("one element per row"); + for (row, el) in st.rows.iter().zip(rows) { + let mut s = Vec::new(); + strings_in(el, &mut s); + if !row.label.is_empty() { + assert!( + s.contains(&row.label), + "label {:?} not verbatim in {el}", + row.label + ); + } + let kind = serde_json::to_value(row.kind).unwrap(); + assert!( + el.as_object().unwrap().values().any(|v| *v == kind), + "kind {kind} missing in {el}" + ); + for x in row.amounts.iter().flatten() { + let digits = format_amount(x.abs()); + assert!( + s.contains(&digits), + "amount digits {digits:?} missing in {el}" + ); + } + } + let mut all = Vec::new(); + strings_in(&data, &mut all); + assert!(all.contains(&"Title #panic()".to_string())); +} + +/// statement_pdf INVARIANT 2: one label per markup string, each rendered +/// alone (so one that would break the parse can't hide behind another). +#[test] +fn pdf_each_markup_label_alone_renders() { + for m in MARKUP { + let st = report::statement( + m, + m.to_string(), + &[m], + vec![Row { + kind: RowKind::Line, + label: m.into(), + amounts: vec![Some(dec("1.00"))], + currency: false, + rule_above: false, + }], + ); + let bytes = statement_pdf(&st, &meta()).unwrap_or_else(|e| panic!("{m:?}: {e}")); + assert!(bytes.starts_with(b"%PDF")); + } +} + +// --------------------------------------------------------------------------- +// chart_csv +// --------------------------------------------------------------------------- + +/// parse_chart_csv INVARIANTS 1 and 3: the header in any case, optional +/// fifth tax_category; blank parent/category is None; blank lines skipped. +#[test] +fn chart_csv_header_any_case_and_blanks() { + let text = "\n\nCODE,Name,TYPE,Parent_Code\n1000,Cash,ASSET,\n\n1010,Checking,asset,1000\n"; + let rows = parse_chart_csv(text).unwrap(); + assert_eq!(rows.len(), 2); + assert_eq!(rows[0].code, "1000"); + assert_eq!(rows[0].account_type, "asset"); + assert_eq!(rows[0].parent_code, None); + assert_eq!(rows[0].tax_category, None); + assert_eq!(rows[1].parent_code.as_deref(), Some("1000")); + + let text = "code,name,type,parent_code,TAX_CATEGORY\n6000,Rent,Expense,,rent_lease_other\n6010,Misc,expense,,\n"; + let rows = parse_chart_csv(text).unwrap(); + assert_eq!(rows[0].tax_category.as_deref(), Some("rent_lease_other")); + assert_eq!(rows[0].account_type, "expense"); + assert_eq!(rows[1].tax_category, None); + + assert_eq!(parse_chart_csv("").unwrap(), vec![]); + assert_eq!(parse_chart_csv("\n\n").unwrap(), vec![]); +} + +/// parse_chart_csv INVARIANTS 1-2: anything but the header is BadHeader; +/// wrong width BadColumnCount(found, expected); unknown type BadType; blank +/// code or name Blank with its row (header is row 1). +#[test] +fn chart_csv_refusals() { + for bad in [ + "code,name,kind,parent_code\n", + "code,name,type\n", + "name,code,type,parent_code\n", + "code,name,type,parent_code,tax_category,extra\n", + "1000,Cash,asset,\n", + ] { + assert_eq!( + parse_chart_csv(bad), + Err(ChartCsvError::BadHeader), + "{bad:?}" + ); + } + assert_eq!( + parse_chart_csv("code,name,type,parent_code\n1000,Cash,asset\n"), + Err(ChartCsvError::BadColumnCount(3, 4)) + ); + assert_eq!( + parse_chart_csv("code,name,type,parent_code,tax_category\n1000,Cash,asset,\n"), + Err(ChartCsvError::BadColumnCount(4, 5)) + ); + assert!(matches!( + parse_chart_csv("code,name,type,parent_code\n1000,Cash,cash,\n"), + Err(ChartCsvError::BadType(_)) + )); + assert_eq!( + parse_chart_csv("code,name,type,parent_code\n1000,Cash,asset,\n ,X,asset,\n"), + Err(ChartCsvError::Blank { + row: 3, + field: "code" + }) + ); + assert_eq!( + parse_chart_csv("code,name,type,parent_code\n1000,,asset,\n"), + Err(ChartCsvError::Blank { + row: 2, + field: "name" + }) + ); +} + +proptest! { + /// parse_chart_csv INVARIANTS 1-3 as a round trip: random rows written + /// with the csv crate parse back to themselves, type lowercased. + #[test] + fn chart_csv_round_trip( + rows in proptest::collection::vec( + ("[0-9]{1,6}", "[A-Za-z][A-Za-z ,\"'&—é]{0,15}[A-Za-z]", 0usize..5, any::(), + proptest::option::of("[0-9]{1,6}"), proptest::option::of("[a-z_]{1,12}")), + 0..20), + with_tax in any::(), + ) { + let types = ["asset", "LIABILITY", "Equity", "revenue", "EXPENSE"]; + let mut out = vec![]; + let mut hdr = vec!["code", "name", "type", "parent_code"]; + if with_tax { hdr.push("tax_category"); } + out.push(hdr.iter().map(|s| s.to_string()).collect::>()); + for (code, name, t, _, parent, tax) in &rows { + let mut r = vec![code.clone(), name.clone(), types[*t].into(), parent.clone().unwrap_or_default()]; + if with_tax { r.push(tax.clone().unwrap_or_default()); } + out.push(r); + } + let got = parse_chart_csv(&csv_text(&out)).unwrap(); + prop_assert_eq!(got.len(), rows.len()); + for (g, (code, name, t, _, parent, tax)) in got.iter().zip(&rows) { + prop_assert_eq!(&g.code, code); + prop_assert_eq!(&g.name, name); + prop_assert_eq!(&g.account_type, &types[*t].to_lowercase()); + prop_assert_eq!(&g.parent_code, parent); + prop_assert_eq!(&g.tax_category, if with_tax { tax } else { &None }); + } + } +} + +// --------------------------------------------------------------------------- +// templates (what tests/templates.rs doesn't already cover) +// --------------------------------------------------------------------------- + +fn owners(n: usize) -> Vec { + (0..n).map(|i| format!("Owner{i}")).collect() +} + +const ENTITIES: [EntityType; 4] = [ + EntityType::SoleProprietorship, + EntityType::Partnership, + EntityType::SCorporation, + EntityType::CCorporation, +]; + +/// templates INVARIANT 1: codes unique, parents before children, valid +/// types, for every entity and any owner count; and the template's CSV is +/// a chart CSV that parses back to exactly its rows (the import path). +#[test] +fn templates_rows_import_and_csv_round_trips() { + let valid = ["asset", "liability", "equity", "revenue", "expense"]; + for entity in ENTITIES { + for n in 1..=6 { + let Ok(t) = chart_template(entity, &owners(n)) else { + assert!( + entity == EntityType::Partnership && n < 2, + "{entity:?} with {n} owners refused" + ); + continue; + }; + let mut seen = HashSet::new(); + for r in &t.rows { + assert!( + valid.contains(&r.account_type.as_str()), + "{}", + r.account_type + ); + if let Some(p) = &r.parent_code { + assert!( + seen.contains(p), + "{entity:?}: {} before its parent {p}", + r.code + ); + } + assert!( + seen.insert(r.code.clone()), + "{entity:?}: duplicate code {}", + r.code + ); + } + assert_eq!(parse_chart_csv(&t.csv()).unwrap(), t.rows, "{entity:?}"); + } + } +} + +/// templates INVARIANT 3 (and the prose: "at least two for a +/// partnership"): one owner is refused; any number from 2 up gets a capital +/// and a distributions account each, shares summing to exactly 100%. +#[test] +fn templates_partnership_any_owner_count() { + assert!(chart_template(EntityType::Partnership, &owners(1)).is_err()); + assert!(chart_template(EntityType::Partnership, &[]).is_err()); + for n in 2..=12 { + let os = owners(n); + let t = chart_template(EntityType::Partnership, &os).unwrap(); + for o in &os { + assert_eq!( + t.rows + .iter() + .filter(|r| r.name == format!("Capital — {o}")) + .count(), + 1 + ); + assert_eq!( + t.rows + .iter() + .filter(|r| r.name == format!("Distributions — {o}")) + .count(), + 1 + ); + } + let shares: Vec = t + .settings_toml + .lines() + .filter_map(|l| { + l.trim() + .strip_prefix("share = \"") + .and_then(|s| s.strip_suffix("%\"")) + }) + .map(|s| s.parse().unwrap()) + .collect(); + assert_eq!(shares.len(), n, "a share per partner"); + assert!(shares.iter().all(|s| *s > Decimal::ZERO)); + assert_eq!( + shares.iter().copied().sum::(), + Decimal::from(100), + "{n} owners" + ); + } +} + +/// EntityType::parse: the four spellings parse; "llc" alone is ambiguous. +#[test] +fn entity_type_parse() { + assert_eq!( + EntityType::parse("sole_proprietorship"), + Some(EntityType::SoleProprietorship) + ); + assert_eq!( + EntityType::parse("partnership"), + Some(EntityType::Partnership) + ); + assert_eq!( + EntityType::parse("s_corporation"), + Some(EntityType::SCorporation) + ); + assert_eq!( + EntityType::parse("c_corporation"), + Some(EntityType::CCorporation) + ); + assert_eq!(EntityType::parse("llc"), None); +}