Documents could name only one revenue or general-expenses account per project, had no number of their own, and took a due date the caller had to work out. This slice gives the core the pieces the rest of S4 needs, and journals nothing: an old-shape document still writes byte for byte, so the owner's books need no upgrade for it. - Payment terms (B-39): Net n, Net n EOM, due on receipt and a custom date, with the early-payment discount and its exact bound (kernel::discount_max, Kani + bridge). A due date past the calendar's end is refused, never a panic, and a discount is refused with terms that have no net days. - Invoice numbers (B-40, A-2): first_number plus the count of numbered invoices, zero-padded to four digits and wider past 9,999, stored at issue, kept by a void; legacy invoices are uncounted. - Line accounts (A-10): a document line may name its account, and the entry posts one line per (account, project) part. An invoice line takes revenue or a liability holding no role (deferred revenue); a bill line an expense or a non-cash asset holding no role. Roles, cash accounts and headers are refused. - A line's amount is exactly its quantity times its unit price (kernel::line_amount, Kani + bridge): both or neither, never rounded. - A vendor's invoice number names one standing bill per vendor, with case, spaces and hyphens ignored, unless a duplicate reason says why (B-40). - The record fields S4.3 will journal: number, terms, bill-to and seller snapshots, customer reference, memo, the empty sends list S5 fills, and the bill's received_on, all optional and defaulted. The store refuses a line carrying an account, quantity or unit price before it rehearses or journals anything: those fields are journaled from S4.3's documents_v2 on, and I-3 will not let a public write replay without them. `document_line_fields_wait_for_documents_v2` proves the refusal, the unchanged journal and allocators, and that an ordinary line journals exactly as before. S4.3 removes this guard in the same change that journals the fields and adds its byte-for-byte test. The document commands keep their signatures; create_invoice_with and create_bill_with take the optional fields for S4.3 and for tests. They are the existing issue commands with more fields, not a new state transition, so no Lean Step is added. Kani: line_amount_is_exact runs in `just prove`; discount_max_is_bounded relates a product to a quotient and runs in `prove-slow` (163 s), as the proration charge harnesses do. just check green; just prove green.
1367 lines
48 KiB
Rust
1367 lines
48 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),
|
|
class_id: None,
|
|
account: None,
|
|
quantity: None,
|
|
unit_price: None,
|
|
}
|
|
}
|
|
|
|
/// 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" (its `owed`, derived by the ledger beside
|
|
/// amount_paid: total - amount_paid while it stands).
|
|
#[test]
|
|
fn invoice_total_is_sum_of_lines_and_balance_is_what_is_owed() {
|
|
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"),
|
|
owed: dec("90.00"),
|
|
status: DocumentStatus::Partial,
|
|
entry_id: 1,
|
|
number: None,
|
|
terms: None,
|
|
bill_to: None,
|
|
seller: None,
|
|
customer_ref: None,
|
|
memo: None,
|
|
sends: Vec::new(),
|
|
};
|
|
assert_eq!(inv.total().unwrap(), dec("150.00"));
|
|
assert_eq!(inv.balance().unwrap(), dec("90.00"));
|
|
}
|
|
|
|
/// Oracle: Bill doc: total() is the sum of lines, balance() what is owed.
|
|
#[test]
|
|
fn bill_total_is_sum_of_lines_and_balance_is_what_is_owed() {
|
|
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"),
|
|
owed: Decimal::ZERO,
|
|
status: DocumentStatus::Paid,
|
|
entry_id: 9,
|
|
vendor_ref: None,
|
|
received_on: None,
|
|
terms: None,
|
|
memo: None,
|
|
};
|
|
assert_eq!(bill.total().unwrap(), dec("20.00"));
|
|
assert_eq!(bill.balance().unwrap(), 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().unwrap(), 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().unwrap(), 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().unwrap(), 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),
|
|
class_id: None,
|
|
account: None,
|
|
quantity: None,
|
|
unit_price: None,
|
|
})
|
|
.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().unwrap(), dec("1234.56"));
|
|
assert_eq!(inv.amount_paid, Decimal::ZERO);
|
|
assert_eq!(inv.status, DocumentStatus::Open);
|
|
assert_eq!(inv.balance().unwrap(), 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`.
|
|
///
|
|
/// CONVENTION (docs/testing.md #4): 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().unwrap(), 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().unwrap(), 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().unwrap(), 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().unwrap(), 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).unwrap();
|
|
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).unwrap();
|
|
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),
|
|
class_id: None,
|
|
account: None,
|
|
quantity: None,
|
|
unit_price: None,
|
|
})
|
|
.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().unwrap(), totals[i]);
|
|
prop_assert_eq!(inv.amount_paid, expect_paid[i]);
|
|
prop_assert_eq!(inv.balance().unwrap(), totals[i] - expect_paid[i]);
|
|
prop_assert!(inv.balance().unwrap() >= Decimal::ZERO);
|
|
prop_assert_eq!(inv.status, status_law(expect_paid[i], totals[i]));
|
|
open_sum += inv.balance().unwrap();
|
|
}
|
|
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).unwrap();
|
|
let mut want: std::collections::BTreeMap<String, [Decimal; 5]> = Default::default();
|
|
for (i, doc) in docs.iter().enumerate() {
|
|
// Payments (dated base + 5) count only once made: aging
|
|
// as of a date sees what existed then (owner, 2026-10-13).
|
|
let paid = if as_of >= base + Duration::days(5) { expect_paid[i] } else { Decimal::ZERO };
|
|
let open = totals[i] - paid;
|
|
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().unwrap()).sum();
|
|
let want_sum: Decimal = want.values().flatten().copied().sum();
|
|
prop_assert_eq!(row_sum, want_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().unwrap(), totals[i]);
|
|
prop_assert_eq!(b.amount_paid, expect_paid[i]);
|
|
prop_assert_eq!(b.balance().unwrap(), totals[i] - expect_paid[i]);
|
|
prop_assert_eq!(b.status, status_law(expect_paid[i], totals[i]));
|
|
open_sum += b.balance().unwrap();
|
|
}
|
|
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).unwrap();
|
|
let mut want: std::collections::BTreeMap<String, [Decimal; 5]> = Default::default();
|
|
for (i, doc) in docs.iter().enumerate() {
|
|
// Payments (dated base + 3) count only once made.
|
|
let paid = if as_of >= base + Duration::days(3) { expect_paid[i] } else { Decimal::ZERO };
|
|
let open = totals[i] - paid;
|
|
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);
|
|
}
|
|
}
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Decisions (owner, 2026-10-13): no payment before its document; aging
|
|
// as of a date sees only what existed by then.
|
|
// ---------------------------------------------------------------------------
|
|
|
|
/// A payment can't apply to an invoice or bill that didn't exist yet (a
|
|
/// real prepayment is a deposit, recorded differently). Refused, books
|
|
/// unchanged.
|
|
#[test]
|
|
fn a_payment_never_predates_its_document() {
|
|
let (mut l, cash) = books();
|
|
let c = l.add_customer("Acme").unwrap();
|
|
let v = l.add_vendor("Vend").unwrap();
|
|
let inv = l
|
|
.create_invoice(c, d(2026, 3, 10), d(2026, 4, 10), vec![line("w", "100.00")])
|
|
.unwrap();
|
|
let bill = l
|
|
.create_bill(v, d(2026, 3, 10), d(2026, 4, 10), vec![line("w", "100.00")])
|
|
.unwrap();
|
|
let before = fingerprint(&l);
|
|
assert_eq!(
|
|
l.receive_payment(inv, d(2026, 3, 9), dec("10.00"), cash),
|
|
Err(LedgerError::PaymentBeforeDocument {
|
|
date: d(2026, 3, 9),
|
|
document_date: d(2026, 3, 10),
|
|
})
|
|
);
|
|
assert!(matches!(
|
|
l.pay_bill(bill, d(2026, 3, 1), dec("10.00"), cash),
|
|
Err(LedgerError::PaymentBeforeDocument { .. })
|
|
));
|
|
assert_eq!(fingerprint(&l), before);
|
|
// The same day is fine.
|
|
l.receive_payment(inv, d(2026, 3, 10), dec("10.00"), cash)
|
|
.unwrap();
|
|
}
|
|
|
|
/// Aging as of a date shows only documents issued by then, less only the
|
|
/// payments made by then.
|
|
#[test]
|
|
fn aging_as_of_a_date_sees_only_what_existed_then() {
|
|
let (mut l, cash) = books();
|
|
let c = l.add_customer("Acme").unwrap();
|
|
let early = l
|
|
.create_invoice(c, d(2026, 1, 5), d(2026, 2, 4), vec![line("a", "300.00")])
|
|
.unwrap();
|
|
l.create_invoice(c, d(2026, 3, 1), d(2026, 3, 31), vec![line("b", "500.00")])
|
|
.unwrap();
|
|
l.receive_payment(early, d(2026, 2, 20), dec("100.00"), cash)
|
|
.unwrap();
|
|
let total = |as_of| -> Decimal {
|
|
l.ar_aging(as_of)
|
|
.unwrap()
|
|
.iter()
|
|
.map(|r| buckets(r).iter().copied().sum::<Decimal>())
|
|
.sum()
|
|
};
|
|
// Jan 31: only the first invoice exists, nothing paid yet.
|
|
assert_eq!(total(d(2026, 1, 31)), dec("300.00"));
|
|
// Feb 28: paid 100 of it; the March invoice doesn't exist yet.
|
|
assert_eq!(total(d(2026, 2, 28)), dec("200.00"));
|
|
// Mar 15: both.
|
|
assert_eq!(total(d(2026, 3, 15)), dec("700.00"));
|
|
// Before anything was issued: nothing.
|
|
assert!(l.ar_aging(d(2025, 12, 31)).unwrap().is_empty());
|
|
}
|