check / check (push) Canceled after 0s
refuse hand entries (P4b S1, part A) Plan P4b (D-45), slice S1, the engine half: rules.md B-38. - ledger-core: every receivables or payables line carries its document (Ledger.line_docs: DocRef, Movement, DocTag), written by the document commands in the step that posts it, derived on replay from the existing document events (the role's account as it stood then; payments by the ordinal rule), copied onto a reversal's mirror. Ledger::stands_on; doc_state / invoice_state / bill_state (total, paid, outstanding, status Open/Partial/Paid/WrittenOff/Void, overdue days) as of any day; ar_aging / ap_aging read them, so reversed payments and voids are right. The control guard (add_line, add_line_fx, post_entry, register_recurring) refuses untagged lines on receivables/payables, exempting a reversal's mirror of a legacy line; replay never refuses. Bounds (kernel::running_bounds, bounds_kept) on payments and reversals; a document reversal never dated before its entry; deposit/payer accounts must be cash-like and hold no role. New API: receive_payment_with / pay_bill_with (PaymentDetails: instrument, reference), void_document / check_void_document, control_check, payments_recorded, standing_payments, replay_reverse_entry. Invoice.amount_paid/status remain only as derived caches for the MCP layer until part B. cashflow bill_splits reads payments from the tags. - ledger-store: journal feature document_standing (D-39 gated) for a payment accepted only because an earlier one was reversed; receive_payment_with / pay_bill_with (instrument journaled as entry.instrument_set, reference in the description), void_document; replay uses replay_reverse_entry; schema 15 drops the projection's amount_paid/status. Fixture p4-arap-before + golden figures written by the version before S1, adjudicated in arap_standing.rs. - ledger-gui: party card, cash "coming up" and company owed totals read doc_state as of today (late from the derived state). - Lean Documents.lean (control_agrees and friends); Kani bounds_check_matches_definition, bounds_kept_is_the_definition_on_books_in_bounds; rules B-38, B-18/B-23 amended; bookspec `older = true` for legacy hand lines; tests that hand-post to 1200/2000 post as replay or renumbered. Review fixes (both reviews of part A): - control_role_of guards only an account the role is assigned to or one holding its documents' lines (a chart's own 1200 Equipment or card at 2000 is free until a document posts there); payment accounts check other roles by holds_role (type-checked). The MCP tests that failed on the guard (oracle_mcp, spec_projects) pass unchanged. - standing_payments leaves out reversals (sorted, deduped); replayed tags match the movement's side; payments_recorded counts every payment event (tagged or not, Ledger.payment_events) and refuses overflow. - The record caches settled (paid + discounted + written off) and owed (Invoice/Bill.owed, never serialized): a void never reads as paid. - assign_role: receivables/payables refuse moving onto an account with untagged lines (RoleAccountHasHandLines) and, on their control account holding a balance, say DocumentRoleHoldsBalance (no hand entry named); B-23 reworded. The reversal-mirror exemption only takes a hand line off (even chain depth); putting it back is refused. - DocStatus Overpaid and Unattached; aging leaves out credit balances; ControlCheck.attention lists out-of-bounds and unattached documents. - stands_on counts a chain in order; indexes (doc_index, doc_accounts, order_pos, reversed_by) keep the derived figures linear. - Viewer reads a post-dated document as of its own date; one state per row. bounds_kept's side-crossing documented as intended. - Tests: ten new spec_documents scenarios, stronger proptests, store proptest arap_props (live = replay = snapshot at every date, closed months), fuzz ops void/payments with details, a GUI post-dated test; Lean Step.assign / Step.release with unattached_zero kept; model limits stated in docs/testing.md and B-38. Review fixes (CPA and engineering lenses): only an assigned role's account or one holding document lines is a control account (a chart's own 1200 asset stays free); a role can't move onto untagged lines or off document lines (Lean Step.assign); a void's cache never reads as paid; no Decimal sum panics; per-document indexes keep figures linear; a replayed tag matches its side; the feature gate counts payment events; a payment's reversal is never listed as standing; an overpaid document has its own status; the viewer keeps post-dated documents. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01T3gXMVt7XJPi6ens1or9Dj
303 lines
11 KiB
Rust
303 lines
11 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),
|
|
/// Close month `m` (January to March), in order.
|
|
Close(u8),
|
|
/// A hand line on receivables (true) or payables: always refused.
|
|
Hand(bool, 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)),
|
|
1 => (1u8..4).prop_map(Op::Close),
|
|
1 => (any::<bool>(), 1i64..10_000).prop_map(|(r, c)| Op::Hand(r, c)),
|
|
]
|
|
}
|
|
|
|
/// 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,
|
|
}]
|
|
};
|
|
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::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::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)?;
|
|
}
|
|
}
|