Files
wanderbooks/crates/ledger-core/tests/spec_documents.rs
T
swaits 13282cf80e feat(arap): terms, numbers and line accounts in the core (P4b S4.1)
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.
2026-10-27 00:02:51 -06:00

1925 lines
71 KiB
Rust

//! Documents read their figures from the ledger lines tagged with them
//! (rules.md B-38; plan P4b S1, D-45): one test per scenario of the
//! B-38 spec block, then the properties.
//!
//! ```text
//! GOAL: invoices and bills are the subledgers of the receivables and
//! payables control accounts; every figure about a document is read
//! from the ledger lines tagged with it.
//! SOURCE: docs/spec/rules.md B-8, B-9, B-10, B-18, B-23, B-28, B-38;
//! ASC 310-10, ASC 405-20-40-1, ASC 606-10-45-4.
//! ```
use chrono::NaiveDate;
use ledger_core::arap::{
BillFields, DocumentKind, DocumentLine, EarlyDiscount, InvoiceFields, Terms, TermsKind,
};
use ledger_core::{
AccountId, AccountType, Currency, DocRef, DocStatus, Ledger, LedgerError, Money, Movement,
PaymentDetails, Side,
};
use proptest::prelude::*;
use rust_decimal::Decimal;
fn d(y: i32, m: u32, day: u32) -> NaiveDate {
NaiveDate::from_ymd_opt(y, m, day).unwrap()
}
fn cents(c: i64) -> Decimal {
Decimal::new(c, 2)
}
fn usd(c: i64) -> Money {
Money::new(cents(c), Currency::USD)
}
fn line(what: &str, c: i64) -> Vec<DocumentLine> {
vec![DocumentLine {
description: what.into(),
amount: cents(c),
class_id: None,
account: None,
quantity: None,
unit_price: None,
}]
}
/// Bank 1000, a card 2050, capital 3000, revenue 4000 (made by the first
/// invoice), and a customer and a vendor.
struct Books {
l: Ledger,
bank: AccountId,
card: AccountId,
capital: AccountId,
customer: u64,
vendor: u64,
}
fn books() -> Books {
let mut l = Ledger::new(Currency::USD);
let bank = l
.add_account("1000", "Bank", AccountType::Asset, None, false)
.unwrap();
let card = l
.add_account("2050", "Card", AccountType::Liability, None, false)
.unwrap();
let capital = l
.add_account("3000", "Capital", AccountType::Equity, None, false)
.unwrap();
let customer = l.add_customer("Fernhill Labs").unwrap();
let vendor = l.add_vendor("Tobin Harlow Supply").unwrap();
Books {
l,
bank,
card,
capital,
customer,
vendor,
}
}
impl Books {
fn invoice(&mut self, on: NaiveDate, c: i64) -> u64 {
self.l
.create_invoice(
self.customer,
on,
on + chrono::Days::new(30),
line("Work", c),
)
.unwrap()
}
fn bill(&mut self, on: NaiveDate, c: i64) -> u64 {
self.l
.create_bill(
self.vendor,
on,
on + chrono::Days::new(30),
line("Paper", c),
)
.unwrap()
}
fn state(&self, doc: DocRef, on: NaiveDate) -> ledger_core::DocState {
self.l.doc_state(doc, on).unwrap().expect("issued by then")
}
fn account(&self, code: &str) -> AccountId {
self.l.chart().find_by_code(code).unwrap().id
}
/// Every control account's tie-out holds: no difference (B-38
/// INVARIANT 1).
fn ties(&self, on: NaiveDate) {
for c in self.l.control_check(on).unwrap() {
assert_eq!(c.difference, Decimal::ZERO, "{c:?}");
}
}
}
/// Scenario 1: invoice 100, payment 100, payment reversed: Open, owing
/// 100, A/R 100.
#[test]
fn reversing_a_payment_reopens_the_invoice() {
let mut b = books();
let inv = b.invoice(d(2026, 1, 10), 10_000);
let pay =
b.l.receive_payment(inv, d(2026, 1, 20), cents(10_000), b.bank)
.unwrap();
let doc = DocRef::Invoice(inv);
assert_eq!(b.state(doc, d(2026, 1, 21)).status, DocStatus::Paid);
b.l.reverse_entry(pay, d(2026, 1, 25), "Bounced").unwrap();
let st = b.state(doc, d(2026, 1, 31));
assert_eq!(st.status, DocStatus::Open);
assert_eq!((st.paid, st.outstanding), (Decimal::ZERO, cents(10_000)));
let ar = b.account("1200");
assert_eq!(b.l.account_balance(ar).unwrap(), cents(10_000));
// The record's figures follow: what is owed at the end of the books.
assert_eq!(b.l.get_invoice(inv).unwrap().balance(), Ok(cents(10_000)));
// The mirror carries the document: the payment and its reversal.
let lines = b.l.doc_lines(doc);
assert_eq!(lines.len(), 3);
assert!(
lines
.iter()
.filter(|l| l.reversal)
.all(|l| l.movement == Movement::Payment)
);
b.ties(d(2026, 1, 31));
}
/// Scenario 2: an invoice reversed with nothing paid is Void, not in the
/// aging, its id (later its number) kept.
#[test]
fn a_voided_invoice_never_ages() {
let mut b = books();
let inv = b.invoice(d(2026, 2, 1), 20_000);
let r =
b.l.void_document(DocRef::Invoice(inv), d(2026, 2, 5), "wrong customer")
.unwrap();
assert_eq!(
b.l.get_entry(r).unwrap().description(),
format!("Void invoice {inv}: wrong customer")
);
let st = b.state(DocRef::Invoice(inv), d(2026, 3, 31));
assert_eq!(
(st.status, st.outstanding),
(DocStatus::Void, Decimal::ZERO)
);
assert!(b.l.ar_aging(d(2026, 3, 31)).unwrap().is_empty());
assert!(b.l.get_invoice(inv).is_some(), "kept");
// Before the void it was open, and aged.
assert_eq!(
b.state(DocRef::Invoice(inv), d(2026, 2, 4)).status,
DocStatus::Open
);
assert_eq!(
b.l.ar_aging(d(2026, 2, 4)).unwrap()[0].current,
cents(20_000)
);
// Void already: refused, naming the reversal.
match b
.l
.void_document(DocRef::Invoice(inv), d(2026, 2, 6), "again")
{
Err(LedgerError::InvalidVoid(why)) => assert!(why.contains(&r.to_string()), "{why}"),
other => panic!("expected InvalidVoid, got {other:?}"),
}
// Reversing the void re-instates it (B-10); voiding again reverses
// that reversal, the end of the chain.
let back = b.l.reverse_entry(r, d(2026, 2, 7), "Re-instate").unwrap();
assert_eq!(
b.state(DocRef::Invoice(inv), d(2026, 2, 8)).status,
DocStatus::Open
);
let again =
b.l.void_document(DocRef::Invoice(inv), d(2026, 2, 9), "duplicate")
.unwrap();
assert_eq!(b.l.get_entry(again).unwrap().reversal_of(), Some(back));
assert_eq!(
b.state(DocRef::Invoice(inv), d(2026, 2, 9)).status,
DocStatus::Void
);
b.ties(d(2026, 2, 28));
}
/// Scenario 3: a void with a payment standing SHOULD refuse, naming the
/// payment; after the payment's reversal it is taken.
#[test]
fn void_refused_while_paid() {
let mut b = books();
let inv = b.invoice(d(2026, 3, 1), 10_000);
let pay =
b.l.receive_payment(inv, d(2026, 3, 5), cents(4_000), b.bank)
.unwrap();
let before = b.l.clone();
let origin = b.l.get_invoice(inv).unwrap().entry_id;
for refused in [
b.l.void_document(DocRef::Invoice(inv), d(2026, 3, 10), "duplicate"),
b.l.reverse_entry(origin, d(2026, 3, 10), "Void"),
] {
match refused {
Err(LedgerError::DocumentBounds(why)) => {
assert!(why.contains(&format!("entry {pay}")), "{why}")
}
other => panic!("expected DocumentBounds, got {other:?}"),
}
assert!(
b.l.differs_from(&before).is_empty(),
"a refusal changes nothing"
);
}
b.l.reverse_entry(pay, d(2026, 3, 9), "Refund").unwrap();
b.l.void_document(DocRef::Invoice(inv), d(2026, 3, 10), "duplicate")
.unwrap();
assert_eq!(
b.state(DocRef::Invoice(inv), d(2026, 3, 31)).status,
DocStatus::Void
);
b.ties(d(2026, 3, 31));
}
/// Scenario 4: as of a date between a payment and its reversal, it counts
/// as paid; aging as of then ignores the later reversal.
#[test]
fn aging_as_of_ignores_a_later_reversal() {
let mut b = books();
let inv = b.invoice(d(2026, 1, 10), 10_000);
let bill = b.bill(d(2026, 1, 15), 8_000);
let p =
b.l.receive_payment(inv, d(2026, 1, 20), cents(10_000), b.bank)
.unwrap();
let q =
b.l.pay_bill(bill, d(2026, 1, 20), cents(8_000), b.bank)
.unwrap();
b.l.reverse_entry(p, d(2026, 1, 25), "Bounced").unwrap();
b.l.reverse_entry(q, d(2026, 1, 28), "Paid in error")
.unwrap();
assert!(b.l.ar_aging(d(2026, 1, 22)).unwrap().is_empty());
assert!(b.l.ap_aging(d(2026, 1, 22)).unwrap().is_empty());
assert!(b.l.stands_on(p, d(2026, 1, 22)));
assert!(!b.l.stands_on(p, d(2026, 1, 25)));
assert!(!b.l.stands_on(p, d(2026, 1, 19)), "not before its date");
assert_eq!(
b.l.ar_aging(d(2026, 1, 31)).unwrap()[0].current,
cents(10_000)
);
assert_eq!(
b.l.ap_aging(d(2026, 1, 31)).unwrap()[0].current,
cents(8_000)
);
assert_eq!(
b.state(DocRef::Bill(bill), d(2026, 1, 27)).status,
DocStatus::Paid
);
assert_eq!(
b.state(DocRef::Bill(bill), d(2026, 1, 28)).status,
DocStatus::Open
);
// An overdue flag, not a status.
let st = b.state(DocRef::Invoice(inv), d(2026, 3, 1));
assert_eq!((st.status, st.overdue_days), (DocStatus::Open, 20));
}
/// Scenario 5: the wrong payment reversed, the right one is accepted;
/// a payment above what is owed on any later day is still refused.
#[test]
fn a_corrected_payment_after_reversing_the_wrong_one() {
let mut b = books();
let inv = b.invoice(d(2026, 1, 10), 10_000);
let wrong =
b.l.receive_payment(inv, d(2026, 1, 20), cents(10_000), b.bank)
.unwrap();
b.l.reverse_entry(wrong, d(2026, 1, 21), "Wrong invoice")
.unwrap();
assert_eq!(
b.l.payments_recorded(DocRef::Invoice(inv)),
Ok(cents(10_000))
);
b.l.receive_payment(inv, d(2026, 1, 22), cents(10_000), b.bank)
.unwrap();
let st = b.state(DocRef::Invoice(inv), d(2026, 1, 31));
assert_eq!((st.status, st.paid), (DocStatus::Paid, cents(10_000)));
assert_eq!(
b.l.payments_recorded(DocRef::Invoice(inv)),
Ok(cents(20_000))
);
assert!(matches!(
b.l.receive_payment(inv, d(2026, 1, 23), cents(1), b.bank),
Err(LedgerError::Overpayment(..))
));
// A payment dated before a later one is bounded by every day after
// it: dated on the 21st, only nothing more could be paid.
let mut c = books();
let inv = c.invoice(d(2026, 1, 10), 10_000);
c.l.receive_payment(inv, d(2026, 1, 25), cents(6_000), c.bank)
.unwrap();
assert_eq!(
c.l.receive_payment(inv, d(2026, 1, 21), cents(5_000), c.bank),
Err(LedgerError::Overpayment(cents(5_000), cents(4_000)))
);
c.l.receive_payment(inv, d(2026, 1, 21), cents(4_000), c.bank)
.unwrap();
}
/// Re-instating a payment (reversing its reversal) beside the payment that
/// replaced it would overpay: refused. Reversing a payment is always
/// taken.
#[test]
fn reinstating_a_payment_beside_its_replacement_is_refused() {
let mut b = books();
let inv = b.invoice(d(2026, 1, 10), 10_000);
let first =
b.l.receive_payment(inv, d(2026, 1, 20), cents(10_000), b.bank)
.unwrap();
let undo = b.l.reverse_entry(first, d(2026, 1, 21), "Bounced").unwrap();
b.l.receive_payment(inv, d(2026, 1, 22), cents(10_000), b.bank)
.unwrap();
let before = b.l.clone();
assert!(matches!(
b.l.reverse_entry(undo, d(2026, 1, 23), "It cleared after all"),
Err(LedgerError::DocumentBounds(_))
));
assert!(b.l.differs_from(&before).is_empty());
}
/// Scenario 6a: a hand line on receivables or payables SHOULD refuse with
/// the document tools named, when added and (a draft drawn up before the
/// role moved there) when posted; a recurring template naming one too.
#[test]
fn hand_line_on_control_refused() {
let mut b = books();
b.invoice(d(2026, 1, 10), 10_000);
b.bill(d(2026, 1, 10), 1_000);
let (ar, ap, revenue) = (b.account("1200"), b.account("2000"), b.account("4000"));
for (account, side) in [(ar, Side::Debit), (ap, Side::Credit)] {
let e = b.l.try_create_entry(d(2026, 1, 15), "by hand").unwrap();
match b.l.add_line(e, account, side, usd(2_500)) {
Err(LedgerError::UntaggedControlLine { account, at }) => {
assert!(account.starts_with("1200") || account.starts_with("2000"));
assert_eq!(at, format!("line 0 of entry {e}"));
}
other => panic!("expected UntaggedControlLine, got {other:?}"),
}
let err =
b.l.add_line_fx(
e,
account,
side,
Currency::new("EUR").unwrap(),
cents(100),
Decimal::ONE,
)
.unwrap_err();
assert!(matches!(err, LedgerError::UntaggedControlLine { .. }));
let text = LedgerError::UntaggedControlLine {
account: "1200".into(),
at: "x".into(),
}
.to_string();
for tool in [
"books.create_invoice",
"books.receive_payment",
"books.reverse_entry",
] {
assert!(text.contains(tool), "{text}");
}
}
// A draft drawn up while 1150 held no role, posted after receivables
// moved there (its balance zero): refused at the post.
let mut c = books();
let other =
c.l.add_account("1150", "Trade Receivables", AccountType::Asset, None, false)
.unwrap();
let e =
c.l.try_create_entry(d(2026, 1, 15), "drafted early")
.unwrap();
c.l.add_line(e, other, Side::Debit, usd(100)).unwrap();
c.l.add_line(e, c.capital, Side::Credit, usd(100)).unwrap();
c.l.assign_role("receivables", Some(other)).unwrap();
assert!(matches!(
c.l.post_entry(e),
Err(LedgerError::UntaggedControlLine { .. })
));
// A recurring template naming a control account.
let err =
b.l.register_recurring(ledger_core::NewRecurring {
description: "Retainer".into(),
lines: vec![
(ar, Side::Debit, usd(1_000)),
(revenue, Side::Credit, usd(1_000)),
],
frequency: ledger_core::Frequency::Monthly,
start: d(2026, 2, 1),
end: None,
})
.unwrap_err();
assert!(
matches!(err, LedgerError::UntaggedControlLine { .. }),
"{err:?}"
);
// An account at the default code but of another type fills no role:
// lines on it are anyone's.
let mut e2 = Ledger::new(Currency::USD);
let savings = e2
.add_account("2000", "Savings", AccountType::Asset, None, false)
.unwrap();
let cap = e2
.add_account("3000", "Capital", AccountType::Equity, None, false)
.unwrap();
let e = e2.try_create_entry(d(2026, 1, 1), "save").unwrap();
e2.add_line(e, savings, Side::Debit, usd(5)).unwrap();
e2.add_line(e, cap, Side::Credit, usd(5)).unwrap();
e2.post_entry(e).unwrap();
}
/// Scenario 6b: reversing a legacy hand line on receivables is accepted,
/// and what is unattached shrinks to nothing.
#[test]
fn reversing_a_legacy_hand_line_is_allowed() {
let mut b = books();
b.invoice(d(2026, 1, 10), 10_000);
let (ar, revenue) = (b.account("1200"), b.account("4000"));
// A hand line as an earlier version wrote it: replayed.
let hand = ledger_core::parties::replaying(|| {
let e = b.l.create_entry(d(2026, 1, 15), "Hand-booked sale");
b.l.add_line(e, ar, Side::Debit, usd(2_500)).unwrap();
b.l.add_line(e, revenue, Side::Credit, usd(2_500)).unwrap();
b.l.post_entry(e).unwrap();
e
});
let check = |l: &Ledger, on| {
l.control_check(on)
.unwrap()
.into_iter()
.find(|c| c.role == "receivables")
.unwrap()
};
let c = check(&b.l, d(2026, 1, 31));
assert_eq!(c.balance, cents(12_500));
assert_eq!(c.documents_total, cents(10_000));
assert_eq!(c.unattached.len(), 1);
assert_eq!(
(c.unattached[0].entry, c.unattached[0].amount),
(hand, cents(2_500))
);
assert_eq!(c.difference, Decimal::ZERO);
b.l.reverse_entry(hand, d(2026, 1, 20), "Re-enter as an invoice")
.unwrap();
let c = check(&b.l, d(2026, 1, 31));
assert!(c.unattached.is_empty(), "{c:?}");
assert_eq!(c.balance, cents(10_000));
// As of a day before the reversal, still there.
assert_eq!(check(&b.l, d(2026, 1, 16)).unattached.len(), 1);
}
/// Scenario 8: receive_payment's deposit account (pay_bill's payer) must
/// be a bank, a card or a payable that holds no role.
#[test]
fn deposit_account_must_be_cash_like() {
let mut b = books();
let inv = b.invoice(d(2026, 1, 10), 10_000);
let bill = b.bill(d(2026, 1, 10), 1_000);
let (ar, ap, revenue) = (b.account("1200"), b.account("2000"), b.account("4000"));
let before = b.l.clone();
for account in [ar, ap, revenue, b.capital] {
assert!(matches!(
b.l.receive_payment(inv, d(2026, 1, 20), cents(100), account),
Err(LedgerError::PaymentAccount { .. })
));
assert!(matches!(
b.l.pay_bill(bill, d(2026, 1, 20), cents(100), account),
Err(LedgerError::PaymentAccount { .. })
));
}
assert!(b.l.differs_from(&before).is_empty());
// A card is fine for a bill.
b.l.pay_bill(bill, d(2026, 1, 20), cents(1_000), b.card)
.unwrap();
}
/// A reversal of an entry a document owns is never dated before it
/// (B-9; P4b.3, "Dating rules").
#[test]
fn reversal_before_original_refused_for_documents() {
let mut b = books();
let inv = b.invoice(d(2026, 1, 10), 10_000);
let pay =
b.l.receive_payment(inv, d(2026, 1, 20), cents(100), b.bank)
.unwrap();
assert!(matches!(
b.l.reverse_entry(pay, d(2026, 1, 19), "early"),
Err(LedgerError::DocumentReversalBeforeOriginal { .. })
));
assert!(matches!(
b.l.void_document(DocRef::Invoice(inv), d(2026, 1, 9), "early"),
Err(LedgerError::DocumentReversalBeforeOriginal { .. })
));
// The same day is fine.
b.l.reverse_entry(pay, d(2026, 1, 20), "same day").unwrap();
}
/// A payment's method tags its entry in the same step (D-40); its
/// reference is in the description; a bad one is refused.
#[test]
fn a_payment_carries_its_method_and_reference() {
let mut b = books();
let bill = b.bill(d(2026, 1, 10), 1_000);
let card =
b.l.add_instrument(
ledger_core::InstrumentKind::BusinessCard,
"Visa",
Some("4242"),
None,
b.card,
)
.unwrap();
let details = PaymentDetails {
instrument: Some(card),
reference: Some("ch_3Nx".into()),
};
let e =
b.l.pay_bill_with(bill, d(2026, 1, 12), cents(1_000), b.card, &details)
.unwrap();
assert_eq!(b.l.entry_instrument(e), Some(card));
assert_eq!(
b.l.get_entry(e).unwrap().description(),
format!("Payment on bill {bill} (ref ch_3Nx)")
);
let inv = b.invoice(d(2026, 1, 10), 1_000);
let before = b.l.clone();
for bad in [
PaymentDetails {
instrument: Some(99),
reference: None,
},
PaymentDetails {
instrument: None,
reference: Some("two\nlines".into()),
},
PaymentDetails {
instrument: None,
reference: Some("x".repeat(201)),
},
] {
assert!(
b.l.receive_payment_with(inv, d(2026, 1, 12), cents(100), b.bank, &bad)
.is_err()
);
}
assert!(b.l.differs_from(&before).is_empty());
}
/// The status rules (P4b.3, "Status as of D") on their own.
#[test]
fn status_follows_the_figures() {
use ledger_core::arap::doc_status;
let (t, z) = (cents(10_000), Decimal::ZERO);
assert_eq!(doc_status(false, true, t, t, z), DocStatus::Void);
assert_eq!(doc_status(false, true, t, z, z), DocStatus::Void);
assert_eq!(doc_status(false, false, t, -t, z), DocStatus::Void);
assert_eq!(doc_status(true, false, t, z, z), DocStatus::Unattached);
assert_eq!(doc_status(true, true, t, -cents(1), z), DocStatus::Overpaid);
assert_eq!(
doc_status(true, true, t, z, cents(1)),
DocStatus::WrittenOff
);
assert_eq!(doc_status(true, true, t, z, z), DocStatus::Paid);
assert_eq!(doc_status(true, true, t, cents(1), z), DocStatus::Partial);
assert_eq!(doc_status(true, true, t, t, z), DocStatus::Open);
assert_eq!(doc_status(true, true, t, t + t, z), DocStatus::Open);
let mut keys = Vec::new();
for s in [
DocStatus::Open,
DocStatus::Partial,
DocStatus::Paid,
DocStatus::WrittenOff,
DocStatus::Void,
DocStatus::Overpaid,
DocStatus::Unattached,
] {
assert!(!s.key().is_empty() && !s.label().is_empty());
keys.push(s.key());
}
keys.sort_unstable();
keys.dedup();
assert_eq!(keys.len(), 7, "each status its own key");
}
/// A document dated after the day asked about isn't issued yet.
#[test]
fn a_document_after_the_day_has_no_state() {
let mut b = books();
let inv = b.invoice(d(2026, 5, 1), 100);
assert_eq!(b.l.invoice_state(inv, d(2026, 4, 30)), Ok(None));
assert!(b.l.invoice_state(inv, d(2026, 5, 1)).unwrap().is_some());
assert_eq!(
b.l.invoice_state(99, d(2026, 5, 1)),
Err(LedgerError::UnknownInvoice(99))
);
assert_eq!(
b.l.bill_state(99, d(2026, 5, 1)),
Err(LedgerError::UnknownBill(99))
);
}
/// Replay tags a document's control line on the role's account as it
/// stood then: an invoice journaled before receivables moved to 1150
/// keeps its 1200 line; a payment after, its 1150 line.
#[test]
fn role_reassigned_mid_stream_tags_the_role_then() {
let mut l = Ledger::new(Currency::USD);
let bank = l
.add_account("1000", "Bank", AccountType::Asset, None, false)
.unwrap();
let old = l
.add_account(
"1200",
"Accounts Receivable",
AccountType::Asset,
None,
false,
)
.unwrap();
let new = l
.add_account("1150", "Trade Receivables", AccountType::Asset, None, false)
.unwrap();
let revenue = l
.add_account("4000", "Revenue", AccountType::Revenue, None, false)
.unwrap();
let c = l.add_customer("Fernhill Labs").unwrap();
let entry = |l: &mut Ledger, dr, cr, n| {
let e = l.create_entry(d(2026, 1, 5), "replayed");
l.replay_add_line(e, dr, Side::Debit, usd(n)).unwrap();
l.replay_add_line(e, cr, Side::Credit, usd(n)).unwrap();
l.replay_post_entry(e).unwrap();
e
};
let e1 = entry(&mut l, old, revenue, 10_000);
l.replay_invoice(1, c, d(2026, 1, 5), d(2026, 2, 4), line("Work", 10_000), e1)
.unwrap();
// The role moves (as an earlier version could, with a hand entry
// moving the balance), then the payment.
let moved = entry(&mut l, new, old, 10_000);
ledger_core::parties::replaying(|| l.assign_role("receivables", Some(new))).unwrap();
let p = entry(&mut l, bank, new, 10_000);
l.replay_invoice_payment(1, cents(10_000), p).unwrap();
assert_eq!(l.line_doc(e1, 0).unwrap().doc, DocRef::Invoice(1));
assert_eq!(l.line_doc(p, 1).unwrap().movement, Movement::Payment);
assert_eq!(l.line_doc(moved, 0), None, "a hand line is no document's");
let st = l.invoice_state(1, d(2026, 1, 31)).unwrap().unwrap();
assert_eq!(
(st.status, st.outstanding),
(DocStatus::Paid, Decimal::ZERO)
);
for c in l.control_check(d(2026, 1, 31)).unwrap() {
assert_eq!(c.difference, Decimal::ZERO, "{c:?}");
}
}
/// Several payment events naming one entry tag its control lines in line
/// order (the ordinal rule, P4b.3), each at its own amount.
#[test]
fn several_payment_events_on_one_entry_tag_its_lines_in_order() {
let mut l = Ledger::new(Currency::USD);
let bank = l
.add_account("1000", "Bank", AccountType::Asset, None, false)
.unwrap();
let ar = l
.add_account(
"1200",
"Accounts Receivable",
AccountType::Asset,
None,
false,
)
.unwrap();
let revenue = l
.add_account("4000", "Revenue", AccountType::Revenue, None, false)
.unwrap();
let c = l.add_customer("Fernhill Labs").unwrap();
for id in [1, 2] {
let e = l.create_entry(d(2026, 1, 5), "replayed");
l.replay_add_line(e, ar, Side::Debit, usd(1_000 * id))
.unwrap();
l.replay_add_line(e, revenue, Side::Credit, usd(1_000 * id))
.unwrap();
l.replay_post_entry(e).unwrap();
l.replay_invoice(
id as u64,
c,
d(2026, 1, 5),
d(2026, 2, 4),
line("W", 1_000 * id),
e,
)
.unwrap();
}
let p = l.create_entry(d(2026, 1, 9), "one deposit, two invoices");
l.replay_add_line(p, bank, Side::Debit, usd(3_000)).unwrap();
l.replay_add_line(p, ar, Side::Credit, usd(1_000)).unwrap();
l.replay_add_line(p, ar, Side::Credit, usd(2_000)).unwrap();
l.replay_post_entry(p).unwrap();
l.replay_invoice_payment(1, cents(1_000), p).unwrap();
l.replay_invoice_payment(2, cents(2_000), p).unwrap();
assert_eq!(l.line_doc(p, 1).unwrap().doc, DocRef::Invoice(1));
assert_eq!(l.line_doc(p, 2).unwrap().doc, DocRef::Invoice(2));
for id in [1, 2] {
let st = l.invoice_state(id, d(2026, 1, 31)).unwrap().unwrap();
assert_eq!(st.status, DocStatus::Paid, "invoice {id}");
}
// Counted once each by the payments recorded.
assert_eq!(l.payments_recorded(DocRef::Invoice(2)), Ok(cents(2_000)));
}
/// A void waits on the payments that stand, and names exactly those: a
/// bounced payment's reversal stands too, but re-instating the bounced
/// payment is never the remedy (review of S1, finding 1).
#[test]
fn standing_payments_never_names_a_reversal() {
let mut b = books();
let inv = b.invoice(d(2026, 1, 10), 10_000);
let doc = DocRef::Invoice(inv);
let p =
b.l.receive_payment(inv, d(2026, 1, 12), cents(4_000), b.bank)
.unwrap();
let r = b.l.reverse_entry(p, d(2026, 1, 14), "Bounced").unwrap();
let q =
b.l.receive_payment(inv, d(2026, 1, 16), cents(4_000), b.bank)
.unwrap();
assert_eq!(b.l.standing_payments(doc), vec![q]);
match b.l.void_document(doc, d(2026, 1, 20), "duplicate") {
Err(LedgerError::DocumentBounds(why)) => {
assert!(why.contains(&format!("entry {q}")), "{why}");
assert!(!why.contains(&format!("entry {r}")), "{why}");
assert!(!why.contains(&format!("entry {p}")), "{why}");
}
other => panic!("expected DocumentBounds, got {other:?}"),
}
// Two payments in one day, and each named once.
let q2 =
b.l.receive_payment(inv, d(2026, 1, 16), cents(1_000), b.bank)
.unwrap();
assert_eq!(b.l.standing_payments(doc), vec![q, q2]);
}
/// Replay tags a payment's line on the side a payment posts: an older
/// payment deposited into receivables itself (Dr 1200 / Cr 1200, plan
/// P4b.2 gap 5) pays its invoice with the credit, and the debit is no
/// document's (review of S1, finding 2).
#[test]
fn a_payment_into_the_control_account_tags_its_credit() {
let mut l = Ledger::new(Currency::USD);
let ar = l
.add_account(
"1200",
"Accounts Receivable",
AccountType::Asset,
None,
false,
)
.unwrap();
let revenue = l
.add_account("4000", "Revenue", AccountType::Revenue, None, false)
.unwrap();
let c = l.add_customer("Fernhill Labs").unwrap();
let e = l.create_entry(d(2026, 1, 5), "Invoice 1");
l.replay_add_line(e, ar, Side::Debit, usd(10_000)).unwrap();
l.replay_add_line(e, revenue, Side::Credit, usd(10_000))
.unwrap();
l.replay_post_entry(e).unwrap();
l.replay_invoice(1, c, d(2026, 1, 5), d(2026, 2, 4), line("W", 10_000), e)
.unwrap();
let p = l.create_entry(d(2026, 1, 9), "Payment on invoice 1");
l.replay_add_line(p, ar, Side::Debit, usd(3_000)).unwrap();
l.replay_add_line(p, ar, Side::Credit, usd(3_000)).unwrap();
l.replay_post_entry(p).unwrap();
l.replay_invoice_payment(1, cents(3_000), p).unwrap();
assert_eq!(l.line_doc(p, 0), None, "the debit is no document's");
assert_eq!(l.line_doc(p, 1).unwrap().movement, Movement::Payment);
let st = l.invoice_state(1, d(2026, 1, 31)).unwrap().unwrap();
assert_eq!(
(st.paid, st.outstanding, st.status),
(cents(3_000), cents(7_000), DocStatus::Partial)
);
let c = &l.control_check(d(2026, 1, 31)).unwrap()[0];
assert_eq!(
(c.balance, c.documents_total, c.difference),
(cents(10_000), cents(7_000), Decimal::ZERO)
);
assert_eq!(c.unattached.len(), 1);
assert_eq!(c.unattached[0].amount, cents(3_000));
}
/// The record's cached figures (what the MCP reads until it reads
/// doc_state) never call a void paid: it settled nothing and owes
/// nothing (review of S1, finding 3).
#[test]
fn a_void_never_reads_as_paid_in_the_record() {
let mut b = books();
let inv = b.invoice(d(2026, 1, 10), 10_000);
b.l.void_document(DocRef::Invoice(inv), d(2026, 1, 11), "duplicate")
.unwrap();
let r = b.l.get_invoice(inv).unwrap();
assert_eq!(r.amount_paid, Decimal::ZERO);
assert_eq!(r.balance(), Ok(Decimal::ZERO));
assert_eq!(r.status, ledger_core::DocumentStatus::Open);
assert_eq!(
b.state(DocRef::Invoice(inv), d(2026, 1, 31)).status,
DocStatus::Void
);
// Paid in part, the rest owed: the record says so.
let bill = b.bill(d(2026, 1, 10), 1_000);
b.l.pay_bill(bill, d(2026, 1, 12), cents(400), b.bank)
.unwrap();
let r = b.l.get_bill(bill).unwrap();
assert_eq!(
(r.amount_paid, r.balance(), r.status),
(
cents(400),
Ok(cents(600)),
ledger_core::DocumentStatus::Partial
)
);
}
/// Receivables and payables move only to an account whose lines are all
/// documents', and never while their account holds a balance; the
/// refusal names no hand entry, which the guard refuses (review of S1:
/// first review's finding 4, second's finding 2). An account the role
/// moved off stays guarded while it holds documents' lines.
#[test]
fn moving_a_document_role_keeps_the_guard() {
let mut b = books();
let inv = b.invoice(d(2026, 1, 10), 10_000);
let (ar, revenue) = (b.account("1200"), b.account("4000"));
let fresh =
b.l.add_account("1150", "Trade Receivables", AccountType::Asset, None, false)
.unwrap();
let err = b.l.assign_role("receivables", Some(fresh)).unwrap_err();
assert!(
matches!(err, LedgerError::DocumentRoleHoldsBalance { .. }),
"{err:?}"
);
let text = err.to_string();
assert!(!text.contains("journal entry"), "{text}");
assert!(text.contains("hand entry"), "{text}");
// A hand line on the account it would move to.
let used =
b.l.add_account("1160", "Old Receivables", AccountType::Asset, None, false)
.unwrap();
let e = b.l.try_create_entry(d(2026, 1, 3), "by hand").unwrap();
b.l.add_line(e, used, Side::Debit, usd(500)).unwrap();
b.l.add_line(e, revenue, Side::Credit, usd(500)).unwrap();
b.l.post_entry(e).unwrap();
b.l.receive_payment(inv, d(2026, 1, 12), cents(10_000), b.bank)
.unwrap();
match b.l.assign_role("receivables", Some(used)) {
Err(LedgerError::RoleAccountHasHandLines { entry, code, .. }) => {
assert_eq!((entry, code.as_str()), (e, "1160"));
}
other => panic!("expected RoleAccountHasHandLines, got {other:?}"),
}
// Replay takes what an earlier version journaled.
let mut replayed = b.l.clone();
ledger_core::parties::replaying(|| replayed.assign_role("receivables", Some(used))).unwrap();
// Nothing owed now: the role moves to the fresh account, and 1200,
// holding the invoice's lines, stays guarded.
b.l.assign_role("receivables", Some(fresh)).unwrap();
assert!(b.l.control_role_of(ar).is_some());
let e = b.l.try_create_entry(d(2026, 1, 20), "by hand").unwrap();
assert!(matches!(
b.l.add_line(e, ar, Side::Debit, usd(100)),
Err(LedgerError::UntaggedControlLine { .. })
));
// A later reversal of the old payment lands on 1200, tagged, and
// both accounts tie.
let pay = b.l.standing_payments(DocRef::Invoice(inv))[0];
b.l.reverse_entry(pay, d(2026, 1, 21), "Bounced").unwrap();
b.ties(d(2026, 1, 31));
}
/// The guard locks only accounts whose balance is documents: a chart's
/// own 1200 Equipment, or a card at 2000, is anyone's while no document
/// posts there and the role is assigned elsewhere (second review of S1,
/// finding 1).
#[test]
fn a_chart_whose_1200_is_not_receivables() {
let mut l = Ledger::new(Currency::USD);
let equipment = l
.add_account("1200", "Equipment", AccountType::Asset, None, false)
.unwrap();
let card = l
.add_account("2000", "Visa", AccountType::Liability, None, false)
.unwrap();
let payables = l
.add_account(
"2010",
"Accounts Payable",
AccountType::Liability,
None,
false,
)
.unwrap();
l.add_account("3000", "Capital", AccountType::Equity, None, false)
.unwrap();
// Buying equipment by hand, on the card.
let e = l.try_create_entry(d(2026, 1, 3), "Laptop").unwrap();
l.add_line(e, equipment, Side::Debit, usd(150_000)).unwrap();
l.add_line(e, card, Side::Credit, usd(150_000)).unwrap();
l.post_entry(e).unwrap();
assert!(l.control_role_of(equipment).is_none());
assert!(l.control_role_of(card).is_none());
assert!(l.control_check(d(2026, 1, 31)).unwrap().is_empty());
// Bills post to the assigned payables; the card at 2000 pays one.
l.assign_role("payables", Some(payables)).unwrap();
let v = l.add_vendor("Tobin Harlow Supply").unwrap();
let bill = l
.create_bill(v, d(2026, 1, 5), d(2026, 2, 4), line("Paper", 2_000))
.unwrap();
// Payables' own account may not pay it.
assert!(matches!(
l.pay_bill(bill, d(2026, 1, 6), cents(1), payables),
Err(LedgerError::PaymentAccount { .. })
));
l.pay_bill(bill, d(2026, 1, 6), cents(2_000), card).unwrap();
assert_eq!(l.control_role_of(payables).map(|c| c.key), Some("payables"));
let checks = l.control_check(d(2026, 1, 31)).unwrap();
assert_eq!(checks.len(), 1);
assert_eq!(checks[0].account, payables);
assert_eq!(checks[0].difference, Decimal::ZERO);
}
/// Books an earlier version left out of bounds: a credit balance is
/// Overpaid (or Void, for a void under a payment that stands), never
/// aged as a negative receivable, and listed by the control check; a
/// document whose own line couldn't be tagged is Unattached, never Paid
/// (first review of S1, finding 6; second review's finding 9).
#[test]
fn out_of_bounds_documents_are_listed_not_aged() {
let mut l = Ledger::new(Currency::USD);
let bank = l
.add_account("1000", "Bank", AccountType::Asset, None, false)
.unwrap();
let ar = l
.add_account(
"1200",
"Accounts Receivable",
AccountType::Asset,
None,
false,
)
.unwrap();
let revenue = l
.add_account("4000", "Revenue", AccountType::Revenue, None, false)
.unwrap();
let c = l.add_customer("Fernhill Labs").unwrap();
let post = |l: &mut Ledger, on, lines: &[(AccountId, Side, i64)]| {
let e = l.create_entry(on, "replayed");
for &(a, side, n) in lines {
l.replay_add_line(e, a, side, usd(n)).unwrap();
}
l.replay_post_entry(e).unwrap();
e
};
ledger_core::parties::replaying(|| {
// Invoice 1, paid twice over by two payment events.
let e = post(
&mut l,
d(2026, 1, 5),
&[(ar, Side::Debit, 10_000), (revenue, Side::Credit, 10_000)],
);
l.replay_invoice(1, c, d(2026, 1, 5), d(2026, 2, 4), line("W", 10_000), e)
.unwrap();
for _ in 0..2 {
let p = post(
&mut l,
d(2026, 1, 9),
&[(bank, Side::Debit, 6_000), (ar, Side::Credit, 6_000)],
);
l.replay_invoice_payment(1, cents(6_000), p).unwrap();
}
// Invoice 2, paid, then voided under the payment.
let e2 = post(
&mut l,
d(2026, 1, 5),
&[(ar, Side::Debit, 5_000), (revenue, Side::Credit, 5_000)],
);
l.replay_invoice(2, c, d(2026, 1, 5), d(2026, 2, 4), line("W", 5_000), e2)
.unwrap();
let p = post(
&mut l,
d(2026, 1, 9),
&[(bank, Side::Debit, 5_000), (ar, Side::Credit, 5_000)],
);
l.replay_invoice_payment(2, cents(5_000), p).unwrap();
l.replay_reverse_entry(e2, d(2026, 1, 10), "Void").unwrap();
// Invoice 3, whose entry posted to revenue only (no line to tag).
let e3 = post(
&mut l,
d(2026, 1, 5),
&[(bank, Side::Debit, 700), (revenue, Side::Credit, 700)],
);
l.replay_invoice(3, c, d(2026, 1, 5), d(2026, 2, 4), line("W", 700), e3)
.unwrap();
// Invoice 4, owing 30.00, to show aging still counts the owed.
let e4 = post(
&mut l,
d(2026, 1, 5),
&[(ar, Side::Debit, 3_000), (revenue, Side::Credit, 3_000)],
);
l.replay_invoice(4, c, d(2026, 1, 5), d(2026, 2, 4), line("W", 3_000), e4)
.unwrap();
});
let on = d(2026, 1, 31);
let st = |id| l.invoice_state(id, on).unwrap().unwrap();
assert_eq!(
(st(1).status, st(1).outstanding),
(DocStatus::Overpaid, -cents(2_000))
);
assert!(st(1).out_of_bounds());
assert_eq!(
(st(2).status, st(2).outstanding),
(DocStatus::Void, -cents(5_000))
);
assert_eq!(st(3).status, DocStatus::Unattached);
assert!(!st(4).out_of_bounds());
let aged: Vec<_> = l.ar_aging(on).unwrap();
assert_eq!(aged.len(), 1);
assert_eq!(aged[0].total(), Ok(cents(3_000)), "{aged:?}");
let check = &l.control_check(on).unwrap()[0];
assert_eq!(check.difference, Decimal::ZERO);
let listed: Vec<_> = check
.attention
.iter()
.map(|a| (a.doc, a.status, a.outstanding))
.collect();
assert_eq!(
listed,
[
(DocRef::Invoice(1), DocStatus::Overpaid, -cents(2_000)),
(DocRef::Invoice(2), DocStatus::Void, -cents(5_000)),
(DocRef::Invoice(3), DocStatus::Unattached, Decimal::ZERO),
]
);
}
/// Reversing a legacy hand line takes it off; reversing that reversal
/// would put it back, and is refused: no command adds an untagged control
/// line (B-38 INVARIANT 1 and item 3; first review of S1).
#[test]
fn putting_back_a_removed_hand_line_is_refused() {
let mut b = books();
b.invoice(d(2026, 1, 10), 10_000);
let (ar, revenue) = (b.account("1200"), b.account("4000"));
let hand = ledger_core::parties::replaying(|| {
let e = b.l.create_entry(d(2026, 1, 15), "Hand-booked sale");
b.l.add_line(e, ar, Side::Debit, usd(2_500)).unwrap();
b.l.add_line(e, revenue, Side::Credit, usd(2_500)).unwrap();
b.l.post_entry(e).unwrap();
e
});
let off = b.l.reverse_entry(hand, d(2026, 1, 20), "Re-enter").unwrap();
let before = b.l.clone();
assert!(matches!(
b.l.reverse_entry(off, d(2026, 1, 21), "Put it back"),
Err(LedgerError::UntaggedControlLine { .. })
));
assert!(b.l.differs_from(&before).is_empty());
// An earlier version's re-instatement replays, and a later reversal
// of it takes the line off again.
let back =
b.l.replay_reverse_entry(off, d(2026, 1, 21), "Put back")
.unwrap();
b.l.reverse_entry(back, d(2026, 1, 22), "Off again")
.unwrap();
let c = &b.l.control_check(d(2026, 1, 31)).unwrap()[0];
assert!(c.unattached.is_empty(), "{c:?}");
}
/// Plan P4b.3 ("Dating rules"): an invoice from a closed period is voided
/// in an open one. As of the closed period's end it is still Open; the
/// reversal's revenue lands in the open period, the closed one's figures
/// untouched (ASC 250: an immaterial error corrected when found).
#[test]
fn a_void_of_a_closed_periods_invoice_lands_in_the_open_period() {
use ledger_core::reports::profit_and_loss_for_period;
let mut b = books();
let inv = b.invoice(d(2026, 1, 10), 10_000);
let jan =
b.l.create_period("2026-01", d(2026, 1, 1), d(2026, 1, 31))
.unwrap();
b.l.close_period(jan).unwrap();
let doc = DocRef::Invoice(inv);
assert!(matches!(
b.l.void_document(doc, d(2026, 1, 20), "duplicate"),
Err(LedgerError::ClosedPeriod(..))
));
b.l.void_document(doc, d(2026, 2, 3), "duplicate").unwrap();
assert_eq!(b.state(doc, d(2026, 1, 31)).status, DocStatus::Open);
assert_eq!(b.state(doc, d(2026, 2, 3)).status, DocStatus::Void);
let revenue = |from, to| {
profit_and_loss_for_period(&b.l, from, to, None)
.unwrap()
.total_revenue
};
assert_eq!(revenue(d(2026, 1, 1), d(2026, 1, 31)), cents(10_000));
assert_eq!(revenue(d(2026, 2, 1), d(2026, 2, 28)), -cents(10_000));
let aged = b.l.ar_aging(d(2026, 1, 31)).unwrap();
assert_eq!(aged[0].total(), Ok(cents(10_000)));
assert!(b.l.ar_aging(d(2026, 2, 28)).unwrap().is_empty());
b.ties(d(2026, 1, 31));
b.ties(d(2026, 2, 28));
}
/// The payments an older version counts are every payment event, its
/// line tagged or not, so the document_standing gate counts what that
/// version would (second review of S1, finding 7); past exact arithmetic
/// they are refused, never a panic (finding 4).
#[test]
fn payments_recorded_counts_every_event() {
let mut l = Ledger::new(Currency::USD);
let bank = l
.add_account("1000", "Bank", AccountType::Asset, None, false)
.unwrap();
let ar = l
.add_account(
"1200",
"Accounts Receivable",
AccountType::Asset,
None,
false,
)
.unwrap();
let revenue = l
.add_account("4000", "Revenue", AccountType::Revenue, None, false)
.unwrap();
let c = l.add_customer("Fernhill Labs").unwrap();
let e = l.create_entry(d(2026, 1, 5), "Invoice 1");
l.replay_add_line(e, ar, Side::Debit, usd(10_000)).unwrap();
l.replay_add_line(e, revenue, Side::Credit, usd(10_000))
.unwrap();
l.replay_post_entry(e).unwrap();
l.replay_invoice(1, c, d(2026, 1, 5), d(2026, 2, 4), line("W", 10_000), e)
.unwrap();
// A payment event whose amount matches no line: untagged, yet an
// older version adds it.
let p = l.create_entry(d(2026, 1, 9), "Payment");
l.replay_add_line(p, bank, Side::Debit, usd(4_000)).unwrap();
l.replay_add_line(p, ar, Side::Credit, usd(4_000)).unwrap();
l.replay_post_entry(p).unwrap();
l.replay_invoice_payment(1, cents(3_000), p).unwrap();
assert_eq!(l.line_doc(p, 1), None);
assert_eq!(l.payments_recorded(DocRef::Invoice(1)), Ok(cents(3_000)));
let huge = Decimal::MAX / Decimal::TWO + Decimal::ONE;
l.replay_invoice_payment(1, huge, p).unwrap();
l.replay_invoice_payment(1, huge, p).unwrap();
assert!(matches!(
l.payments_recorded(DocRef::Invoice(1)),
Err(LedgerError::TooLargeToCompute(_))
));
}
/// stands_on counts a reversal chain in order: an older journal's
/// re-reversal dated before the reversal it undoes counts only from that
/// reversal's date (second review of S1, finding 9).
#[test]
fn a_chain_counts_in_order() {
let mut b = books();
let e = b.l.try_create_entry(d(2026, 1, 1), "Capital in").unwrap();
b.l.add_line(e, b.bank, Side::Debit, usd(100)).unwrap();
b.l.add_line(e, b.capital, Side::Credit, usd(100)).unwrap();
b.l.post_entry(e).unwrap();
let r = b.l.reverse_entry(e, d(2026, 1, 20), "undo").unwrap();
let rr = b.l.replay_reverse_entry(r, d(2026, 1, 10), "redo").unwrap();
assert!(b.l.stands_on(e, d(2026, 1, 15)), "nothing reversed it yet");
assert!(!b.l.stands_on(r, d(2026, 1, 15)));
assert!(b.l.stands_on(e, d(2026, 1, 25)));
assert!(!b.l.stands_on(r, d(2026, 1, 25)));
assert!(b.l.stands_on(rr, d(2026, 1, 25)));
}
// ---------------------------------------------------------------------------
// Properties
// ---------------------------------------------------------------------------
/// 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`.
Pay(usize, u8, i64),
/// Reverse entry `k` (mod the posted entries) on day `n`.
Reverse(usize, u8),
/// Void document `k` on day `n`.
Void(usize, 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..60, 1i64..50_000).prop_map(|(i, n, c)| Op::Create(i, n, c)),
4 => (0usize..8, 0u8..90, 1i64..60_000).prop_map(|(k, n, c)| Op::Pay(k, n, c)),
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 => (any::<bool>(), 1i64..10_000).prop_map(|(r, c)| Op::Hand(r, c)),
]
}
/// Run `ops`, checking after every step that a refusal changed nothing;
/// returns the books and the documents made.
fn run(ops: &[Op]) -> (Books, Vec<DocRef>) {
let mut b = books();
let mut docs: Vec<DocRef> = Vec::new();
let day = |n: u8| d(2026, 1, 1) + chrono::Days::new(u64::from(n));
for op in ops {
let before = b.l.clone();
let result: Result<(), LedgerError> = match *op {
Op::Create(true, n, c) => {
b.l.create_invoice(b.customer, day(n), day(n), line("w", c))
.map(|i| docs.push(DocRef::Invoice(i)))
}
Op::Create(false, n, c) => {
b.l.create_bill(b.vendor, day(n), day(n), line("w", c))
.map(|i| docs.push(DocRef::Bill(i)))
}
Op::Pay(_, _, _) if docs.is_empty() => Ok(()),
Op::Pay(k, n, c) => match docs[k % docs.len()] {
DocRef::Invoice(i) => b.l.receive_payment(i, day(n), cents(c), b.bank).map(|_| ()),
DocRef::Bill(i) => b.l.pay_bill(i, day(n), cents(c), b.bank).map(|_| ()),
},
Op::Reverse(k, n) => {
let posted: Vec<u64> = b.l.posted_entries().map(|e| e.id()).collect();
match posted.get(k % posted.len().max(1)) {
Some(&e) => b.l.reverse_entry(e, day(n), "undo").map(|_| ()),
None => Ok(()),
}
}
Op::Void(_, _) if docs.is_empty() => Ok(()),
Op::Void(k, n) => {
b.l.void_document(docs[k % docs.len()], day(n), "void")
.map(|_| ())
}
Op::Hand(receivables, c) => {
let code = if receivables { "1200" } else { "2000" };
match b.l.chart().find_by_code(code).map(|a| a.id) {
Some(a) => {
let e = b.l.try_create_entry(day(0), "hand").unwrap();
let r = b.l.add_line(e, a, Side::Debit, usd(c));
assert!(
matches!(r, Err(LedgerError::UntaggedControlLine { .. })),
"{r:?}"
);
b.l = before.clone();
Err(LedgerError::EmptyDocumentLines)
}
None => Ok(()),
}
}
};
if result.is_err() {
assert!(
b.l.differs_from(&before).is_empty(),
"{op:?} refused, yet changed the books"
);
}
}
(b, docs)
}
/// Every day a figure could change on, and the end.
fn days(b: &Books) -> Vec<NaiveDate> {
let mut v: Vec<NaiveDate> = b.l.posted_entries().map(|e| e.date()).collect();
v.push(d(2026, 12, 31));
v.sort();
v.dedup();
v
}
proptest! {
#![proptest_config(ProptestConfig::with_cases(256))]
/// B-38 INVARIANTS 1, 2 and 6 over random histories of creates,
/// payments, voids, reversals of any entry and refused hand lines: at
/// every date the control accounts equal their documents (no
/// difference, nothing unattached: no command adds a hand line), each
/// document owes between nothing and its total, its own status agrees
/// with its figures, and every refusal left the books unchanged.
#[test]
fn subledger_equals_control(ops in prop::collection::vec(arb_op(), 0..40)) {
let (b, docs) = run(&ops);
for on in days(&b) {
let checks = b.l.control_check(on).unwrap();
for c in &checks {
prop_assert_eq!(c.difference, Decimal::ZERO);
prop_assert!(c.unattached.is_empty(), "{:?}", c);
}
for doc in &docs {
let Some(st) = b.l.doc_state(*doc, on).unwrap() else { continue };
prop_assert!(st.outstanding >= Decimal::ZERO && st.outstanding <= st.total,
"{doc} on {on}: {st:?}");
prop_assert_eq!(st.outstanding, st.total * Decimal::from(
u8::from(st.status != DocStatus::Void)) - st.paid);
}
// The control accounts' balances are the documents' sums.
let owed = |inv: bool| -> Decimal {
docs.iter()
.filter(|d| matches!(d, DocRef::Invoice(_)) == inv)
.filter_map(|d| b.l.doc_state(*d, on).unwrap())
.map(|s| s.outstanding)
.sum()
};
for c in &checks {
prop_assert_eq!(c.balance, owed(c.role == "receivables"));
}
}
}
/// B-38 INVARIANT 5: the aging as of a day partitions what the
/// documents owe then (in books these commands write, none owes less
/// than nothing). INVARIANT 4 (Store, replay and Snapshot agree) is
/// ledger-store's `status_is_a_function_of_journal_and_date`.
#[test]
fn aging_partitions_what_documents_owe(ops in prop::collection::vec(arb_op(), 0..30)) {
let (a, docs) = run(&ops);
for on in days(&a) {
let (mut ar, mut ap) = (Decimal::ZERO, Decimal::ZERO);
for doc in &docs {
let Some(st) = a.l.doc_state(*doc, on).unwrap() else { continue };
match doc {
DocRef::Invoice(_) => ar += st.outstanding,
DocRef::Bill(_) => ap += st.outstanding,
}
}
let aged = |rows: Vec<ledger_core::AgingRow>| -> Decimal {
rows.iter().map(|r| r.total().unwrap()).sum()
};
prop_assert_eq!(aged(a.l.ar_aging(on).unwrap()), ar);
prop_assert_eq!(aged(a.l.ap_aging(on).unwrap()), ap);
}
}
/// B-38 INVARIANT 3: reversing a tagged entry moves its documents by
/// exactly the opposite of the entry's own tagged lines from the
/// reversal's day (read from the entry reversed, not from the mirror,
/// so a mirror that lost its tag is caught); reversing that reversal
/// restores them. Reversing a payment that stands is always accepted
/// (P4b.3, Bounds), so the property is never vacuous on payments.
#[test]
fn reversal_twice_restores_state(ops in prop::collection::vec(arb_op(), 1..30), k in 0usize..40) {
let (mut b, docs) = run(&ops);
let posted: Vec<u64> = b.l.posted_entries().map(|e| e.id()).collect();
prop_assume!(!posted.is_empty());
let e = posted[k % posted.len()];
let (earlier, later) = (d(2026, 10, 31), d(2026, 11, 1));
let state = |b: &Books, on| -> Vec<_> {
docs.iter().map(|doc| b.l.doc_state(*doc, on).unwrap()).collect()
};
let (before_earlier, before) = (state(&b, earlier), state(&b, later));
let original: Vec<Decimal> = docs
.iter()
.map(|doc| b.l.doc_lines(*doc).iter().filter(|l| l.entry == e).map(|l| l.amount).sum())
.collect();
let unreversed = b.l.reversal_chain(e).last() == Some(&e);
let a_payment = b.l.get_entry(e).is_some_and(|x| x.reversal_of().is_none())
&& docs.iter().any(|doc| b.l.doc_lines(*doc).iter()
.any(|l| l.entry == e && l.movement == Movement::Payment));
let reversed = b.l.reverse_entry(e, later, "undo");
if a_payment && unreversed {
prop_assert!(reversed.is_ok(), "reversing payment {e}: {reversed:?}");
}
let Ok(r) = reversed else { return Ok(()) };
let moved = state(&b, later);
// Nothing moves before the reversal's day.
prop_assert_eq!(state(&b, earlier), before_earlier);
for i in 0..docs.len() {
let (Some(x), Some(y)) = (before[i], moved[i]) else { continue };
prop_assert_eq!(y.outstanding - x.outstanding, -original[i]);
}
if b.l.reverse_entry(r, d(2026, 11, 2), "redo").is_ok() {
for (i, doc) in docs.iter().enumerate() {
let now = b.l.doc_state(*doc, d(2026, 11, 2)).unwrap();
prop_assert_eq!(now.map(|s| (s.outstanding, s.status)), before[i].map(|s| (s.outstanding, s.status)));
}
}
}
}
// ---------------------------------------------------------------------------
// S4.1: terms, numbers, line accounts, quantity and price
// ---------------------------------------------------------------------------
/// A document line naming its account and, when `qty` and `price` are
/// given, its quantity and unit price (plan P4b S4.1).
fn doc_line(
what: &str,
c: i64,
account: Option<AccountId>,
qty: Option<i64>,
price: Option<i64>,
) -> DocumentLine {
let quantity = qty.map(Decimal::from);
let unit_price = price.map(cents);
let amount = match (quantity, unit_price) {
(Some(q), Some(p)) => q * p,
_ => cents(c),
};
DocumentLine {
description: what.into(),
amount,
class_id: None,
account,
quantity,
unit_price,
}
}
/// B-39 SCENARIOS: Net 30, Net 15 EOM, a leap day, due on receipt, a
/// custom date, and the 9999-12 bound refused instead of panicking.
#[test]
fn terms_derive_due_dates() {
let net30 = Terms::new(TermsKind::Net(30), None).unwrap();
assert_eq!(net30.due_date(d(2026, 1, 31)).unwrap(), d(2026, 3, 2));
assert_eq!(net30.describe(), "Net 30");
let eom15 = Terms::new(TermsKind::NetEom(15), None).unwrap();
assert_eq!(eom15.due_date(d(2026, 10, 23)).unwrap(), d(2026, 11, 15));
assert_eq!(eom15.describe(), "Net 15 EOM");
let net28 = Terms::new(TermsKind::Net(28), None).unwrap();
assert_eq!(net28.due_date(d(2028, 2, 1)).unwrap(), d(2028, 2, 29));
let net0 = Terms::new(TermsKind::Net(0), None).unwrap();
assert_eq!(net0.due_date(d(2026, 5, 4)).unwrap(), d(2026, 5, 4));
let on_receipt = Terms::new(TermsKind::DueOnReceipt, None).unwrap();
assert_eq!(on_receipt.due_date(d(2026, 7, 4)).unwrap(), d(2026, 7, 4));
let custom = Terms::custom(d(2026, 11, 15)).unwrap();
assert_eq!(custom.describe(), "Due 2026-11-15");
assert_eq!(custom.due_date(d(2026, 11, 15)).unwrap(), d(2026, 11, 15));
// The calendar's end: refused, not a panic.
let last_day = Terms::new(TermsKind::Net(1), None).unwrap();
assert!(last_day.due_date(d(9999, 12, 31)).is_err());
let last_month = Terms::new(TermsKind::NetEom(1), None).unwrap();
assert!(last_month.due_date(d(9999, 12, 1)).is_err());
// Ten years is the most a term may run.
assert!(Terms::new(TermsKind::Net(3_651), None).is_err());
assert!(Terms::new(TermsKind::NetEom(3_651), None).is_err());
}
/// B-39 INVARIANT 2: the discount's bound, its date, and the terms that
/// cannot carry one.
#[test]
fn discount_bounds_and_late_reason() {
let discount = EarlyDiscount {
percent_bp: 200,
days: 10,
};
let net30 = Terms::new(TermsKind::Net(30), Some(discount)).unwrap();
assert_eq!(net30.describe(), "2/10 net 30");
assert_eq!(net30.discount_by(d(2026, 1, 10)), Some(d(2026, 1, 20)));
// The discount window closes after its last day: the discount is the
// bound, and a payment after it has no discount to take without a
// reason (the payment path's check, S6).
assert!(net30.discount_by(d(2026, 1, 10)).unwrap() < net30.due_date(d(2026, 1, 10)).unwrap());
// Due on receipt and EOM terms have no net days to discount before.
assert_eq!(
Terms::new(TermsKind::DueOnReceipt, Some(discount)),
Err(LedgerError::TermsDiscountNeedsNetDays)
);
assert_eq!(
Terms::new(TermsKind::NetEom(30), Some(discount)),
Err(LedgerError::TermsDiscountNeedsNetDays)
);
// The discount must close before the net days run out.
assert_eq!(
Terms::new(
TermsKind::Net(10),
Some(EarlyDiscount {
percent_bp: 200,
days: 10
})
),
Err(LedgerError::TermsDiscountNeedsNetDays)
);
// A percentage outside 0.01%..99.99% is not a discount.
assert!(
Terms::new(
TermsKind::Net(30),
Some(EarlyDiscount {
percent_bp: 0,
days: 10
})
)
.is_err()
);
assert!(
Terms::new(
TermsKind::Net(30),
Some(EarlyDiscount {
percent_bp: 10_000,
days: 10
})
)
.is_err()
);
// The kernel bounds the exact cents: 2% of 100.00 is 2.00, and 2% of
// 100.01 is 2.0002, never a rounded 2.00.
use ledger_core::kernel;
assert_eq!(kernel::discount_max(cents(10_000), 200), Some(cents(200)));
assert_eq!(
kernel::discount_max(cents(10_001), 200),
Some(Decimal::new(20_002, 4))
);
assert_eq!(kernel::discount_max(cents(10_000), 0), None);
assert_eq!(kernel::discount_max(cents(-1), 200), None);
}
/// B-39 INVARIANT 3: the canonical spellings read back as they print.
#[test]
fn terms_describe_and_parse_round_trip() {
for text in [
"Net 30",
"Net 15 EOM",
"2/10 net 30",
"Due on receipt",
"Due 2026-11-15",
] {
let terms = Terms::parse(text).unwrap();
assert_eq!(terms.describe(), text, "parsing {text}");
assert_eq!(Terms::parse(&terms.describe()).unwrap(), terms);
}
// Case does not matter; the text printed is the canonical spelling.
assert_eq!(Terms::parse("net 30").unwrap().describe(), "Net 30");
assert_eq!(
Terms::parse("2/10 NET 30").unwrap().describe(),
"2/10 net 30"
);
assert_eq!(
Terms::parse("2.5/10 net 30").unwrap().describe(),
"2.5/10 net 30"
);
// Anything else is refused, never guessed.
for bad in [
"net thirty",
"net 30 days",
"2/10 net 5",
"Due 2026-13-01",
"2/10",
"",
] {
assert!(
matches!(Terms::parse(bad), Err(LedgerError::TermsUnreadable(_))),
"{bad:?} was read as {:?}",
Terms::parse(bad)
);
}
}
/// A bill's lines post to the accounts they name, one line per (account,
/// project) part (plan P4b S4.1, 8.3.6 "Record bill").
#[test]
fn bill_lines_post_to_their_accounts() {
let mut b = books();
let equipment =
b.l.add_account("1500", "Equipment", AccountType::Asset, None, false)
.unwrap();
let supplies =
b.l.add_account("6100", "Supplies", AccountType::Expense, None, false)
.unwrap();
let bill =
b.l.create_bill(
b.vendor,
d(2026, 3, 1),
d(2026, 3, 31),
vec![
doc_line("Chair", 30_000, Some(equipment), None, None),
doc_line("Paper", 5_000, Some(supplies), None, None),
doc_line("Ink", 1_000, Some(supplies), None, None),
],
)
.unwrap();
assert_eq!(b.account("1500"), equipment);
assert_eq!(b.l.account_balance(equipment).unwrap(), cents(30_000));
assert_eq!(b.l.account_balance(supplies).unwrap(), cents(6_000));
let ap = b.account("2000");
assert_eq!(b.l.account_balance(ap).unwrap(), cents(36_000));
// The legacy default account takes nothing when every line names one.
assert_eq!(
b.l.account_balance(b.account("6999")).unwrap(),
Decimal::ZERO
);
// Three lines: one per (account, project) part (equipment, supplies
// for Paper and Ink together) and one payable line.
assert_eq!(
b.l.get_entry(b.l.get_bill(bill).unwrap().entry_id)
.unwrap()
.lines()
.len(),
3
);
b.ties(d(2026, 3, 31));
}
/// An invoice line billed ahead of the work goes to 2400 Deferred Revenue,
/// a contract liability (ASC 606-10-45-2; A-10's invoice rule).
#[test]
fn invoice_line_to_deferred_revenue_posts_to_2400() {
let mut b = books();
let deferred =
b.l.add_account(
"2400",
"Deferred Revenue",
AccountType::Liability,
None,
false,
)
.unwrap();
let inv =
b.l.create_invoice(
b.customer,
d(2026, 4, 1),
d(2026, 5, 1),
vec![doc_line("Retainer", 50_000, Some(deferred), None, None)],
)
.unwrap();
assert_eq!(b.l.account_balance(deferred).unwrap(), cents(50_000));
let revenue = b.account("4000");
assert_eq!(b.l.account_balance(revenue).unwrap(), Decimal::ZERO);
assert_eq!(b.l.get_invoice(inv).unwrap().total(), Ok(cents(50_000)));
b.ties(d(2026, 5, 1));
}
/// A line's amount is exactly its quantity times its unit price; the
/// engine never rounds one (plan P4b S4.1; kernel::line_amount).
#[test]
fn qty_times_price_must_equal_amount() {
let mut b = books();
let inv =
b.l.create_invoice(
b.customer,
d(2026, 5, 1),
d(2026, 5, 31),
vec![doc_line("Hours", 0, None, Some(3), Some(3_333))],
)
.unwrap();
assert_eq!(b.l.get_invoice(inv).unwrap().total(), Ok(cents(9_999)));
// 3 x 33.33 is 99.99, not 100.00: refused, and nothing is posted.
let bad = vec![DocumentLine {
description: "Hours".into(),
amount: cents(10_000),
class_id: None,
account: None,
quantity: Some(Decimal::from(3)),
unit_price: Some(cents(3_333)),
}];
let ar = b.account("1200");
let before = b.l.account_balance(ar).unwrap();
assert!(matches!(
b.l.create_invoice(b.customer, d(2026, 6, 1), d(2026, 6, 30), bad.clone()),
Err(LedgerError::LineArithmetic(_))
));
assert_eq!(b.l.account_balance(ar).unwrap(), before);
assert_eq!(b.l.next_invoice_id(), inv + 1);
// Both or neither: a quantity without a price is refused too.
let mut half = bad.clone();
half[0].amount = cents(9_999);
half[0].unit_price = None;
assert!(matches!(
b.l.create_invoice(b.customer, d(2026, 6, 1), d(2026, 6, 30), half),
Err(LedgerError::LineArithmetic(_))
));
}
/// B-39 line accounts: an invoice takes revenue or deferred revenue; a
/// bill takes an expense or a non-cash asset; roles, cash and headers are
/// refused (plan P4b S4.1, A-10).
#[test]
fn document_line_account_refuses_roles_cash_and_placeholders() {
let mut b = books();
let deferred =
b.l.add_account(
"2400",
"Deferred Revenue",
AccountType::Liability,
None,
false,
)
.unwrap();
let revenue =
b.l.add_account("4000", "Revenue", AccountType::Revenue, None, false)
.unwrap();
let equipment =
b.l.add_account("1500", "Equipment", AccountType::Asset, None, false)
.unwrap();
let supplies =
b.l.add_account("6100", "Supplies", AccountType::Expense, None, false)
.unwrap();
let header =
b.l.add_account("9000", "Header", AccountType::Asset, None, true)
.unwrap();
let control =
b.l.add_account("1150", "Trade Receivables", AccountType::Asset, None, false)
.unwrap();
b.l.assign_role("receivables", Some(control)).unwrap();
let check = |l: &Ledger, kind, account| l.check_document_line_account(kind, account);
// An invoice line: revenue, or a liability holding no role.
assert!(check(&b.l, DocumentKind::Invoice, revenue).is_ok());
assert!(check(&b.l, DocumentKind::Invoice, deferred).is_ok());
assert!(check(&b.l, DocumentKind::Invoice, b.bank).is_err());
assert!(check(&b.l, DocumentKind::Invoice, supplies).is_err());
assert!(check(&b.l, DocumentKind::Invoice, equipment).is_err());
assert!(check(&b.l, DocumentKind::Invoice, control).is_err());
assert!(check(&b.l, DocumentKind::Invoice, header).is_err());
// A bill line: an expense, or a non-cash asset holding no role.
assert!(check(&b.l, DocumentKind::Bill, supplies).is_ok());
assert!(check(&b.l, DocumentKind::Bill, equipment).is_ok());
assert!(check(&b.l, DocumentKind::Bill, b.bank).is_err());
assert!(check(&b.l, DocumentKind::Bill, revenue).is_err());
assert!(check(&b.l, DocumentKind::Bill, deferred).is_err());
assert!(check(&b.l, DocumentKind::Bill, control).is_err());
assert!(check(&b.l, DocumentKind::Bill, header).is_err());
// The create path applies the same rule, and refuses before posting.
let before = b.l.next_bill_id();
let bad = vec![doc_line("Loan", 1_000, Some(b.bank), None, None)];
assert!(matches!(
b.l.create_bill(b.vendor, d(2026, 7, 1), d(2026, 7, 31), bad),
Err(LedgerError::DocumentLineAccount { .. })
));
assert_eq!(b.l.next_bill_id(), before);
}
/// A-2, B-40: numbers run, a void keeps its number, legacy invoices are
/// not counted, and a number is never reused.
#[test]
fn numbers_run_and_voids_keep_theirs() {
let mut b = books();
let first = {
let number = b.l.next_invoice_number("INV-", 1);
assert_eq!(number, "INV-0001");
b.l.create_invoice_with(
b.customer,
d(2026, 1, 5),
d(2026, 2, 4),
line("Work", 10_000),
InvoiceFields {
number: Some(number),
..InvoiceFields::default()
},
)
.unwrap()
};
let second = {
let number = b.l.next_invoice_number("INV-", 1);
assert_eq!(number, "INV-0002");
b.l.create_invoice_with(
b.customer,
d(2026, 1, 6),
d(2026, 2, 5),
line("Work", 20_000),
InvoiceFields {
number: Some(number),
..InvoiceFields::default()
},
)
.unwrap()
};
assert_eq!(
b.l.get_invoice(first).unwrap().number.as_deref(),
Some("INV-0001")
);
// A void keeps its number and still counts: the series never reuses
// one.
b.l.void_document(DocRef::Invoice(first), d(2026, 1, 7), "Duplicate")
.unwrap();
assert_eq!(
b.l.get_invoice(first).unwrap().number.as_deref(),
Some("INV-0001")
);
assert_eq!(b.l.next_invoice_number("INV-", 1), "INV-0003");
// A legacy invoice (no number) is not counted, shown as "Invoice {id}".
let legacy =
b.l.create_invoice(b.customer, d(2026, 1, 8), d(2026, 2, 7), line("Old", 100))
.unwrap();
assert_eq!(b.l.get_invoice(legacy).unwrap().number, None);
assert_eq!(b.l.next_invoice_number("INV-", 1), "INV-0003");
// Numbers are never reused, even by a direct create.
assert_eq!(
b.l.create_invoice_with(
b.customer,
d(2026, 1, 9),
d(2026, 2, 8),
line("Work", 100),
InvoiceFields {
number: Some("INV-0002".into()),
..InvoiceFields::default()
},
),
Err(LedgerError::DuplicateInvoiceNumber("INV-0002".into()))
);
assert_eq!(
b.l.get_invoice(second).unwrap().number.as_deref(),
Some("INV-0002")
);
}
/// B-40: four digits, wider past 9,999, never truncated.
#[test]
fn number_widens_past_9999() {
let mut b = books();
let number = b.l.next_invoice_number("INV-", 9_999);
assert_eq!(number, "INV-9999");
b.l.create_invoice_with(
b.customer,
d(2026, 2, 1),
d(2026, 3, 1),
line("Work", 100),
InvoiceFields {
number: Some(number),
..InvoiceFields::default()
},
)
.unwrap();
assert_eq!(b.l.next_invoice_number("INV-", 9_999), "INV-10000");
}
/// B-40: a vendor's invoice number names one standing bill; case, spaces
/// and hyphens are ignored; a duplicate needs a reason (plan P4b S4.1).
#[test]
fn duplicate_vendor_ref_refused_unless_a_reason_is_given() {
let mut b = books();
let fields = |vendor_ref: &str, reason: Option<&str>| BillFields {
vendor_ref: Some(vendor_ref.into()),
duplicate_reason: reason.map(str::to_string),
..BillFields::default()
};
let first =
b.l.create_bill_with(
b.vendor,
d(2026, 1, 5),
d(2026, 2, 4),
line("Paper", 1_000),
fields("ACME-123", None),
)
.unwrap();
// Case, spaces and hyphens do not make a new number.
for same in ["acme 123", "Acme123", "ACME - 123"] {
assert_eq!(
b.l.create_bill_with(
b.vendor,
d(2026, 1, 6),
d(2026, 2, 5),
line("Paper", 2_000),
fields(same, None),
),
Err(LedgerError::DuplicateVendorRef {
vendor: b.vendor,
vendor_ref: same.into()
})
);
}
// Another vendor's invoice may carry the same number.
let other = b.l.add_vendor("Other Supply").unwrap();
b.l.create_bill_with(
other,
d(2026, 1, 6),
d(2026, 2, 5),
line("Paper", 2_000),
fields("ACME-123", None),
)
.unwrap();
// A reason makes the second one a bill of its own.
let second =
b.l.create_bill_with(
b.vendor,
d(2026, 1, 7),
d(2026, 2, 6),
line("Paper", 3_000),
fields("acme 123", Some("Second shipment, vendor reissued it")),
)
.unwrap();
// A voided bill no longer stands, so its number is free again.
b.l.void_document(DocRef::Bill(first), d(2026, 1, 8), "Entered twice")
.unwrap();
b.l.void_document(DocRef::Bill(second), d(2026, 1, 8), "Entered twice")
.unwrap();
b.l.create_bill_with(
b.vendor,
d(2026, 1, 9),
d(2026, 2, 8),
line("Paper", 4_000),
fields("ACME-123", None),
)
.unwrap();
}