Files
wanderbooks/crates/ledger-store/tests/arap_props.rs
T
swaitsandClaude Fable 5.1 335a3eaa7e merge: P4b S7, bad debts (branch S7, 4b4fecb9)
The fourth of the paused branches: Ledger::write_off and the bad_debts
control role, invoice.written_off with its evidence filed in the same
store call, books.write_off_invoice, the write-off on the timeline and
in Needs attention, Lean writeOff_within_bounds, rules.md B-41 and the
bookspec examples. Twenty-eight files conflicted with the S4.4, S5 and
S6 merges; resolved as plan 8.0.2 says, keeping both sides' appends with
their seams restored, and: the scope table at 125; S7's law numbered 37;
B-41 placed before B-42; the handbook's step 4 and recovery procedures
folded into the one invoices-and-bills section; S7's wire tests in their
own module with the confirm S4.4's contract needs; S7's copy of the
payment steps dropped (S6 replaced them with Ledger::settle) and its
write-off journaled through S6's journal_auto_lines; in the old-books
fixture S7's hand-written write-off moved to a second invoice, since
S6's hand-written payment settles invoice 1 in full.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HS8WZziDnyxunC23MUxrLp
2026-10-28 21:47:33 -06:00

443 lines
17 KiB
Rust

//! Documents through the store, at random (rules.md B-38 INVARIANTS 1, 2,
//! 4 and 6; plan P4b.15): histories of invoices, bills, payments (with a
//! payment method and a reference, or none), voids, reversals of any
//! entry, closed months and refused hand lines, run through `Store`, then
//! replayed (the books reopened) and read by a `Snapshot`.
//!
//! ```text
//! GOAL: a document's state is a function of the journal and the day:
//! the live books, their replay and a viewer's snapshot agree on
//! every figure, at every date, and the control accounts equal
//! their documents.
//! SOURCE: docs/spec/rules.md B-28 (replay), B-38 INVARIANTS 1, 2, 4, 6.
//! ```
use chrono::NaiveDate;
use ledger_core::arap::DocumentLine;
use ledger_core::{
AccountType, Currency, DocRef, Ledger, LedgerError, Money, PaymentDetails, Side,
};
use ledger_store::{Store, StoreError, snapshot::Snapshot};
use proptest::prelude::*;
use rust_decimal::Decimal;
use std::path::{Path, PathBuf};
fn day(n: u8) -> NaiveDate {
NaiveDate::from_ymd_opt(2026, 1, 1).unwrap() + chrono::Days::new(u64::from(n))
}
fn cents(c: i64) -> Decimal {
Decimal::new(c, 2)
}
/// A test's dir, removed when the test ends, pass or panic.
struct TestDir(PathBuf);
impl Drop for TestDir {
fn drop(&mut self) {
let _ = std::fs::remove_dir_all(&self.0);
}
}
fn test_dir() -> TestDir {
use std::sync::atomic::{AtomicUsize, Ordering};
static N: AtomicUsize = AtomicUsize::new(0);
let dir = std::env::temp_dir().join(format!(
"wanderbooks-arap-props-{}-{}",
std::process::id(),
N.fetch_add(1, Ordering::Relaxed)
));
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(&dir).unwrap();
TestDir(dir)
}
/// One step of a random history.
#[derive(Clone, Debug)]
enum Op {
/// An invoice (true) or a bill, on day `n`, for `c` cents.
Create(bool, u8, i64),
/// A payment on document `k` (mod the documents) on day `n`, for `c`
/// cents, by card or not, with a reference or not.
Pay(usize, u8, i64, bool, bool),
/// Reverse entry `k` (mod the posted entries) on day `n`.
Reverse(usize, u8),
/// Void document `k` on day `n`.
Void(usize, u8),
/// Record a send of document `k` (an invoice; a bill is skipped) on
/// day `n`, a reminder after its first send. A send moves no figure.
MarkSent(usize, u8),
/// Write off an invoice `k` (mod the documents) on day `n` for `c`
/// cents, with its evidence filed in the same call.
WriteOff(usize, u8, i64),
/// Close month `m` (January to March), in order.
Close(u8),
/// A hand line on receivables (true) or payables: always refused.
Hand(bool, i64),
/// One payment settling two documents of one party, the first with a
/// discount and (on invoices) a processor's fee.
Settle {
a: usize,
b: usize,
n: u8,
cash: i64,
discount: i64,
fee: i64,
},
}
fn arb_op() -> impl Strategy<Value = Op> {
prop_oneof![
3 => (any::<bool>(), 0u8..80, 1i64..50_000).prop_map(|(i, n, c)| Op::Create(i, n, c)),
4 => (0usize..8, 0u8..90, 1i64..60_000, any::<bool>(), any::<bool>())
.prop_map(|(k, n, c, card, r)| Op::Pay(k, n, c, card, r)),
3 => (0usize..40, 0u8..90).prop_map(|(k, n)| Op::Reverse(k, n)),
1 => (0usize..8, 0u8..90).prop_map(|(k, n)| Op::Void(k, n)),
2 => (0usize..8, 0u8..90).prop_map(|(k, n)| Op::MarkSent(k, n)),
2 => (0usize..8, 0u8..90, 1i64..40_000)
.prop_map(|(k, n, c)| Op::WriteOff(k, n, c)),
1 => (1u8..4).prop_map(Op::Close),
1 => (any::<bool>(), 1i64..10_000).prop_map(|(r, c)| Op::Hand(r, c)),
2 => (0usize..8, 0usize..8, 0u8..90, 1i64..20_000, 0i64..2_000, 0i64..500)
.prop_map(|(a, b, n, cash, discount, fee)| Op::Settle {
a,
b,
n,
cash,
discount,
fee,
}),
]
}
/// The books' fixed accounts: bank, card (and its payment method).
struct Setup {
bank: u64,
card: u64,
visa: u64,
customer: u64,
vendor: u64,
}
fn setup(dir: &Path) -> (Store, Setup) {
let mut s = Store::create(dir, Currency::USD).unwrap();
let bank = s
.add_account("1000", "Bank", AccountType::Asset, None, false)
.unwrap();
let card = s
.add_account("2050", "Card", AccountType::Liability, None, false)
.unwrap();
s.add_account("3000", "Capital", AccountType::Equity, None, false)
.unwrap();
let visa = s
.add_instrument(
ledger_core::InstrumentKind::BusinessCard,
"Fernhill Visa",
Some("4242"),
None,
card,
)
.unwrap();
let customer = s.add_customer("Fernhill Labs").unwrap();
let vendor = s.add_vendor("Tobin Harlow Supply").unwrap();
(
s,
Setup {
bank,
card,
visa,
customer,
vendor,
},
)
}
/// Run `ops` on the store; a refusal must journal nothing.
fn run(s: &mut Store, k: &Setup, ops: &[Op], docs: &mut Vec<DocRef>) -> Result<(), TestCaseError> {
for op in ops {
let head = s.head();
let line = |c: i64| {
vec![DocumentLine {
description: "work".into(),
amount: cents(c),
class_id: None,
account: None,
quantity: None,
unit_price: None,
}]
};
let result: Result<(), StoreError> = match *op {
Op::Create(true, n, c) => s
.create_invoice(k.customer, day(n), day(n) + chrono::Days::new(30), line(c))
.map(|i| docs.push(DocRef::Invoice(i))),
Op::Create(false, n, c) => s
.create_bill(k.vendor, day(n), day(n) + chrono::Days::new(30), line(c))
.map(|i| docs.push(DocRef::Bill(i))),
Op::Pay(..) if docs.is_empty() => Ok(()),
Op::Pay(i, n, c, by_card, reference) => {
let details = PaymentDetails {
instrument: by_card.then_some(k.visa),
reference: reference.then(|| format!("trace {i}{n}")),
};
match docs[i % docs.len()] {
DocRef::Invoice(id) => s
.receive_payment_with(id, day(n), cents(c), k.bank, &details)
.map(drop),
DocRef::Bill(id) => {
let from = if by_card { k.card } else { k.bank };
s.pay_bill_with(id, day(n), cents(c), from, &details)
.map(drop)
}
}
}
Op::Reverse(i, n) => {
let posted: Vec<u64> = s.ledger().posted_entries().map(|e| e.id()).collect();
match posted.get(i % posted.len().max(1)) {
Some(&e) => s.reverse_entry(e, day(n), "undo").map(drop),
None => Ok(()),
}
}
Op::Void(..) if docs.is_empty() => Ok(()),
Op::Void(i, n) => s
.void_document(docs[i % docs.len()], day(n), "duplicate")
.map(drop),
Op::MarkSent(..) if docs.is_empty() => Ok(()),
Op::MarkSent(i, n) => match docs[i % docs.len()] {
// Bills get no sends: we receive them (plan P4b S5).
DocRef::Bill(_) => Ok(()),
DocRef::Invoice(id) => {
let kind = if s
.ledger()
.get_invoice(id)
.is_some_and(|i| !i.sends.is_empty())
{
ledger_core::arap::SendKind::Reminder
} else {
ledger_core::arap::SendKind::Original
};
s.mark_sent(
id,
ledger_core::arap::Sent {
on: day(n),
by: "agent".into(),
via: ledger_core::arap::SentVia::Email,
to: vec!["billing@fernhill.example".into()],
kind,
reference: None,
note: None,
},
)
.map(drop)
}
},
Op::WriteOff(..) if docs.is_empty() => Ok(()),
Op::WriteOff(i, n, c) => match docs[i % docs.len()] {
// A bill is never written off: the API takes an invoice.
DocRef::Bill(_) => Ok(()),
DocRef::Invoice(id) => s
.write_off_invoice(
id,
day(n),
cents(c),
"uncollectible",
Some(ledger_store::WriteOffEvidence::File {
file_name: "collection-letter.pdf",
mime_type: "application/pdf",
bytes: b"%PDF-1.4\nFinal demand\n%%EOF\n",
description: Some("Collection attempts"),
}),
)
.map(drop),
},
Op::Close(m) => {
let start = NaiveDate::from_ymd_opt(2026, u32::from(m), 1).unwrap();
let end = start + chrono::Months::new(1) - chrono::Days::new(1);
s.create_period(&format!("2026-{m:02}"), start, end)
.and_then(|p| {
let mid = s.head();
s.close_period(p).map(drop).inspect_err(|_| {
// The period itself was journaled: only the
// close must leave nothing behind.
assert_eq!(s.head(), mid);
})
})
.map(drop)
}
Op::Settle { .. } if docs.is_empty() => Ok(()),
Op::Settle {
a,
b,
n,
cash,
discount,
fee,
} => {
let first = docs[a % docs.len()];
let mut second = docs[b % docs.len()];
if second == first && docs.len() > 1 {
second = docs[(b + 1) % docs.len()];
}
let same_kind = matches!(
(first, second),
(DocRef::Invoice(_), DocRef::Invoice(_)) | (DocRef::Bill(_), DocRef::Bill(_))
);
if !same_kind {
// One party and one kind per settlement: not this pair.
Ok(())
} else {
let party = match first {
DocRef::Invoice(id) => s.ledger().get_invoice(id).map(|i| i.customer_id),
DocRef::Bill(id) => s.ledger().get_bill(id).map(|v| v.vendor_id),
}
.unwrap();
let invoices = matches!(first, DocRef::Invoice(_));
let money = cents(cash);
let apps = vec![
ledger_core::Application {
doc: first,
cash: money,
discount: cents(discount),
discount_reason: (discount > 0)
.then(|| format!("a late discount, n {n}")),
},
ledger_core::Application {
doc: second,
cash: money,
discount: Decimal::ZERO,
discount_reason: None,
},
];
s.settle(ledger_core::SettleParams {
date: day(n),
description: "two documents, one payment".into(),
cash_account: k.bank,
party,
applications: apps,
fee: (invoices && fee > 0).then_some(ledger_core::Fee {
amount: cents(fee),
account: None,
vendor: None,
}),
instrument: None,
reference: None,
})
.map(drop)
}
}
Op::Hand(receivables, c) => {
let code = if receivables { "1200" } else { "2000" };
match s.ledger().chart().find_by_code(code).map(|a| a.id) {
Some(a) if s.ledger().control_role_of(a).is_some() => {
// After every month the history can close.
let on = NaiveDate::from_ymd_opt(2026, 12, 1).unwrap();
let e = s.create_entry(on, "hand").unwrap();
let mid = s.head();
let m = Money::new(cents(c), Currency::USD);
let r = s.add_line(e, a, Side::Debit, m);
prop_assert!(
matches!(
r,
Err(StoreError::Ledger(LedgerError::UntaggedControlLine { .. }))
),
"{r:?}"
);
prop_assert_eq!(s.head(), mid);
Ok(())
}
_ => Ok(()),
}
}
};
if result.is_err() && !matches!(op, Op::Close(_)) {
prop_assert_eq!(s.head(), head, "{:?} refused, yet journaled", op);
}
}
Ok(())
}
/// Every day a figure could change on, and the end.
fn days(l: &Ledger) -> Vec<NaiveDate> {
let mut v: Vec<NaiveDate> = l.posted_entries().map(|e| e.date()).collect();
v.push(NaiveDate::from_ymd_opt(2026, 12, 31).unwrap());
v.sort();
v.dedup();
v
}
/// What the books say about their documents on day `on`: each state, the
/// control check and both agings, as one comparable value.
fn figures(l: &Ledger, docs: &[DocRef], on: NaiveDate) -> serde_json::Value {
let states: Vec<_> = docs.iter().map(|d| l.doc_state(*d, on).unwrap()).collect();
serde_json::json!({
"states": states,
"control": l.control_check(on).unwrap(),
"ar": l.ar_aging(on).unwrap(),
"ap": l.ap_aging(on).unwrap(),
})
}
/// The live books, their replay and a snapshot agree; the control
/// accounts equal their documents, each within bounds, at every date.
fn agree(live: &Ledger, dir: &Path, docs: &[DocRef]) -> Result<(), TestCaseError> {
let replayed = Store::open(dir).unwrap();
let snap = Snapshot::load(dir).unwrap();
prop_assert!(
live.differs_from(replayed.ledger()).is_empty(),
"replay differs: {:?}",
live.differs_from(replayed.ledger())
);
prop_assert!(
live.differs_from(snap.ledger()).is_empty(),
"snapshot differs: {:?}",
live.differs_from(snap.ledger())
);
for on in days(live) {
let f = figures(live, docs, on);
prop_assert_eq!(
&f,
&figures(replayed.ledger(), docs, on),
"replay on {}",
on
);
prop_assert_eq!(&f, &figures(snap.ledger(), docs, on), "snapshot on {}", on);
for c in live.control_check(on).unwrap() {
prop_assert_eq!(c.difference, Decimal::ZERO);
prop_assert!(c.unattached.is_empty() && c.attention.is_empty(), "{:?}", c);
}
for d in docs {
if let Some(st) = live.doc_state(*d, on).unwrap() {
prop_assert!(!st.out_of_bounds(), "{d} on {on}: {st:?}");
}
}
}
replayed.verify().unwrap();
Ok(())
}
proptest! {
#![proptest_config(ProptestConfig::with_cases(32))]
/// B-38 INVARIANT 4 (and 1, 2, 6 through the store): a document's
/// state is a function of the journal and the day. Half the history,
/// then the books reopened and compared; the rest on the reopened
/// books, compared again.
#[test]
fn status_is_a_function_of_journal_and_date(
first in prop::collection::vec(arb_op(), 0..18),
then in prop::collection::vec(arb_op(), 0..18),
) {
let dir = test_dir();
let (mut s, k) = setup(&dir.0);
let mut docs = Vec::new();
run(&mut s, &k, &first, &mut docs)?;
let live = s.ledger().clone();
drop(s);
agree(&live, &dir.0, &docs)?;
let mut s = Store::open(&dir.0).unwrap();
run(&mut s, &k, &then, &mut docs)?;
let live = s.ledger().clone();
drop(s);
agree(&live, &dir.0, &docs)?;
}
}