From 48062b89f0c1d4f256e5e802916b627ec8242541 Mon Sep 17 00:00:00 2001 From: Stephen Waits Date: Thu, 5 Nov 2026 08:36:10 -0700 Subject: [PATCH] test(fuzz): the operation sequence gains documents, settlements and drafts (P12) The sequence only knew accounts, lines, periods and closes, but that is no longer where money moves: invoices, bills, their payments, write-offs and voids are now first class, and the draft-state commands decide whether an entry exists at all. Op gains each of them, on the same Ledger the store wraps, with random parties, dates and amounts (including the raw-decimal extremes). The invariant after every sequence gains B-38: each control account equals its documents as of three dates, and a control check too large to compute is a refusal, never a difference. That is the identity that would catch a payment tagged to the wrong document, or one that went unmatched, which the trial balance alone cannot see. --- fuzz/fuzz_targets/fuzz_op_sequence.rs | 350 ++++++++++++++++++++++++-- 1 file changed, 327 insertions(+), 23 deletions(-) diff --git a/fuzz/fuzz_targets/fuzz_op_sequence.rs b/fuzz/fuzz_targets/fuzz_op_sequence.rs index 470aab2..e753fae 100644 --- a/fuzz/fuzz_targets/fuzz_op_sequence.rs +++ b/fuzz/fuzz_targets/fuzz_op_sequence.rs @@ -2,27 +2,112 @@ // balanced pairs, foreign-currency lines, periods, and closes, with // amounts built from raw decimal bits (huge, tiny, odd scales). Refusals // are fine; panics, hangs, and broken books are not. After any sequence: -// the trial balance balances, every posted entry validates, and no draft -// sits in a closed period (the Lean invariant Inv). +// the trial balance balances, every posted entry validates, no draft sits +// in a closed period (the Lean invariant Inv), and the control accounts +// equal their documents (control_check's difference is zero, B-38). +// +// The documents (invoices, bills), their settlements and payments, write-offs +// and voids, and the draft-state ops (P12) run on the Ledger the store wraps: +// the store adds the journal, the engine is the state machine fuzzed here. #![no_main] use arbitrary::Arbitrary; use chrono::NaiveDate; -use ledger_core::{AccountType, Currency, EntryStatus, Ledger, Money, Side}; +use ledger_core::arap::{DocRef, DocumentLine}; +use ledger_core::{ + AccountType, Application, Currency, DraftState, EntryStatus, Ledger, Money, PaymentDetails, + SettleParams, Side, +}; use libfuzzer_sys::fuzz_target; use rust_decimal::Decimal; #[derive(Arbitrary, Debug, Clone, Copy)] enum Op { - AddAccount { code: u8, typ: u8, placeholder: bool }, - CreateEntry { day: u8 }, - AddLine { entry: u8, account: u8, debit: bool, amount: Amount }, - Pair { entry: u8, debit: u8, credit: u8, amount: Amount }, - AddLineFx { entry: u8, account: u8, debit: bool, foreign: Amount, rate: Amount }, - PostEntry { entry: u8 }, - ReverseEntry { entry: u8, day: u8 }, - CreatePeriod { start: u8, len: u8 }, - ClosePeriod { period: u8 }, + AddAccount { + code: u8, + typ: u8, + placeholder: bool, + }, + CreateEntry { + day: u8, + }, + AddLine { + entry: u8, + account: u8, + debit: bool, + amount: Amount, + }, + Pair { + entry: u8, + debit: u8, + credit: u8, + amount: Amount, + }, + AddLineFx { + entry: u8, + account: u8, + debit: bool, + foreign: Amount, + rate: Amount, + }, + PostEntry { + entry: u8, + }, + ReverseEntry { + entry: u8, + day: u8, + }, + CreatePeriod { + start: u8, + len: u8, + }, + ClosePeriod { + period: u8, + }, + CreateInvoice { + day: u8, + terms: u8, + amount: Amount, + }, + CreateBill { + day: u8, + terms: u8, + amount: Amount, + }, + /// A payment on a document (mod the documents) from the bank or a + /// fuzzed account, with a reference or not. + Pay { + doc: u8, + day: u8, + amount: Amount, + from: u8, + reference: bool, + }, + /// One payment settling two documents, the first with a discount. + Settle { + a: u8, + b: u8, + day: u8, + cash: Amount, + discount: Amount, + }, + WriteOff { + doc: u8, + day: u8, + amount: Amount, + }, + VoidDocument { + doc: u8, + day: u8, + }, + SetDraftState { + entry: u8, + awaiting: bool, + note: u8, + }, + DiscardDraft { + entry: u8, + }, } /// A decimal from raw parts: any mantissa up to 96 bits, any scale 0-28. @@ -42,7 +127,13 @@ impl Amount { // Mostly ordinary money, so entries balance and post. return Decimal::new(i64::from(self.lo % 1_000_000), 2); } - Decimal::from_parts(self.lo, self.mid, self.hi, self.negative, u32::from(self.scale % 29)) + Decimal::from_parts( + self.lo, + self.mid, + self.hi, + self.negative, + u32::from(self.scale % 29), + ) } } @@ -63,34 +154,85 @@ fn day(d: u8) -> NaiveDate { fuzz_target!(|ops: Vec| { let mut l = Ledger::new(Currency::USD); let mut entries: Vec = Vec::new(); + let mut docs: Vec = Vec::new(); + // The books' fixed parts: the control accounts documents post to, a bank, + // a customer and a vendor. + let _ = ( + l.add_control_account(&ledger_core::RECEIVABLES), + l.add_control_account(&ledger_core::PAYABLES), + l.add_control_account(&ledger_core::BAD_DEBTS), + ); + let bank = l + .add_account("1900", "Bank", AccountType::Asset, None, false) + .ok(); + let customer = l.add_customer("Fernhill Labs").ok(); + let vendor = l.add_vendor("Tobin Harlow Supply").ok(); + let doc_line = |a: Amount| { + vec![DocumentLine { + description: "work".into(), + amount: a.get(), + class_id: None, + account: None, + quantity: None, + unit_price: None, + }] + }; let pick = |v: &[u64], i: u8| v.get(i as usize % v.len().max(1)).copied(); for op in ops.into_iter().take(200) { let accounts: Vec = l.chart().all().map(|a| a.id).collect(); match op { - Op::AddAccount { code, typ, placeholder } => { - let _ = l.add_account(&format!("{}", 1000 + u32::from(code % 40)), "f", account_type(typ), None, placeholder); + Op::AddAccount { + code, + typ, + placeholder, + } => { + let _ = l.add_account( + &format!("{}", 1000 + u32::from(code % 40)), + "f", + account_type(typ), + None, + placeholder, + ); } Op::CreateEntry { day: d } => { if let Ok(id) = l.try_create_entry(day(d), "f") { entries.push(id); } } - Op::AddLine { entry, account, debit, amount } => { + Op::AddLine { + entry, + account, + debit, + amount, + } => { if let (Some(e), Some(a)) = (pick(&entries, entry), pick(&accounts, account)) { let side = if debit { Side::Debit } else { Side::Credit }; let _ = l.add_line(e, a, side, Money::new(amount.get(), Currency::USD)); } } - Op::Pair { entry, debit, credit, amount } => { - if let (Some(e), Some(a), Some(b)) = - (pick(&entries, entry), pick(&accounts, debit), pick(&accounts, credit)) - { + Op::Pair { + entry, + debit, + credit, + amount, + } => { + if let (Some(e), Some(a), Some(b)) = ( + pick(&entries, entry), + pick(&accounts, debit), + pick(&accounts, credit), + ) { let m = Money::new(amount.get(), Currency::USD); let _ = l.add_line(e, a, Side::Debit, m); let _ = l.add_line(e, b, Side::Credit, m); } } - Op::AddLineFx { entry, account, debit, foreign, rate } => { + Op::AddLineFx { + entry, + account, + debit, + foreign, + rate, + } => { if let (Some(e), Some(a)) = (pick(&entries, entry), pick(&accounts, account)) { let side = if debit { Side::Debit } else { Side::Credit }; let _ = l.add_line_fx(e, a, side, Currency::EUR, foreign.get(), rate.get()); @@ -111,6 +253,147 @@ fuzz_target!(|ops: Vec| { Op::CreatePeriod { start, len } => { let _ = l.create_period("p", day(start), day(start.saturating_add(len % 60))); } + Op::CreateInvoice { + day: d, + terms, + amount, + } => { + if let Some(c) = customer + && let Ok(id) = l.create_invoice_with( + c, + day(d), + day(d) + chrono::Days::new(u64::from(terms)), + doc_line(amount), + Default::default(), + ) + { + docs.push(DocRef::Invoice(id)); + } + } + Op::CreateBill { + day: d, + terms, + amount, + } => { + if let Some(v) = vendor + && let Ok(id) = l.create_bill_with( + v, + day(d), + day(d) + chrono::Days::new(u64::from(terms)), + doc_line(amount), + Default::default(), + ) + { + docs.push(DocRef::Bill(id)); + } + } + Op::Pay { + doc, + day: d, + amount, + from, + reference, + } => { + if !docs.is_empty() { + let cash = if from % 4 == 0 { + pick(&accounts, from) + } else { + bank + }; + let details = PaymentDetails { + instrument: None, + reference: reference.then(|| format!("trace {doc}{d}")), + }; + if let Some(cash) = cash { + let _ = match docs[doc as usize % docs.len()] { + DocRef::Invoice(id) => { + l.receive_payment_with(id, day(d), amount.get(), cash, &details) + } + DocRef::Bill(id) => { + l.pay_bill_with(id, day(d), amount.get(), cash, &details) + } + }; + } + } + } + Op::Settle { + a, + b, + day: d, + cash, + discount, + } => { + if let (false, Some(cash_account)) = (docs.is_empty(), bank) { + let first = docs[a as usize % docs.len()]; + let second = docs[b as usize % docs.len()]; + let party = match first { + DocRef::Invoice(id) => l.get_invoice(id).map(|i| i.customer_id), + DocRef::Bill(id) => l.get_bill(id).map(|v| v.vendor_id), + }; + if let Some(party) = party { + let _ = l.settle(SettleParams { + date: day(d), + description: "two documents, one payment".into(), + cash_account, + party, + applications: vec![ + Application { + doc: first, + cash: cash.get(), + discount: discount.get(), + discount_reason: Some("fuzz".into()), + }, + Application { + doc: second, + cash: cash.get(), + discount: Decimal::ZERO, + discount_reason: None, + }, + ], + fee: None, + instrument: None, + reference: None, + }); + } + } + } + Op::WriteOff { + doc, + day: d, + amount, + } => { + if !docs.is_empty() + && let DocRef::Invoice(id) = docs[doc as usize % docs.len()] + { + let _ = l.write_off(id, day(d), amount.get(), "uncollectible"); + } + } + Op::VoidDocument { doc, day: d } => { + if !docs.is_empty() { + let _ = l.void_document(docs[doc as usize % docs.len()], day(d), "duplicate"); + } + } + Op::SetDraftState { + entry, + awaiting, + note, + } => { + if let Some(e) = pick(&entries, entry) { + let state = if awaiting { + DraftState::AwaitingEvidence { + note: format!("receipt {note}"), + } + } else { + DraftState::Building + }; + let _ = l.set_draft_state(e, state); + } + } + Op::DiscardDraft { entry } => { + if let Some(e) = pick(&entries, entry) { + let _ = l.discard_draft(e, "fuzz"); + } + } Op::ClosePeriod { period } => { let ids: Vec = l.list_periods().iter().map(|p| p.id).collect(); if let Some(p) = pick(&ids, period) { @@ -122,13 +405,34 @@ fuzz_target!(|ops: Vec| { let tb = ledger_core::reports::trial_balance(&l); assert_eq!(tb.total_debits, tb.total_credits, "trial balance broken"); for e in l.posted_entries() { - assert!(e.validate(l.chart()).is_ok(), "posted entry {} invalid", e.id()); + assert!( + e.validate(l.chart()).is_ok(), + "posted entry {} invalid", + e.id() + ); + } + // The control accounts equal their documents, at any day (B-38): too + // large to compute is a refusal, never a difference. + for d in [0u8, 60, 199] { + if let Ok(checks) = l.control_check(day(d)) { + for c in checks { + assert_eq!( + c.difference, + Decimal::ZERO, + "{} differs from its documents", + c.role + ); + } + } } for id in 1..l.next_entry_id() { if let Some(e) = l.get_entry(id) && e.status() == EntryStatus::Draft { - assert!(l.closed_period_containing(e.date()).is_none(), "draft in a closed period"); + assert!( + l.closed_period_containing(e.date()).is_none(), + "draft in a closed period" + ); } } });