Files
wanderbooks/crates/ledger-core/tests/spec_arap.rs
T
swaitsandClaude Opus 5.5 beb1fcf233 test(core): spec-blind suites and a live GnuCash oracle; four fixes they found
Max-testing pass, steps 2-4.

Spec-blind tests (rank 1-2): four agents, each given only the specs and
the public interface (doc comments and signatures, every body removed,
never src/ or the existing tests), wrote tests/spec_reports.rs (62),
spec_ledger.rs (72), spec_arap.rs (42), spec_assets_payroll.rs (50):
hand-computed examples per scenario and a proptest law per invariant
(conservation, partition, invariance, scaling, reversal cancels,
"a refusal leaves the books unchanged" over random invalid commands).
They found:

1. A refused payroll run changed the books: an all-zero run passed
   validation, then failed at post after creating seven payroll
   accounts and using up an entry id. validate_payroll_lines now
   refuses a run with no gross pay.
2. A refused add_account used up an account id (empty code, duplicate,
   unknown parent). The id is now taken only when the account is added.
3. Stale prose: close_period_to and members_equity still described the
   old last-member rounding.
4. Open (left #[ignore]d pending Stephen): depreciation_schedule's doc
   says "through the month containing" the date; the code prorates that
   month by days.

GnuCash oracle (rank 3, an oracle never the spec): `just oracle` builds
GnuCash 5.10 in a container pinned by base digest and package versions
(oracle/gnucash/) and runs tests/oracle_gnucash.rs: 200 seeded random
books built through wanderbooks (entries, foreign lines, reversals,
monthly closes), every account's balance at month ends and random dates
and each month's P&L, compared exactly: 15,800 comparisons. First run:
4,724 differences, all from foreign lines; without them, zero.
Adjudicated from principles (rules.md B-3): wanderbooks booked
foreign * rate unrounded ($108.34567); a ledger in one money holds no
fractions of its smallest unit, and its cash must match a bank. Stephen
decided: book in the base currency's minor unit, half away from zero.
add_line_fx now rounds; minor units per ISO 4217 list one (2026-09-17,
SIX), generated into Currency::minor_units and recorded in
docs/spec/sources/iso-4217.md. Replay now takes the journaled base
amount (replay_add_line_fx), so lines booked before replay exactly as
booked (contract: books_written_under_older_rules_still_open). Now:
zero differences, foreign lines included.

Tests: 449, from 223.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GWVo7x3UHw5kwCUaWxiejX
2026-10-13 14:39:06 -06:00

1265 lines
44 KiB
Rust

//! Spec-derived tests for A/R and A/P: customers, vendors, invoices, bills,
//! payments, aging, and the control accounts they post to.
//!
//! Oracles are the spec blocks in the `ledger-core` interface (doc comments
//! on `Ledger::create_invoice`, `create_bill`, `receive_payment`, `pay_bill`,
//! `arap::aging_bucket`, the `ControlAccount` / `ControlAccountMismatch`
//! docs) and `docs/spec/rules.md` B-18. Expected values are computed by hand
//! from those, never from the code under test.
use std::str::FromStr;
use chrono::{Duration, NaiveDate};
use ledger_core::arap::{aging_bucket, derive_status, validate_due_date, validate_lines};
use ledger_core::{
AccountId, AccountType, AgingRow, Bill, Currency, DocumentLine, DocumentStatus, EntryStatus,
Invoice, Ledger, LedgerError, Side,
};
use proptest::prelude::*;
use rust_decimal::Decimal;
// ---------------------------------------------------------------- helpers
fn dec(s: &str) -> Decimal {
Decimal::from_str(s).expect("decimal literal")
}
fn cents(c: i64) -> Decimal {
Decimal::new(c, 2)
}
fn d(y: i32, m: u32, day: u32) -> NaiveDate {
NaiveDate::from_ymd_opt(y, m, day).expect("valid date")
}
fn line(desc: &str, amount: &str) -> DocumentLine {
DocumentLine {
description: desc.to_string(),
amount: dec(amount),
}
}
/// A ledger with only a cash account (1000), which the payment commands
/// need as their cash side.
fn books() -> (Ledger, AccountId) {
let mut l = Ledger::new(Currency::USD);
let cash = l
.add_account("1000", "Cash", AccountType::Asset, None, false)
.expect("cash account");
(l, cash)
}
fn acct(l: &Ledger, code: &str) -> Option<AccountId> {
l.chart().find_by_code(code).map(|a| a.id)
}
fn bal(l: &Ledger, code: &str) -> Decimal {
match acct(l, code) {
Some(id) => l.account_balance(id).expect("known account"),
None => Decimal::ZERO,
}
}
/// Everything observable about the books, deterministically ordered, so a
/// refusal can be checked to leave them unchanged.
fn fingerprint(l: &Ledger) -> String {
let mut out = String::new();
let mut accts: Vec<_> = l
.chart()
.all()
.map(|a| {
format!(
"{}|{}|{}|{:?}|{}|{:?}",
a.id, a.code, a.name, a.account_type, a.placeholder, a.parent
)
})
.collect();
accts.sort();
out.push_str(&accts.join("\n"));
out.push_str(&format!(
"\nnext: acct {} entry {} inv {} bill {} cust {} vend {}\n",
l.next_account_id(),
l.next_entry_id(),
l.next_invoice_id(),
l.next_bill_id(),
l.next_customer_id(),
l.next_vendor_id()
));
for id in 0..l.next_entry_id() + 2 {
if let Some(e) = l.get_entry(id) {
out.push_str(&format!(
"E{} {} {:?} {:?} {:?} {:?}\n",
e.id(),
e.date(),
e.description(),
e.status(),
e.lines()
.iter()
.map(|x| (x.account, x.side, x.amount.amount))
.collect::<Vec<_>>(),
l.entry_vendor(id)
));
}
}
for id in 0..l.next_invoice_id() + 2 {
if let Some(i) = l.get_invoice(id) {
out.push_str(&format!("{i:?}\n"));
}
}
for id in 0..l.next_bill_id() + 2 {
if let Some(b) = l.get_bill(id) {
out.push_str(&format!("{b:?}\n"));
}
}
for c in l.list_customers() {
out.push_str(&format!("{c:?}\n"));
}
for v in l.list_vendors() {
out.push_str(&format!("{v:?}\n"));
}
out
}
/// Expected bucket index from rules.md B-18, written independently:
/// 0 Current (on or before due), 1: 1-30, 2: 31-60, 3: 61-90, 4: 90+.
fn expected_bucket(due: NaiveDate, as_of: NaiveDate) -> usize {
let days = (as_of - due).num_days();
match days {
i64::MIN..=0 => 0,
1..=30 => 1,
31..=60 => 2,
61..=90 => 3,
_ => 4,
}
}
fn buckets(r: &AgingRow) -> [Decimal; 5] {
[r.current, r.days_30, r.days_60, r.days_90, r.days_90_plus]
}
fn status_law(paid: Decimal, total: Decimal) -> DocumentStatus {
if paid.is_zero() {
DocumentStatus::Open
} else if paid < total {
DocumentStatus::Partial
} else {
DocumentStatus::Paid
}
}
/// The (account, side, amount) lines of an entry.
fn entry_lines(l: &Ledger, id: u64) -> Vec<(AccountId, Side, Decimal)> {
l.get_entry(id)
.expect("entry exists")
.lines()
.iter()
.map(|x| (x.account, x.side, x.amount.amount))
.collect()
}
/// Net debit-minus-credit per account within one entry.
fn net_for(lines: &[(AccountId, Side, Decimal)], account: AccountId) -> Decimal {
lines
.iter()
.filter(|(a, _, _)| *a == account)
.map(|(_, s, amt)| match s {
Side::Debit => *amt,
Side::Credit => -*amt,
})
.sum()
}
// ------------------------------------------------- Invoice / Bill methods
/// Oracle: Invoice doc: total() is "Total of all lines", balance() is
/// "Amount still owed" = total - amount_paid.
#[test]
fn invoice_total_is_sum_of_lines_and_balance_is_total_minus_paid() {
let inv = Invoice {
id: 7,
customer_id: 1,
date: d(2026, 3, 1),
due_date: d(2026, 3, 31),
lines: vec![line("a", "100.10"), line("b", "0.05"), line("c", "49.85")],
amount_paid: dec("60.00"),
status: DocumentStatus::Partial,
entry_id: 1,
};
assert_eq!(inv.total(), dec("150.00"));
assert_eq!(inv.balance(), dec("90.00"));
}
/// Oracle: Bill doc: total() is the sum of lines, balance() total - paid.
#[test]
fn bill_total_is_sum_of_lines_and_balance_is_total_minus_paid() {
let bill = Bill {
id: 3,
vendor_id: 2,
date: d(2026, 3, 1),
due_date: d(2026, 3, 1),
lines: vec![line("x", "19.99"), line("y", "0.01")],
amount_paid: dec("20.00"),
status: DocumentStatus::Paid,
entry_id: 9,
};
assert_eq!(bill.total(), dec("20.00"));
assert_eq!(bill.balance(), Decimal::ZERO);
}
// ------------------------------------------------------ pure validators
/// Oracle: validate_lines doc: "at least one" line.
#[test]
fn validate_lines_refuses_no_lines() {
assert_eq!(validate_lines(&[]), Err(LedgerError::EmptyDocumentLines));
}
/// Oracle: validate_lines doc: "all positive amounts" (zero is not positive).
#[test]
fn validate_lines_refuses_zero_and_negative_amounts() {
assert_eq!(
validate_lines(&[line("ok", "1.00"), line("zero", "0")]),
Err(LedgerError::InvalidDocumentLineAmount(Decimal::ZERO))
);
assert_eq!(
validate_lines(&[line("neg", "-0.01")]),
Err(LedgerError::InvalidDocumentLineAmount(dec("-0.01")))
);
}
/// Oracle: validate_lines doc: one or more positive lines are valid.
#[test]
fn validate_lines_accepts_positive_lines() {
assert_eq!(validate_lines(&[line("a", "0.01")]), Ok(()));
assert_eq!(
validate_lines(&[line("a", "5"), line("b", "1000000")]),
Ok(())
);
}
/// Oracle: validate_due_date doc: due date "not before" the document date
/// (equal is allowed).
#[test]
fn validate_due_date_allows_same_day_and_later_refuses_earlier() {
assert_eq!(validate_due_date(d(2026, 5, 1), d(2026, 5, 1)), Ok(()));
assert_eq!(validate_due_date(d(2026, 5, 1), d(2026, 6, 1)), Ok(()));
assert!(matches!(
validate_due_date(d(2026, 5, 1), d(2026, 4, 30)),
Err(LedgerError::DueBeforeDate(_))
));
}
/// Oracle: DocumentStatus docs: Open = nothing paid, Partial = partially
/// paid, Paid = fully paid.
#[test]
fn derive_status_examples() {
assert_eq!(derive_status(dec("0"), dec("100")), DocumentStatus::Open);
assert_eq!(
derive_status(dec("0.01"), dec("100")),
DocumentStatus::Partial
);
assert_eq!(
derive_status(dec("99.99"), dec("100")),
DocumentStatus::Partial
);
assert_eq!(
derive_status(dec("100.00"), dec("100")),
DocumentStatus::Paid
);
}
// ------------------------------------------------------------ aging_bucket
/// Oracle: aging_bucket SCENARIO 1 and B-18 DECISION: due today is Current.
#[test]
fn aging_due_today_is_current() {
let r = aging_bucket(d(2026, 10, 13), d(2026, 10, 13), dec("42.00"));
assert_eq!(
buckets(&r),
[dec("42.00"), dec("0"), dec("0"), dec("0"), dec("0")]
);
}
/// Oracle: aging_bucket SCENARIO 1: before the due date is Current.
#[test]
fn aging_not_yet_due_is_current() {
let r = aging_bucket(d(2026, 12, 31), d(2026, 10, 13), dec("5"));
assert_eq!(r.current, dec("5"));
assert_eq!(r.total(), dec("5"));
}
/// Oracle: aging_bucket SCENARIOS 2-5 at each edge (1, 30, 31, 60, 61, 90,
/// 91 days past due), B-18.
#[test]
fn aging_boundaries_by_days_past_due() {
let due = d(2026, 1, 1);
let cases: [(i64, usize); 8] = [
(1, 1),
(30, 1),
(31, 2),
(60, 2),
(61, 3),
(90, 3),
(91, 4),
(400, 4),
];
for (days, idx) in cases {
let r = aging_bucket(due, due + Duration::days(days), dec("10.00"));
let mut want = [Decimal::ZERO; 5];
want[idx] = dec("10.00");
assert_eq!(buckets(&r), want, "{days} days past due");
}
}
/// Oracle: AgingRow::total doc ("Total outstanding") = sum of the buckets.
#[test]
fn aging_row_total_sums_buckets() {
let r = AgingRow {
name: "x".into(),
current: dec("1"),
days_30: dec("2"),
days_60: dec("3.5"),
days_90: dec("4"),
days_90_plus: dec("0.25"),
};
assert_eq!(r.total(), dec("10.75"));
}
proptest! {
#![proptest_config(ProptestConfig::with_cases(256))]
/// Oracle: aging_bucket INVARIANT 1 (whole balance in exactly one
/// bucket, others zero) and SCENARIOS 1-5 (which bucket).
#[test]
fn prop_aging_one_bucket_matches_spec(
offset in -400i64..400,
bal_cents in 1i64..100_000_000,
) {
let due = d(2026, 6, 15);
let as_of = due + Duration::days(offset);
let balance = cents(bal_cents);
let r = aging_bucket(due, as_of, balance);
let b = buckets(&r);
let mut want = [Decimal::ZERO; 5];
want[expected_bucket(due, as_of)] = balance;
prop_assert_eq!(b, want);
prop_assert_eq!(r.total(), balance);
}
/// Oracle: aging_bucket INVARIANT 2: never moves to a younger bucket as
/// as_of advances.
#[test]
fn prop_aging_only_ages(
a in -200i64..300,
step in 0i64..200,
) {
let due = d(2026, 6, 15);
let r1 = aging_bucket(due, due + Duration::days(a), dec("1"));
let r2 = aging_bucket(due, due + Duration::days(a + step), dec("1"));
let idx = |r: &AgingRow| buckets(r).iter().position(|x| !x.is_zero()).unwrap();
prop_assert!(idx(&r2) >= idx(&r1));
}
/// Oracle: DocumentStatus docs (Open / Partial / Paid exactly per paid:
/// zero / between / equal).
#[test]
fn prop_derive_status_law(total_c in 1i64..10_000_000, frac in 0u32..=100) {
let total = cents(total_c);
let paid = match frac {
0 => Decimal::ZERO,
100 => total,
f => (total * Decimal::from(f) / Decimal::from(100)).round_dp(2),
};
prop_assert_eq!(derive_status(paid, total), status_law(paid, total));
}
/// Oracle: validate_lines doc: valid iff non-empty and every amount > 0.
#[test]
fn prop_validate_lines_iff_nonempty_and_positive(
amts in proptest::collection::vec(-1000i64..1000, 0..5)
) {
let lines: Vec<DocumentLine> = amts
.iter()
.map(|c| DocumentLine { description: "l".into(), amount: cents(*c) })
.collect();
let ok = !amts.is_empty() && amts.iter().all(|c| *c > 0);
prop_assert_eq!(validate_lines(&lines).is_ok(), ok);
}
}
// ------------------------------------------------------ customers/vendors
/// Oracle: add_customer doc ("Returns its id") with get_customer /
/// list_customers ("in id order").
#[test]
fn add_customer_and_vendor_are_retrievable_in_id_order() {
let (mut l, _) = books();
let a = l.add_customer("Acme").unwrap();
let b = l.add_customer("Beta").unwrap();
assert_ne!(a, b);
assert_eq!(l.get_customer(a).unwrap().name, "Acme");
assert_eq!(l.get_customer(b).unwrap().name, "Beta");
let names: Vec<_> = l.list_customers().iter().map(|c| c.name.clone()).collect();
assert_eq!(names, vec!["Acme", "Beta"]);
let v = l.add_vendor("Landlord").unwrap();
let w = l.add_vendor("Hosting").unwrap();
assert_ne!(v, w);
assert_eq!(l.get_vendor(v).unwrap().name, "Landlord");
let vn: Vec<_> = l.list_vendors().iter().map(|x| x.name.clone()).collect();
assert_eq!(vn, vec!["Landlord", "Hosting"]);
}
/// Oracle: next_customer_id / next_vendor_id docs ("the id the next ...
/// added will receive").
#[test]
fn add_customer_and_vendor_use_the_announced_next_id() {
let (mut l, _) = books();
let nc = l.next_customer_id();
assert_eq!(l.add_customer("A").unwrap(), nc);
let nv = l.next_vendor_id();
assert_eq!(l.add_vendor("V").unwrap(), nv);
}
// ------------------------------------------------------------ invoices
/// Oracle: create_invoice INVARIANT 1 (Dr 1200 / Cr 4000 for the sum of the
/// lines, dated the invoice date) and INVARIANT 2 (starts open, nothing
/// paid); control accounts doc: missing 1200/4000 are created.
#[test]
fn create_invoice_posts_dr_receivables_cr_revenue_for_the_line_sum() {
let (mut l, _) = books();
let c = l.add_customer("Acme").unwrap();
assert!(acct(&l, "1200").is_none() && acct(&l, "4000").is_none());
let next = l.next_invoice_id();
let id = l
.create_invoice(
c,
d(2026, 3, 1),
d(2026, 3, 31),
vec![line("design", "1000.00"), line("hosting", "234.56")],
)
.unwrap();
assert_eq!(id, next);
let ar = l.chart().find_by_code("1200").expect("1200 created");
assert_eq!(ar.account_type, AccountType::Asset);
assert!(!ar.placeholder);
let rev = l.chart().find_by_code("4000").expect("4000 created");
assert_eq!(rev.account_type, AccountType::Revenue);
assert!(!rev.placeholder);
let inv = l.get_invoice(id).unwrap().clone();
assert_eq!(inv.customer_id, c);
assert_eq!(inv.total(), dec("1234.56"));
assert_eq!(inv.amount_paid, Decimal::ZERO);
assert_eq!(inv.status, DocumentStatus::Open);
assert_eq!(inv.balance(), dec("1234.56"));
let e = l.get_entry(inv.entry_id).unwrap();
assert_eq!(e.status(), EntryStatus::Posted);
assert_eq!(e.date(), d(2026, 3, 1));
let lines = entry_lines(&l, inv.entry_id);
assert!(lines.iter().all(|(a, _, _)| *a == ar.id || *a == rev.id));
assert_eq!(net_for(&lines, ar.id), dec("1234.56"));
assert_eq!(net_for(&lines, rev.id), dec("-1234.56"));
assert_eq!(bal(&l, "1200"), dec("1234.56"));
assert_eq!(bal(&l, "4000"), dec("1234.56"));
}
/// Oracle: control accounts doc: an existing postable account of the right
/// type is the one used ("find (or create)"); nothing new is added.
#[test]
fn create_invoice_uses_existing_right_type_controls() {
let (mut l, _) = books();
let ar = l
.add_account("1200", "Trade Debtors", AccountType::Asset, None, false)
.unwrap();
let rev = l
.add_account("4000", "Sales", AccountType::Revenue, None, false)
.unwrap();
let n = l.chart().len();
let c = l.add_customer("Acme").unwrap();
let id = l
.create_invoice(c, d(2026, 3, 1), d(2026, 3, 1), vec![line("x", "10")])
.unwrap();
assert_eq!(l.chart().len(), n);
let lines = entry_lines(&l, l.get_invoice(id).unwrap().entry_id);
assert_eq!(net_for(&lines, ar), dec("10"));
assert_eq!(net_for(&lines, rev), dec("-10"));
}
/// Oracle: create_invoice SCENARIO 1 (unknown customer) + INVARIANT 3.
#[test]
fn create_invoice_refuses_unknown_customer_books_unchanged() {
let (mut l, _) = books();
let before = fingerprint(&l);
let r = l.create_invoice(999, d(2026, 3, 1), d(2026, 3, 2), vec![line("x", "1")]);
assert_eq!(r, Err(LedgerError::UnknownCustomer(999)));
assert_eq!(fingerprint(&l), before);
}
/// Oracle: create_invoice SCENARIO 1 (line amount not positive; no lines,
/// per validate_lines) + INVARIANT 3.
#[test]
fn create_invoice_refuses_bad_lines_books_unchanged() {
let (mut l, _) = books();
let c = l.add_customer("Acme").unwrap();
let before = fingerprint(&l);
let r = l.create_invoice(
c,
d(2026, 3, 1),
d(2026, 3, 2),
vec![line("x", "5"), line("y", "0")],
);
assert_eq!(
r,
Err(LedgerError::InvalidDocumentLineAmount(Decimal::ZERO))
);
let r = l.create_invoice(c, d(2026, 3, 1), d(2026, 3, 2), vec![line("x", "-5")]);
assert_eq!(r, Err(LedgerError::InvalidDocumentLineAmount(dec("-5"))));
let r = l.create_invoice(c, d(2026, 3, 1), d(2026, 3, 2), vec![]);
assert_eq!(r, Err(LedgerError::EmptyDocumentLines));
assert_eq!(fingerprint(&l), before);
}
/// Oracle: create_invoice SCENARIO 1 (due date before invoice date) +
/// INVARIANT 3.
#[test]
fn create_invoice_refuses_due_before_date_books_unchanged() {
let (mut l, _) = books();
let c = l.add_customer("Acme").unwrap();
let before = fingerprint(&l);
let r = l.create_invoice(c, d(2026, 3, 2), d(2026, 3, 1), vec![line("x", "1")]);
assert!(matches!(r, Err(LedgerError::DueBeforeDate(_))), "{r:?}");
assert_eq!(fingerprint(&l), before);
}
/// Closes January 2026 on `l`.
///
/// ASSUMED: closing a period with nothing in it still marks it closed
/// (close_period returns Ok(None)); the precondition is asserted.
fn close_january(l: &mut Ledger) {
let p = l
.create_period("2026-01", d(2026, 1, 1), d(2026, 1, 31))
.unwrap();
l.close_period(p).unwrap();
assert!(l.closed_period_containing(d(2026, 1, 15)).is_some());
}
/// Oracle: create_invoice SCENARIO 1 (date in a closed period) + INVARIANT 3.
#[test]
fn create_invoice_refuses_closed_period_books_unchanged() {
let (mut l, _) = books();
let c = l.add_customer("Acme").unwrap();
close_january(&mut l);
let before = fingerprint(&l);
let r = l.create_invoice(c, d(2026, 1, 15), d(2026, 2, 15), vec![line("x", "1")]);
assert!(matches!(r, Err(LedgerError::ClosedPeriod(..))), "{r:?}");
assert_eq!(fingerprint(&l), before);
}
/// Oracle: create_bill SCENARIO 1 (date in a closed period) + INVARIANT 2.
#[test]
fn create_bill_refuses_closed_period_books_unchanged() {
let (mut l, _) = books();
let v = l.add_vendor("Hosting").unwrap();
close_january(&mut l);
let before = fingerprint(&l);
let r = l.create_bill(v, d(2026, 1, 15), d(2026, 2, 15), vec![line("x", "1")]);
assert!(matches!(r, Err(LedgerError::ClosedPeriod(..))), "{r:?}");
assert_eq!(fingerprint(&l), before);
}
// ------------------------------------------------------ control accounts
fn expect_mismatch(
r: Result<u64, LedgerError>,
code: &str,
name: &str,
expected: AccountType,
found: AccountType,
placeholder: bool,
) {
match r {
Err(LedgerError::ControlAccountMismatch {
code: c,
name: n,
expected: e,
found: f,
placeholder: p,
..
}) => {
assert_eq!(c, code);
assert_eq!(n, name);
assert_eq!(e, expected);
assert_eq!(f, found);
assert_eq!(p, placeholder);
}
other => panic!("expected ControlAccountMismatch for {code}, got {other:?}"),
}
}
/// Oracle: ControlAccountMismatch doc: 1200 of the wrong type is refused;
/// create_invoice INVARIANT 3: books unchanged (4000 not created either).
#[test]
fn invoice_refuses_wrong_type_receivables_books_unchanged() {
let (mut l, _) = books();
l.add_account("1200", "Loan", AccountType::Liability, None, false)
.unwrap();
let c = l.add_customer("Acme").unwrap();
let before = fingerprint(&l);
let r = l.create_invoice(c, d(2026, 3, 1), d(2026, 3, 1), vec![line("x", "1")]);
expect_mismatch(
r,
"1200",
"Loan",
AccountType::Asset,
AccountType::Liability,
false,
);
assert!(acct(&l, "4000").is_none());
assert_eq!(fingerprint(&l), before);
}
/// Oracle: ControlAccountMismatch doc: a placeholder 1200 (right type) is
/// refused, with placeholder = true; books unchanged.
#[test]
fn invoice_refuses_placeholder_receivables_books_unchanged() {
let (mut l, _) = books();
l.add_account("1200", "Receivables", AccountType::Asset, None, true)
.unwrap();
let c = l.add_customer("Acme").unwrap();
let before = fingerprint(&l);
let r = l.create_invoice(c, d(2026, 3, 1), d(2026, 3, 1), vec![line("x", "1")]);
expect_mismatch(
r,
"1200",
"Receivables",
AccountType::Asset,
AccountType::Asset,
true,
);
assert_eq!(fingerprint(&l), before);
}
/// Oracle: ControlAccountMismatch doc: 4000 must be a postable Revenue
/// account; books unchanged (1200 not created either).
#[test]
fn invoice_refuses_wrong_type_revenue_books_unchanged() {
let (mut l, _) = books();
l.add_account("4000", "Rent", AccountType::Expense, None, false)
.unwrap();
let c = l.add_customer("Acme").unwrap();
let before = fingerprint(&l);
let r = l.create_invoice(c, d(2026, 3, 1), d(2026, 3, 1), vec![line("x", "1")]);
expect_mismatch(
r,
"4000",
"Rent",
AccountType::Revenue,
AccountType::Expense,
false,
);
assert!(acct(&l, "1200").is_none());
assert_eq!(fingerprint(&l), before);
}
/// Oracle: ControlAccountMismatch doc: 2000 must be a postable Liability;
/// create_bill INVARIANT 2: books unchanged (6999 not created).
#[test]
fn bill_refuses_wrong_type_payables_books_unchanged() {
let (mut l, _) = books();
l.add_account("2000", "Savings", AccountType::Asset, None, false)
.unwrap();
let v = l.add_vendor("Hosting").unwrap();
let before = fingerprint(&l);
let r = l.create_bill(v, d(2026, 3, 1), d(2026, 3, 1), vec![line("x", "1")]);
expect_mismatch(
r,
"2000",
"Savings",
AccountType::Liability,
AccountType::Asset,
false,
);
assert!(acct(&l, "6999").is_none());
assert_eq!(fingerprint(&l), before);
}
/// Oracle: ControlAccountMismatch doc: a placeholder 6999 is refused; books
/// unchanged (2000 not created).
#[test]
fn bill_refuses_placeholder_general_expenses_books_unchanged() {
let (mut l, _) = books();
l.add_account("6999", "Other", AccountType::Expense, None, true)
.unwrap();
let v = l.add_vendor("Hosting").unwrap();
let before = fingerprint(&l);
let r = l.create_bill(v, d(2026, 3, 1), d(2026, 3, 1), vec![line("x", "1")]);
expect_mismatch(
r,
"6999",
"Other",
AccountType::Expense,
AccountType::Expense,
true,
);
assert!(acct(&l, "2000").is_none());
assert_eq!(fingerprint(&l), before);
}
/// Oracle: ControlAccountMismatch doc: 6999 of the wrong type is refused.
#[test]
fn bill_refuses_wrong_type_general_expenses_books_unchanged() {
let (mut l, _) = books();
l.add_account("6999", "Misc income", AccountType::Revenue, None, false)
.unwrap();
let v = l.add_vendor("Hosting").unwrap();
let before = fingerprint(&l);
let r = l.create_bill(v, d(2026, 3, 1), d(2026, 3, 1), vec![line("x", "1")]);
expect_mismatch(
r,
"6999",
"Misc income",
AccountType::Expense,
AccountType::Revenue,
false,
);
assert_eq!(fingerprint(&l), before);
}
// --------------------------------------------------------------- payments
/// Oracle: receive_payment INVARIANTS 1-2 (Dr cash / Cr 1200 for the
/// amount; paid rises; Partial when partly, Paid exactly when fully).
#[test]
fn receive_payment_partial_then_full() {
let (mut l, cash) = books();
let c = l.add_customer("Acme").unwrap();
let id = l
.create_invoice(c, d(2026, 3, 1), d(2026, 3, 31), vec![line("x", "300.00")])
.unwrap();
let ar = acct(&l, "1200").unwrap();
let e1 = l
.receive_payment(id, d(2026, 3, 10), dec("100.00"), cash)
.unwrap();
let lines = entry_lines(&l, e1);
assert_eq!(l.get_entry(e1).unwrap().status(), EntryStatus::Posted);
assert_eq!(l.get_entry(e1).unwrap().date(), d(2026, 3, 10));
assert!(lines.iter().all(|(a, _, _)| *a == ar || *a == cash));
assert_eq!(net_for(&lines, cash), dec("100.00"));
assert_eq!(net_for(&lines, ar), dec("-100.00"));
let inv = l.get_invoice(id).unwrap();
assert_eq!(inv.amount_paid, dec("100.00"));
assert_eq!(inv.status, DocumentStatus::Partial);
assert_eq!(inv.balance(), dec("200.00"));
assert_eq!(bal(&l, "1200"), dec("200.00"));
assert_eq!(bal(&l, "1000"), dec("100.00"));
l.receive_payment(id, d(2026, 3, 20), dec("200.00"), cash)
.unwrap();
let inv = l.get_invoice(id).unwrap();
assert_eq!(inv.amount_paid, dec("300.00"));
assert_eq!(inv.status, DocumentStatus::Paid);
assert_eq!(inv.balance(), Decimal::ZERO);
assert_eq!(bal(&l, "1200"), Decimal::ZERO);
assert_eq!(bal(&l, "1000"), dec("300.00"));
}
/// Oracle: receive_payment SCENARIO 1 (amount not positive) — refusal leaves
/// the books unchanged (B-8 general refusal rule, as for post_entry).
#[test]
fn receive_payment_refuses_non_positive_amount() {
let (mut l, cash) = books();
let c = l.add_customer("Acme").unwrap();
let id = l
.create_invoice(c, d(2026, 3, 1), d(2026, 3, 31), vec![line("x", "50")])
.unwrap();
let before = fingerprint(&l);
assert_eq!(
l.receive_payment(id, d(2026, 3, 2), Decimal::ZERO, cash),
Err(LedgerError::InvalidPaymentAmount(Decimal::ZERO))
);
assert_eq!(
l.receive_payment(id, d(2026, 3, 2), dec("-1"), cash),
Err(LedgerError::InvalidPaymentAmount(dec("-1")))
);
assert_eq!(fingerprint(&l), before);
}
/// Oracle: receive_payment SCENARIO 2 (exceeds what's owed: Overpayment;
/// never let paid exceed the total), including after full payment.
#[test]
fn receive_payment_refuses_overpayment() {
let (mut l, cash) = books();
let c = l.add_customer("Acme").unwrap();
let id = l
.create_invoice(c, d(2026, 3, 1), d(2026, 3, 31), vec![line("x", "50.00")])
.unwrap();
l.receive_payment(id, d(2026, 3, 2), dec("20.00"), cash)
.unwrap();
let before = fingerprint(&l);
assert_eq!(
l.receive_payment(id, d(2026, 3, 3), dec("30.01"), cash),
Err(LedgerError::Overpayment(dec("30.01"), dec("30.00")))
);
assert_eq!(fingerprint(&l), before);
l.receive_payment(id, d(2026, 3, 3), dec("30.00"), cash)
.unwrap();
let before = fingerprint(&l);
assert_eq!(
l.receive_payment(id, d(2026, 3, 4), dec("0.01"), cash),
Err(LedgerError::Overpayment(dec("0.01"), Decimal::ZERO))
);
assert_eq!(fingerprint(&l), before);
assert_eq!(l.get_invoice(id).unwrap().amount_paid, dec("50.00"));
}
/// Oracle: UnknownInvoice / UnknownBill error docs ("the referenced ...
/// does not exist"); a refusal leaves the books unchanged.
#[test]
fn payments_refuse_unknown_documents() {
let (mut l, cash) = books();
let before = fingerprint(&l);
assert_eq!(
l.receive_payment(77, d(2026, 3, 2), dec("1"), cash),
Err(LedgerError::UnknownInvoice(77))
);
assert_eq!(
l.pay_bill(78, d(2026, 3, 2), dec("1"), cash),
Err(LedgerError::UnknownBill(78))
);
assert_eq!(fingerprint(&l), before);
}
// ------------------------------------------------------------------ bills
/// Oracle: create_bill INVARIANT 1 (Dr 6999 / Cr 2000 for the sum of the
/// lines, dated the bill date, tagged with the vendor); controls created.
#[test]
fn create_bill_posts_dr_general_expenses_cr_payables_tagged() {
let (mut l, _) = books();
let v = l.add_vendor("Hosting").unwrap();
let id = l
.create_bill(
v,
d(2026, 4, 2),
d(2026, 5, 2),
vec![line("vps", "40.00"), line("backup", "9.50")],
)
.unwrap();
let ap = l.chart().find_by_code("2000").expect("2000 created");
assert_eq!(ap.account_type, AccountType::Liability);
assert!(!ap.placeholder);
let ex = l.chart().find_by_code("6999").expect("6999 created");
assert_eq!(ex.account_type, AccountType::Expense);
assert!(!ex.placeholder);
let bill = l.get_bill(id).unwrap().clone();
assert_eq!(bill.vendor_id, v);
assert_eq!(bill.total(), dec("49.50"));
assert_eq!(bill.amount_paid, Decimal::ZERO);
assert_eq!(bill.status, DocumentStatus::Open);
let e = l.get_entry(bill.entry_id).unwrap();
assert_eq!(e.status(), EntryStatus::Posted);
assert_eq!(e.date(), d(2026, 4, 2));
let lines = entry_lines(&l, bill.entry_id);
assert!(lines.iter().all(|(a, _, _)| *a == ap.id || *a == ex.id));
assert_eq!(net_for(&lines, ex.id), dec("49.50"));
assert_eq!(net_for(&lines, ap.id), dec("-49.50"));
assert_eq!(l.entry_vendor(bill.entry_id), Some(v));
assert_eq!(bal(&l, "2000"), dec("49.50"));
assert_eq!(bal(&l, "6999"), dec("49.50"));
}
/// Oracle: create_bill SCENARIO 1 (unknown vendor, bad line, due before
/// date) + INVARIANT 2.
#[test]
fn create_bill_refusals_leave_books_unchanged() {
let (mut l, _) = books();
let v = l.add_vendor("Hosting").unwrap();
let before = fingerprint(&l);
assert_eq!(
l.create_bill(555, d(2026, 4, 1), d(2026, 4, 1), vec![line("x", "1")]),
Err(LedgerError::UnknownVendor(555))
);
assert_eq!(
l.create_bill(v, d(2026, 4, 1), d(2026, 4, 1), vec![line("x", "-2")]),
Err(LedgerError::InvalidDocumentLineAmount(dec("-2")))
);
assert_eq!(
l.create_bill(v, d(2026, 4, 1), d(2026, 4, 1), vec![]),
Err(LedgerError::EmptyDocumentLines)
);
let r = l.create_bill(v, d(2026, 4, 2), d(2026, 4, 1), vec![line("x", "1")]);
assert!(matches!(r, Err(LedgerError::DueBeforeDate(_))), "{r:?}");
assert_eq!(fingerprint(&l), before);
}
/// Oracle: pay_bill INVARIANTS 1-2 (Dr 2000 / Cr cash for the amount,
/// tagged with the vendor; paid rises) and DocumentStatus docs.
#[test]
fn pay_bill_partial_then_full() {
let (mut l, cash) = books();
let v = l.add_vendor("Hosting").unwrap();
let id = l
.create_bill(v, d(2026, 4, 1), d(2026, 4, 30), vec![line("x", "80.00")])
.unwrap();
let ap = acct(&l, "2000").unwrap();
let e = l.pay_bill(id, d(2026, 4, 5), dec("30.00"), cash).unwrap();
let lines = entry_lines(&l, e);
assert_eq!(l.get_entry(e).unwrap().status(), EntryStatus::Posted);
assert_eq!(l.get_entry(e).unwrap().date(), d(2026, 4, 5));
assert!(lines.iter().all(|(a, _, _)| *a == ap || *a == cash));
assert_eq!(net_for(&lines, ap), dec("30.00"));
assert_eq!(net_for(&lines, cash), dec("-30.00"));
assert_eq!(l.entry_vendor(e), Some(v));
let b = l.get_bill(id).unwrap();
assert_eq!(b.amount_paid, dec("30.00"));
assert_eq!(b.status, DocumentStatus::Partial);
assert_eq!(bal(&l, "2000"), dec("50.00"));
l.pay_bill(id, d(2026, 4, 6), dec("50.00"), cash).unwrap();
let b = l.get_bill(id).unwrap();
assert_eq!(b.status, DocumentStatus::Paid);
assert_eq!(b.balance(), Decimal::ZERO);
assert_eq!(bal(&l, "2000"), Decimal::ZERO);
assert_eq!(bal(&l, "1000"), dec("-80.00"));
}
/// Oracle: pay_bill SCENARIO 1 (non-positive or exceeding what's owed:
/// refuse); refusal leaves the books unchanged.
#[test]
fn pay_bill_refuses_bad_amounts_books_unchanged() {
let (mut l, cash) = books();
let v = l.add_vendor("Hosting").unwrap();
let id = l
.create_bill(v, d(2026, 4, 1), d(2026, 4, 30), vec![line("x", "80.00")])
.unwrap();
let before = fingerprint(&l);
assert_eq!(
l.pay_bill(id, d(2026, 4, 2), Decimal::ZERO, cash),
Err(LedgerError::InvalidPaymentAmount(Decimal::ZERO))
);
assert_eq!(
l.pay_bill(id, d(2026, 4, 2), dec("-3"), cash),
Err(LedgerError::InvalidPaymentAmount(dec("-3")))
);
assert_eq!(
l.pay_bill(id, d(2026, 4, 2), dec("80.01"), cash),
Err(LedgerError::Overpayment(dec("80.01"), dec("80.00")))
);
assert_eq!(fingerprint(&l), before);
}
// ------------------------------------------------------------------ aging
/// Oracle: ar_aging doc (one row per customer with an outstanding balance,
/// bucketed by days past due) and B-18 buckets; fully paid customers get no
/// row.
///
/// ASSUMED: rows carry the customer's name and their order is unspecified
/// (the test sorts by name).
#[test]
fn ar_aging_example() {
let (mut l, cash) = books();
let a = l.add_customer("Acme").unwrap();
let b = l.add_customer("Beta").unwrap();
let z = l.add_customer("Zeta").unwrap();
let as_of = d(2026, 10, 13);
// Acme: due today (Current) 100, 45 days late 200 with 50 paid (31-60).
l.create_invoice(a, d(2026, 9, 13), as_of, vec![line("x", "100")])
.unwrap();
let i2 = l
.create_invoice(
a,
d(2026, 8, 1),
as_of - Duration::days(45),
vec![line("y", "200")],
)
.unwrap();
l.receive_payment(i2, d(2026, 9, 1), dec("50"), cash)
.unwrap();
// Beta: 91 days late (90+) 10.
l.create_invoice(
b,
d(2026, 6, 1),
as_of - Duration::days(91),
vec![line("z", "10")],
)
.unwrap();
// Zeta: fully paid, no row.
let i4 = l
.create_invoice(z, d(2026, 9, 1), d(2026, 9, 2), vec![line("w", "7")])
.unwrap();
l.receive_payment(i4, d(2026, 9, 2), dec("7"), cash)
.unwrap();
let mut rows = l.ar_aging(as_of);
rows.sort_by(|x, y| x.name.cmp(&y.name));
assert_eq!(rows.len(), 2);
assert_eq!(rows[0].name, "Acme");
assert_eq!(
buckets(&rows[0]),
[dec("100"), dec("0"), dec("150"), dec("0"), dec("0")]
);
assert_eq!(rows[1].name, "Beta");
assert_eq!(
buckets(&rows[1]),
[dec("0"), dec("0"), dec("0"), dec("0"), dec("10")]
);
}
/// Oracle: ap_aging doc (one row per vendor with an outstanding balance) and
/// B-18 (1-30 and 61-90 buckets).
#[test]
fn ap_aging_example() {
let (mut l, cash) = books();
let v = l.add_vendor("Hosting").unwrap();
let w = l.add_vendor("Paid Up").unwrap();
let as_of = d(2026, 10, 13);
l.create_bill(
v,
d(2026, 9, 1),
as_of - Duration::days(30),
vec![line("a", "12.34")],
)
.unwrap();
l.create_bill(
v,
d(2026, 7, 1),
as_of - Duration::days(61),
vec![line("b", "1.00")],
)
.unwrap();
let pb = l
.create_bill(w, d(2026, 9, 1), d(2026, 9, 30), vec![line("c", "5")])
.unwrap();
l.pay_bill(pb, d(2026, 9, 5), dec("5"), cash).unwrap();
let rows = l.ap_aging(as_of);
assert_eq!(rows.len(), 1);
assert_eq!(rows[0].name, "Hosting");
assert_eq!(
buckets(&rows[0]),
[dec("0"), dec("12.34"), dec("0"), dec("1.00"), dec("0")]
);
}
// ------------------------------------------------- sequence properties
#[derive(Clone, Debug)]
struct Doc {
who: usize,
line_cents: Vec<i64>,
due_offset: i64,
}
fn doc_strategy() -> impl Strategy<Value = Doc> {
(
0usize..3,
proptest::collection::vec(1i64..500_000, 1..4),
0i64..60,
)
.prop_map(|(who, line_cents, due_offset)| Doc {
who,
line_cents,
due_offset,
})
}
/// Payment request: (document index, percent of the remaining balance,
/// 1..=130 — above 100 is an overpayment attempt).
fn pay_strategy() -> impl Strategy<Value = Vec<(usize, u32)>> {
proptest::collection::vec((0usize..8, 1u32..=130), 0..16)
}
fn doc_lines(doc: &Doc) -> Vec<DocumentLine> {
doc.line_cents
.iter()
.map(|c| DocumentLine {
description: "item".into(),
amount: cents(*c),
})
.collect()
}
proptest! {
#![proptest_config(ProptestConfig::with_cases(64))]
/// Oracles: receive_payment INVARIANT 2 and SCENARIOS 1-2 (paid rises by
/// the amount, never exceeds total, Overpayment refused with books
/// unchanged); DocumentStatus docs (status law); Invoice::balance doc;
/// conservation (1200 balance = sum of open invoice balances, 4000 =
/// sum of invoice totals, cash = sum received, since every movement on
/// them comes through invoices and payments); ar_aging doc + B-18
/// (each open balance in exactly one bucket; buckets sum to open
/// balances; due today is Current).
#[test]
fn prop_invoices_and_payments_conserve(
docs in proptest::collection::vec(doc_strategy(), 1..6),
pays in pay_strategy(),
) {
let (mut l, cash) = books();
let names = ["Alpha", "Bravo", "Charlie"];
let custs: Vec<_> = names.iter().map(|n| l.add_customer(n).unwrap()).collect();
let base = d(2026, 3, 1);
let mut ids = Vec::new();
let mut expect_paid: Vec<Decimal> = Vec::new();
let mut totals: Vec<Decimal> = Vec::new();
for doc in &docs {
let id = l
.create_invoice(custs[doc.who], base, base + Duration::days(doc.due_offset), doc_lines(doc))
.unwrap();
ids.push(id);
expect_paid.push(Decimal::ZERO);
totals.push(doc.line_cents.iter().map(|c| cents(*c)).sum());
}
let mut received = Decimal::ZERO;
for (k, pct) in &pays {
let i = k % ids.len();
let remaining = totals[i] - expect_paid[i];
let amount = if *pct > 100 {
remaining + cents(i64::from(*pct - 100))
} else {
(remaining * Decimal::from(*pct) / Decimal::from(100)).round_dp(2)
};
let before = fingerprint(&l);
let r = l.receive_payment(ids[i], base + Duration::days(5), amount, cash);
if amount <= Decimal::ZERO {
prop_assert_eq!(r, Err(LedgerError::InvalidPaymentAmount(amount)));
prop_assert_eq!(fingerprint(&l), before);
} else if amount > remaining {
prop_assert_eq!(r, Err(LedgerError::Overpayment(amount, remaining)));
prop_assert_eq!(fingerprint(&l), before);
} else {
prop_assert!(r.is_ok(), "{:?}", r);
expect_paid[i] += amount;
received += amount;
}
}
let mut open_sum = Decimal::ZERO;
for (i, id) in ids.iter().enumerate() {
let inv = l.get_invoice(*id).unwrap();
prop_assert_eq!(inv.total(), totals[i]);
prop_assert_eq!(inv.amount_paid, expect_paid[i]);
prop_assert_eq!(inv.balance(), totals[i] - expect_paid[i]);
prop_assert!(inv.balance() >= Decimal::ZERO);
prop_assert_eq!(inv.status, status_law(expect_paid[i], totals[i]));
open_sum += inv.balance();
}
prop_assert_eq!(bal(&l, "1200"), open_sum);
prop_assert_eq!(bal(&l, "4000"), totals.iter().copied().sum::<Decimal>());
prop_assert_eq!(l.account_balance(cash).unwrap(), received);
// Aging as of a date spanning every bucket edge in turn.
for as_of_off in [0i64, 1, 30, 31, 60, 61, 90, 91, 200] {
let as_of = base + Duration::days(as_of_off);
let rows = l.ar_aging(as_of);
let mut want: std::collections::BTreeMap<String, [Decimal; 5]> = Default::default();
for (i, doc) in docs.iter().enumerate() {
let open = totals[i] - expect_paid[i];
if open.is_zero() {
continue;
}
let due = base + Duration::days(doc.due_offset);
want.entry(names[doc.who].to_string()).or_insert([Decimal::ZERO; 5])
[expected_bucket(due, as_of)] += open;
}
let got: std::collections::BTreeMap<String, [Decimal; 5]> =
rows.iter().map(|r| (r.name.clone(), buckets(r))).collect();
prop_assert_eq!(rows.len(), got.len(), "one row per customer");
prop_assert_eq!(&got, &want);
let row_sum: Decimal = rows.iter().map(|r| r.total()).sum();
prop_assert_eq!(row_sum, open_sum);
}
}
/// Oracles: pay_bill INVARIANTS 1-2 and SCENARIO 1; DocumentStatus docs;
/// Bill::balance doc; conservation (2000 = sum of open bill balances,
/// 6999 = sum of bill totals, cash = -sum paid); every payment entry
/// tagged with its vendor; ap_aging doc + B-18.
#[test]
fn prop_bills_and_payments_conserve(
docs in proptest::collection::vec(doc_strategy(), 1..6),
pays in pay_strategy(),
) {
let (mut l, cash) = books();
let names = ["Vendor A", "Vendor B", "Vendor C"];
let vends: Vec<_> = names.iter().map(|n| l.add_vendor(n).unwrap()).collect();
let base = d(2026, 5, 1);
let mut ids = Vec::new();
let mut expect_paid: Vec<Decimal> = Vec::new();
let mut totals: Vec<Decimal> = Vec::new();
for doc in &docs {
let id = l
.create_bill(vends[doc.who], base, base + Duration::days(doc.due_offset), doc_lines(doc))
.unwrap();
prop_assert_eq!(l.entry_vendor(l.get_bill(id).unwrap().entry_id), Some(vends[doc.who]));
ids.push(id);
expect_paid.push(Decimal::ZERO);
totals.push(doc.line_cents.iter().map(|c| cents(*c)).sum());
}
let mut paid_out = Decimal::ZERO;
for (k, pct) in &pays {
let i = k % ids.len();
let remaining = totals[i] - expect_paid[i];
let amount = if *pct > 100 {
remaining + cents(i64::from(*pct - 100))
} else {
(remaining * Decimal::from(*pct) / Decimal::from(100)).round_dp(2)
};
let before = fingerprint(&l);
let r = l.pay_bill(ids[i], base + Duration::days(3), amount, cash);
if amount <= Decimal::ZERO {
prop_assert_eq!(r, Err(LedgerError::InvalidPaymentAmount(amount)));
prop_assert_eq!(fingerprint(&l), before);
} else if amount > remaining {
prop_assert!(r.is_err(), "overpayment accepted: {:?}", r);
prop_assert_eq!(fingerprint(&l), before);
} else {
let e = r.unwrap();
prop_assert_eq!(l.entry_vendor(e), Some(vends[docs[i].who]));
expect_paid[i] += amount;
paid_out += amount;
}
}
let mut open_sum = Decimal::ZERO;
for (i, id) in ids.iter().enumerate() {
let b = l.get_bill(*id).unwrap();
prop_assert_eq!(b.total(), totals[i]);
prop_assert_eq!(b.amount_paid, expect_paid[i]);
prop_assert_eq!(b.balance(), totals[i] - expect_paid[i]);
prop_assert_eq!(b.status, status_law(expect_paid[i], totals[i]));
open_sum += b.balance();
}
prop_assert_eq!(bal(&l, "2000"), open_sum);
prop_assert_eq!(bal(&l, "6999"), totals.iter().copied().sum::<Decimal>());
prop_assert_eq!(l.account_balance(cash).unwrap(), -paid_out);
for as_of_off in [0i64, 15, 45, 75, 120] {
let as_of = base + Duration::days(as_of_off);
let rows = l.ap_aging(as_of);
let mut want: std::collections::BTreeMap<String, [Decimal; 5]> = Default::default();
for (i, doc) in docs.iter().enumerate() {
let open = totals[i] - expect_paid[i];
if open.is_zero() {
continue;
}
let due = base + Duration::days(doc.due_offset);
want.entry(names[doc.who].to_string()).or_insert([Decimal::ZERO; 5])
[expected_bucket(due, as_of)] += open;
}
let got: std::collections::BTreeMap<String, [Decimal; 5]> =
rows.iter().map(|r| (r.name.clone(), buckets(r))).collect();
prop_assert_eq!(rows.len(), got.len(), "one row per vendor");
prop_assert_eq!(&got, &want);
}
}
}