check / check (push) Canceled after 0s
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01GWVo7x3UHw5kwCUaWxiejX
2616 lines
97 KiB
Rust
2616 lines
97 KiB
Rust
//! 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<usize>,
|
||
k: usize,
|
||
debits: Vec<i64>,
|
||
seeds: Vec<u64>,
|
||
memo: String,
|
||
}
|
||
|
||
fn entry_spec() -> impl Strategy<Value = EntrySpec> {
|
||
(
|
||
0i64..549,
|
||
proptest::sample::subsequence((0..CHART.len()).collect::<Vec<_>>(), 2..=4),
|
||
any::<u64>(),
|
||
proptest::collection::vec(100i64..10_000_000_000, 3),
|
||
proptest::collection::vec(any::<u64>(), 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<AccountId>,
|
||
}
|
||
|
||
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<Member> {
|
||
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<MemberCapital> {
|
||
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<String> = None;
|
||
let mut sums: Vec<Decimal> = 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<i64>),
|
||
Subtotal(Vec<i64>),
|
||
Spacer,
|
||
}
|
||
|
||
fn row_gen(cols: usize) -> impl Strategy<Value = RowGen> {
|
||
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::<bool>(),
|
||
) {
|
||
let mut rows: Vec<Row> = 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<String> = (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<Option<Decimal>>| 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<Value = (Vec<EntrySpec>, bool, i64)> {
|
||
(
|
||
proptest::collection::vec(entry_spec(), 0..25),
|
||
any::<bool>(),
|
||
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<Value = Txn> {
|
||
(
|
||
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::<u8>(),
|
||
)
|
||
.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>]) -> 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<BankTransaction> {
|
||
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<Txn>) {
|
||
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::<bool>(),
|
||
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<Vec<String>> = 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::<bool>()), 0..4),
|
||
) {
|
||
let mut rows: Vec<Vec<String>> = 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::<bool>(),
|
||
) {
|
||
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<Vec<String>> = 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<NaiveDate> = days.iter().map(|(m, dd)| d(2026, *m, *dd)).collect();
|
||
prop_assert_eq!(got.iter().map(|t| t.date).collect::<Vec<_>>(), 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<Item = i32>) -> 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<String> = HashSet::new();
|
||
let mut seen_trns_header = false;
|
||
let mut txns: Vec<Vec<Vec<&str>>> = Vec::new();
|
||
let mut current: Option<Vec<Vec<&str>>> = 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<Vec<&str>> = lines
|
||
.iter()
|
||
.map(|l| l.split('\t').collect::<Vec<_>>())
|
||
.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<AccountId, &str> =
|
||
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<u16>,
|
||
}
|
||
|
||
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<u8> {
|
||
let mut out: Vec<u8> = 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<u8> = 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<String, Vec<u8>> {
|
||
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 `<t>` inside `xml`, concatenated.
|
||
fn t_text(xml: &str) -> String {
|
||
let mut out = String::new();
|
||
let mut rest = xml;
|
||
while let Some(i) = rest.find("<t") {
|
||
let after = &rest[i + 2..];
|
||
if !(after.starts_with('>') || 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("</t>").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<String> = files
|
||
.get("xl/sharedStrings.xml")
|
||
.map(|b| {
|
||
let s = String::from_utf8(b.clone()).unwrap();
|
||
s.split("<si>").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("<c ") {
|
||
let after = &rest[i + 3..];
|
||
let tag_end = after.find('>').unwrap();
|
||
let attrs = &after[..tag_end];
|
||
let attr = |name: &str| -> Option<String> {
|
||
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("</c>").unwrap();
|
||
let body = &body_all[..close];
|
||
let v = body
|
||
.find("<v>")
|
||
.map(|p| unescape(&body[p + 3..p + 3 + body[p + 3..].find("</v>").unwrap()]));
|
||
let cell = match attr("t").as_deref() {
|
||
Some("s") => Some(Cell::Text(
|
||
shared[v.unwrap().parse::<usize>().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<Decimal> {
|
||
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<bool> {
|
||
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<u32> {
|
||
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<Value = Decimal> {
|
||
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<Value = Decimal> {
|
||
(-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<Option<Decimal>>)>) -> Statement {
|
||
let rows: Vec<Row> = 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} & <x>"),
|
||
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<EntrySpec> = (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` <lbl> @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<String>) {
|
||
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<serde_json::Value>> {
|
||
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::<bool>(),
|
||
proptest::option::of("[0-9]{1,6}"), proptest::option::of("[a-z_]{1,12}")),
|
||
0..20),
|
||
with_tax in any::<bool>(),
|
||
) {
|
||
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::<Vec<_>>());
|
||
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<String> {
|
||
(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<Decimal> = 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>(),
|
||
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);
|
||
}
|
||
|
||
// ---------------------------------------------------------------------
|
||
// XLSX general ledger and general journal sheets, header and footer
|
||
// (added after the store-style mutation run found them untested).
|
||
// ---------------------------------------------------------------------
|
||
|
||
fn num_at(cells: &BTreeMap<(u32, u32), Cell>, r: u32, c: u32) -> Option<Decimal> {
|
||
match cells.get(&(r, c)) {
|
||
Some(Cell::Num(v)) => Some(parse_num(v)),
|
||
Some(Cell::Text(t)) => parse_money_text(t),
|
||
None => None,
|
||
}
|
||
}
|
||
|
||
fn text_at(cells: &BTreeMap<(u32, u32), Cell>, r: u32, c: u32) -> Option<&str> {
|
||
match cells.get(&(r, c)) {
|
||
Some(Cell::Text(t)) => Some(t.as_str()),
|
||
_ => None,
|
||
}
|
||
}
|
||
|
||
proptest! {
|
||
#![proptest_config(ProptestConfig { cases: 64, ..ProptestConfig::default() })]
|
||
|
||
/// The general ledger sheet: per account a beginning balance, each
|
||
/// posting with its running balance (each step moves it by the posting's
|
||
/// debit minus credit, the same sign throughout an account), and a total
|
||
/// row whose debits and credits foot and whose balance is the last one.
|
||
#[test]
|
||
fn xlsx_general_ledger_runs_and_foots((specs, oe, end_off) in books_strategy()) {
|
||
let b = build_books(&specs, oe);
|
||
let (s, e) = (d(2025, 7, 1), d(2025, 7, 1) + chrono::Duration::days(end_off));
|
||
let gl = general_ledger(&b.ledger, s, e).unwrap();
|
||
let bytes = report_to_xlsx(&XlsxReport::GeneralLedger(&gl), &meta()).unwrap();
|
||
let cells = sheet_cells(&bytes);
|
||
for a in &gl.accounts {
|
||
let name = format!("{} {}", a.code, a.name);
|
||
// The heading is the account's name alone in the first column
|
||
// (the Split column holds account names too).
|
||
let head = cells
|
||
.iter()
|
||
.find(|((_, c), v)| *c == 0 && **v == Cell::Text(name.clone()))
|
||
.map(|((r, _), _)| *r)
|
||
.expect("account heading");
|
||
prop_assert_eq!(text_at(&cells, head + 1, 2), Some("Beginning balance"));
|
||
let mut bal = num_at(&cells, head + 1, 6).unwrap();
|
||
prop_assert_eq!(bal, a.beginning_balance);
|
||
let (mut dr, mut cr) = (Decimal::ZERO, Decimal::ZERO);
|
||
let mut sign: Option<Decimal> = None;
|
||
let mut r = head + 2;
|
||
while text_at(&cells, r, 0) != Some(format!("Total for {name}").as_str()) {
|
||
let debit = num_at(&cells, r, 4).unwrap_or_default();
|
||
let credit = num_at(&cells, r, 5).unwrap_or_default();
|
||
let next = num_at(&cells, r, 6).unwrap();
|
||
let step = debit - credit;
|
||
if !step.is_zero() {
|
||
let s = (next - bal) / step;
|
||
prop_assert!(s == Decimal::ONE || s == -Decimal::ONE, "row {}", r);
|
||
if let Some(prev) = sign { prop_assert_eq!(prev, s); }
|
||
sign = Some(s);
|
||
}
|
||
dr += debit;
|
||
cr += credit;
|
||
bal = next;
|
||
r += 1;
|
||
prop_assert!(r < head + 10_000, "no total row for {}", name);
|
||
}
|
||
prop_assert_eq!(num_at(&cells, r, 4).unwrap(), dr);
|
||
prop_assert_eq!(num_at(&cells, r, 5).unwrap(), cr);
|
||
prop_assert_eq!(num_at(&cells, r, 6).unwrap(), bal);
|
||
prop_assert_eq!(bal, a.ending_balance);
|
||
}
|
||
}
|
||
|
||
/// The general journal sheet: exactly the posted lines dated in the
|
||
/// range (each with its date inside it), and a total row whose debits
|
||
/// and credits foot and are equal.
|
||
#[test]
|
||
fn xlsx_general_journal_holds_the_range_and_foots((specs, oe, end_off) in books_strategy()) {
|
||
let b = build_books(&specs, oe);
|
||
let (s, e) = (d(2025, 7, 10), d(2025, 7, 10) + chrono::Duration::days(end_off / 2));
|
||
let bytes = report_to_xlsx(&XlsxReport::GeneralJournal(&b.ledger, s, e), &meta()).unwrap();
|
||
let cells = sheet_cells(&bytes);
|
||
let want_lines: usize = b
|
||
.ledger
|
||
.posted_entries()
|
||
.filter(|x| x.date() >= s && x.date() <= e)
|
||
.map(|x| x.lines().len())
|
||
.sum();
|
||
let header = row_of(&cells, "DATE", 0).expect("column headings");
|
||
let (mut dr, mut cr, mut lines) = (Decimal::ZERO, Decimal::ZERO, 0usize);
|
||
let mut r = header + 1;
|
||
while text_at(&cells, r, 0) != Some("Total") {
|
||
let date: NaiveDate = text_at(&cells, r, 0).unwrap().parse().unwrap();
|
||
prop_assert!(date >= s && date <= e, "{} outside the range", date);
|
||
dr += num_at(&cells, r, 5).unwrap_or_default();
|
||
cr += num_at(&cells, r, 6).unwrap_or_default();
|
||
lines += 1;
|
||
r += 1;
|
||
}
|
||
prop_assert_eq!(lines, want_lines);
|
||
prop_assert_eq!(num_at(&cells, r, 5).unwrap(), dr);
|
||
prop_assert_eq!(num_at(&cells, r, 6).unwrap(), cr);
|
||
prop_assert_eq!(dr, cr);
|
||
}
|
||
}
|
||
|
||
/// Every sheet names the company, the report, and its period at the top,
|
||
/// and ends with the footer (basis, preparation date, journal seq).
|
||
#[test]
|
||
fn xlsx_sheets_carry_header_and_footer() {
|
||
let b = build_books(&[], false);
|
||
let (s, e) = (d(2025, 7, 1), d(2025, 7, 31));
|
||
let pl = profit_and_loss_for_period(&b.ledger, s, e, None).unwrap();
|
||
let st = report::profit_and_loss(&pl);
|
||
let gl = general_ledger(&b.ledger, s, e).unwrap();
|
||
for (rep, title) in [
|
||
(XlsxReport::Statement(&st), "Profit and Loss"),
|
||
(XlsxReport::GeneralLedger(&gl), "General Ledger"),
|
||
(
|
||
XlsxReport::GeneralJournal(&b.ledger, s, e),
|
||
"General Journal",
|
||
),
|
||
] {
|
||
let cells = sheet_cells(&report_to_xlsx(&rep, &meta()).unwrap());
|
||
let texts: Vec<&str> = cells
|
||
.values()
|
||
.filter_map(|c| match c {
|
||
Cell::Text(t) => Some(t.as_str()),
|
||
_ => None,
|
||
})
|
||
.collect();
|
||
let m = meta();
|
||
for want in [
|
||
m.company.as_str(),
|
||
title,
|
||
&report::period_label(s, e),
|
||
&m.footer(),
|
||
] {
|
||
prop_assert_contains(&texts, want, title);
|
||
}
|
||
}
|
||
}
|
||
|
||
fn prop_assert_contains(texts: &[&str], want: &str, ctx: &str) {
|
||
assert!(
|
||
texts.iter().any(|t| t.contains(want)),
|
||
"{ctx}: no cell holds {want:?}"
|
||
);
|
||
}
|
||
|
||
/// The tax summary statement's Difference column is blank where book and
|
||
/// tax agree and book - tax where they don't (meals at 50%); separately
|
||
/// stated items sit in their own section, outside the net.
|
||
#[test]
|
||
fn tax_summary_difference_and_schedule_k() {
|
||
use ledger_core::tax::{TaxForm, tax_summary};
|
||
let mut l = Ledger::new(Currency::USD);
|
||
let cash = l
|
||
.add_account("1000", "Cash", AccountType::Asset, None, false)
|
||
.unwrap();
|
||
let mut acct = |code: &str, t, cat: &str| {
|
||
let id = l.add_account(code, code, t, None, false).unwrap();
|
||
l.set_account_tax(id, Some(cat), None).unwrap();
|
||
id
|
||
};
|
||
let sales = acct("4000", AccountType::Revenue, "gross_receipts");
|
||
let interest = acct("4200", AccountType::Revenue, "interest_income");
|
||
let meals = acct("6450", AccountType::Expense, "meals");
|
||
for (dr, cr, c) in [
|
||
(cash, sales, 100_000),
|
||
(cash, interest, 2_500),
|
||
(meals, cash, 10_000),
|
||
] {
|
||
let e = l.create_entry(d(2026, 2, 1), "x");
|
||
l.add_line(e, dr, Side::Debit, usd(c)).unwrap();
|
||
l.add_line(e, cr, Side::Credit, usd(c)).unwrap();
|
||
l.post_entry(e).unwrap();
|
||
}
|
||
let ts = tax_summary(&l, d(2026, 1, 1), d(2026, 12, 31), Some(TaxForm::Form1065)).unwrap();
|
||
let st = report::tax_summary(&ts);
|
||
let row = |label: &str| {
|
||
st.rows
|
||
.iter()
|
||
.find(|r| r.label.starts_with(label))
|
||
.unwrap_or_else(|| panic!("no row {label}"))
|
||
};
|
||
let sales_row = row("Gross receipts");
|
||
assert_eq!(
|
||
sales_row.amounts,
|
||
vec![Some(cents(100_000)), Some(cents(100_000)), None]
|
||
);
|
||
let meals_row = row("Meals");
|
||
assert_eq!(
|
||
meals_row.amounts,
|
||
vec![Some(cents(10_000)), Some(cents(5_000)), Some(cents(5_000))]
|
||
);
|
||
assert!(
|
||
st.rows
|
||
.iter()
|
||
.any(|r| r.label.starts_with("Separately stated"))
|
||
);
|
||
let net = row("Income less deductions");
|
||
assert_eq!(
|
||
net.amounts[0],
|
||
Some(cents(90_000)),
|
||
"interest is outside the net"
|
||
);
|
||
}
|
||
|
||
/// The cash flow's "Adjustments" subheading and depreciation line appear
|
||
/// only when there is something to adjust.
|
||
#[test]
|
||
fn cash_flow_adjustments_appear_only_when_there_are_some() {
|
||
let mk = |depreciation: Decimal, wc: Vec<ledger_core::CashFlowLine>| {
|
||
ledger_core::CashFlowStatement {
|
||
start: d(2026, 1, 1),
|
||
end: d(2026, 1, 31),
|
||
net_income: cents(1_000),
|
||
depreciation,
|
||
cash_from_operations: cents(1_000)
|
||
+ depreciation
|
||
+ wc.iter().map(|l| l.amount).sum::<Decimal>(),
|
||
working_capital: wc,
|
||
investing: vec![],
|
||
cash_from_investing: Decimal::ZERO,
|
||
financing: vec![],
|
||
cash_from_financing: Decimal::ZERO,
|
||
net_change_in_cash: Decimal::ZERO,
|
||
cash_beginning: Decimal::ZERO,
|
||
cash_ending: Decimal::ZERO,
|
||
}
|
||
};
|
||
let has = |st: &Statement, p: &str| st.rows.iter().any(|r| r.label.starts_with(p));
|
||
let plain = report::cash_flow(&mk(Decimal::ZERO, vec![]), &[]);
|
||
assert!(!has(&plain, "Adjustments") && !has(&plain, "Depreciation"));
|
||
let dep = report::cash_flow(&mk(cents(100), vec![]), &[]);
|
||
assert!(has(&dep, "Adjustments") && has(&dep, "Depreciation"));
|
||
let wc = report::cash_flow(
|
||
&mk(
|
||
Decimal::ZERO,
|
||
vec![ledger_core::CashFlowLine {
|
||
label: "Increase in AR".into(),
|
||
amount: cents(-50),
|
||
account: None,
|
||
}],
|
||
),
|
||
&[],
|
||
);
|
||
assert!(has(&wc, "Adjustments") && !has(&wc, "Depreciation"));
|
||
}
|
||
|
||
/// A template takes at most 90 owners.
|
||
#[test]
|
||
fn templates_take_at_most_90_owners() {
|
||
let owners = |n: usize| (0..n).map(|i| format!("P{i}")).collect::<Vec<_>>();
|
||
assert!(chart_template(EntityType::Partnership, &owners(90)).is_ok());
|
||
assert!(chart_template(EntityType::Partnership, &owners(91)).is_err());
|
||
}
|