Files
wanderbooks/crates/ledger-core/tests/spec_documents.rs
T
swaits 10ee0681c1 feat(arap): settle documents with discounts and a fee (P4b S6)
One payment had to name one document and could take no discount or
processor fee, so paying several invoices from one deposit meant several
entries and the fee had nowhere to go. This adds the kernel's exact
settlement arithmetic (applications total, net deposit), a Ledger::settle
that posts one entry for a customer's or vendor's documents, and the gross
method and gross fee A-8/A-4 chose.

- kernel::settle: sum(cash)+sum(discounts)=applications, sum(cash)-fee=
  deposit, None on overflow or a negative deposit. Kani settle_is_exact
  plus the Decimal bridge.
- Ledger::settle / settlement_lines: one party and one kind per
  settlement, one application per document, each cash+discount within
  payment_room; a discount within the terms (discount_max and
  discount_by) needs no reason, a larger or later one does; a bill with
  an asset line is refused with D-49's remedy; a fee is a customer
  payment's only and posts gross.
- Rows are payment lines in application order, then discount lines, the
  fee line and the cash line, so a journaled event's line indices are
  stable; replay tags exactly the line an event names and leaves an
  unmatched line unattached (never refuses).
- receive_payment_with and pay_bill_with become one-application calls.
- Roles: sales_allowances (4100), purchase_discounts (6995) and
  payment_processing_fees (6060), constants used on demand but not added
  to control_accounts() (existing charts may use those codes for other
  things).
- Lean: settle posts through Step.document, so settle_posts_a_document_step
  and settle_unattached, no new constructor; both in Audit.lean.
- Core tests: bill_on_terms_with_discount, late_discount_needs_a_reason,
  discount_on_an_asset_bill_is_refused_with_the_remedy,
  a_fee_posts_gross_to_receivables_and_net_to_the_bank,
  a_settlement_names_its_lines_and_refuses_a_document_twice, and
  equipment_bill_payment_is_investing_cash in the cash-flow suite.
- Bookspec B-42 is stated, with its id BS-arap-6; the catalogue and index
  now record it as CONVENTION.

just check and just prove green.
2026-10-27 12:59:05 -06:00

2302 lines
83 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();
}
// ---------------------------------------------------------------------------
// P4b S6: paying on terms (rules.md B-42)
// ---------------------------------------------------------------------------
/// A bill on "2/10 net 30", paid within the discount window: the gross
/// method's entry is Dr A/P the full amount, Cr purchase discounts what
/// was taken, Cr the bank the rest; the bill reads paid and discounted.
/// A discount larger than the terms allow needs a reason (B-42).
#[test]
fn bill_on_terms_with_discount() {
use ledger_core::{Application, DocRef, SettleParams};
let mut b = books();
let terms = Terms::parse("2/10 net 30").unwrap();
let on = d(2026, 4, 1);
let bill =
b.l.create_bill_with(
b.vendor,
on,
terms.due_date(on).unwrap(),
line("Paper", 10_000),
BillFields {
terms: Some(terms),
..BillFields::default()
},
)
.unwrap();
let entry =
b.l.settle(SettleParams {
date: d(2026, 4, 5),
description: "Bill 1, early".into(),
cash_account: b.bank,
party: b.vendor,
applications: vec![Application {
doc: DocRef::Bill(bill),
cash: cents(9_800),
discount: cents(200),
discount_reason: None,
}],
fee: None,
instrument: None,
reference: None,
})
.unwrap();
let lines = b.l.get_entry(entry).unwrap().lines();
assert_eq!(lines.len(), 4);
assert_eq!(
(lines[0].account, lines[0].side),
(b.account("2000"), Side::Debit)
);
assert_eq!(lines[0].amount.amount, cents(9_800));
assert_eq!(
(lines[1].account, lines[1].side),
(b.account("2000"), Side::Debit)
);
assert_eq!(lines[1].amount.amount, cents(200));
assert_eq!(
(lines[2].account, lines[2].side),
(b.account("6995"), Side::Credit)
);
assert_eq!(lines[2].amount.amount, cents(200));
assert_eq!((lines[3].account, lines[3].side), (b.bank, Side::Credit));
assert_eq!(lines[3].amount.amount, cents(9_800));
let st = b.state(DocRef::Bill(bill), d(2026, 4, 5));
assert_eq!(
(st.status, st.paid, st.discounted, st.outstanding),
(DocStatus::Paid, cents(9_800), cents(200), Decimal::ZERO)
);
assert_eq!(b.l.line_doc(entry, 0).unwrap().movement, Movement::Payment);
assert_eq!(b.l.line_doc(entry, 1).unwrap().movement, Movement::Discount);
b.ties(d(2026, 4, 5));
// 3% off is more than 2/10 allows: refused without a reason, taken
// with one (a vendor honouring it anyway).
let bill2 =
b.l.create_bill_with(
b.vendor,
on,
Terms::parse("2/10 net 30").unwrap().due_date(on).unwrap(),
line("Paper", 10_000),
BillFields {
terms: Some(Terms::parse("2/10 net 30").unwrap()),
..BillFields::default()
},
)
.unwrap();
let params = |reason: Option<String>| SettleParams {
date: d(2026, 4, 6),
description: "Bill 2, late".into(),
cash_account: b.bank,
party: b.vendor,
applications: vec![Application {
doc: DocRef::Bill(bill2),
cash: cents(9_700),
discount: cents(300),
discount_reason: reason,
}],
fee: None,
instrument: None,
reference: None,
};
assert!(matches!(
b.l.settle(params(None)),
Err(LedgerError::DiscountNeedsReason { .. })
));
b.l.settle(params(Some("the vendor honoured 3%".into())))
.unwrap();
}
/// A discount taken after the discount date needs a reason (B-42
/// INVARIANT 2); a bill with an asset or prepaid line refuses one with the
/// D-49 remedy, and none of the refusals changes the books.
#[test]
fn late_discount_needs_a_reason() {
use ledger_core::{Application, DocRef, SettleParams};
let mut b = books();
let equipment =
b.l.add_account("1500", "Equipment", AccountType::Asset, None, false)
.unwrap();
let terms = Terms::parse("2/10 net 30").unwrap();
let on = d(2026, 4, 1);
let bill =
b.l.create_bill_with(
b.vendor,
on,
terms.due_date(on).unwrap(),
line("Paper", 10_000),
BillFields {
terms: Some(terms.clone()),
..BillFields::default()
},
)
.unwrap();
let late = |reason: Option<String>| SettleParams {
date: d(2026, 4, 20),
description: "Bill 1, late discount".into(),
cash_account: b.bank,
party: b.vendor,
applications: vec![Application {
doc: DocRef::Bill(bill),
cash: cents(9_800),
discount: cents(200),
discount_reason: reason,
}],
fee: None,
instrument: None,
reference: None,
};
assert!(matches!(
b.l.settle(late(None)),
Err(LedgerError::DiscountNeedsReason { .. })
));
b.l.settle(late(Some("the vendor honoured it late".into())))
.unwrap();
// A bill whose line is equipment: no apportioning (D-49).
let asset_bill =
b.l.create_bill_with(
b.vendor,
on,
terms.due_date(on).unwrap(),
vec![DocumentLine {
description: "Laptop".into(),
amount: cents(10_000),
class_id: None,
account: Some(equipment),
quantity: None,
unit_price: None,
}],
BillFields {
terms: Some(terms),
..BillFields::default()
},
)
.unwrap();
let before = b.l.doc_state(DocRef::Bill(asset_bill), d(2026, 4, 5));
let refused = b.l.settle(SettleParams {
date: d(2026, 4, 5),
description: "Laptop, discounted".into(),
cash_account: b.bank,
party: b.vendor,
applications: vec![Application {
doc: DocRef::Bill(asset_bill),
cash: cents(9_800),
discount: cents(200),
discount_reason: Some("vendor honoured it".into()),
}],
fee: None,
instrument: None,
reference: None,
});
match refused {
Err(LedgerError::DiscountOnAssetBill { code, .. }) => assert_eq!(code, "1500"),
other => panic!("expected DiscountOnAssetBill, got {other:?}"),
}
assert_eq!(
b.l.doc_state(DocRef::Bill(asset_bill), d(2026, 4, 5)),
before
);
assert_eq!(
b.state(DocRef::Bill(asset_bill), d(2026, 4, 5)).outstanding,
cents(10_000)
);
}
/// The processor's fee posts gross (A-4): Dr the bank the net, Dr the
/// processing-fee expense the fee, Cr A/R the gross; the invoice reads
/// paid and the deposit account has only the net.
#[test]
fn a_fee_posts_gross_to_receivables_and_net_to_the_bank() {
use ledger_core::{Application, DocRef, Fee, SettleParams};
let mut b = books();
let invoice = b.invoice(d(2026, 4, 1), 10_000);
let entry =
b.l.settle(SettleParams {
date: d(2026, 4, 3),
description: "Card payment, fee withheld".into(),
cash_account: b.bank,
party: b.customer,
applications: vec![Application {
doc: DocRef::Invoice(invoice),
cash: cents(10_000),
discount: Decimal::ZERO,
discount_reason: None,
}],
fee: Some(Fee {
amount: cents(300),
account: None,
vendor: None,
}),
instrument: None,
reference: None,
})
.unwrap();
let lines = b.l.get_entry(entry).unwrap().lines();
assert_eq!(lines.len(), 3);
assert_eq!(
(lines[0].account, lines[0].side),
(b.account("1200"), Side::Credit)
);
assert_eq!(lines[0].amount.amount, cents(10_000));
assert_eq!(
(lines[1].account, lines[1].side),
(b.account("6060"), Side::Debit)
);
assert_eq!(lines[1].amount.amount, cents(300));
assert_eq!((lines[2].account, lines[2].side), (b.bank, Side::Debit));
assert_eq!(lines[2].amount.amount, cents(9_700));
assert_eq!(
b.state(DocRef::Invoice(invoice), d(2026, 4, 3)).status,
DocStatus::Paid
);
assert_eq!(b.l.account_balance(b.bank).unwrap(), cents(9_700));
}
/// A payment that settles more than one document names its lines in
/// application order, and two applications on one document are refused:
/// each document's bound is one bound (B-42 INVARIANTS 1 and 2).
#[test]
fn a_settlement_names_its_lines_and_refuses_a_document_twice() {
use ledger_core::{Application, DocRef, SettleParams};
let mut b = books();
let first = b.invoice(d(2026, 4, 1), 10_000);
let second = b.invoice(d(2026, 4, 2), 5_000);
let entry =
b.l.settle(SettleParams {
date: d(2026, 4, 5),
description: "Two invoices, one deposit".into(),
cash_account: b.bank,
party: b.customer,
applications: vec![
Application {
doc: DocRef::Invoice(first),
cash: cents(10_000),
discount: Decimal::ZERO,
discount_reason: None,
},
Application {
doc: DocRef::Invoice(second),
cash: cents(5_000),
discount: Decimal::ZERO,
discount_reason: None,
},
],
fee: None,
instrument: None,
reference: None,
})
.unwrap();
let lines = b.l.get_entry(entry).unwrap().lines();
assert_eq!(lines.len(), 3);
assert_eq!(b.l.line_doc(entry, 0).unwrap().doc, DocRef::Invoice(first));
assert_eq!(b.l.line_doc(entry, 1).unwrap().doc, DocRef::Invoice(second));
assert!(b.l.line_doc(entry, 2).is_none());
assert_eq!(b.l.account_balance(b.bank).unwrap(), cents(15_000));
let thrice = b.invoice(d(2026, 4, 3), 10_000);
let before = b.l.account_balance(b.bank).unwrap();
let refused = b.l.settle(SettleParams {
date: d(2026, 4, 5),
description: "Twice".into(),
cash_account: b.bank,
party: b.customer,
applications: vec![
Application {
doc: DocRef::Invoice(thrice),
cash: cents(5_000),
discount: Decimal::ZERO,
discount_reason: None,
},
Application {
doc: DocRef::Invoice(thrice),
cash: cents(5_000),
discount: Decimal::ZERO,
discount_reason: None,
},
],
fee: None,
instrument: None,
reference: None,
});
assert!(matches!(
refused,
Err(LedgerError::SettlementDuplicateDocument(_))
));
assert_eq!(b.l.account_balance(b.bank).unwrap(), before);
}
/// A discount on a bill with an asset line is refused, never apportioned
/// (D-49): the refusal names the asset account and both remedies (record
/// the bill net, or reclassify Dr 6995 / Cr the asset).
#[test]
fn discount_on_an_asset_bill_is_refused_with_the_remedy() {
use ledger_core::{Application, DocRef, SettleParams};
let mut b = books();
let equipment =
b.l.add_account("1500", "Equipment", AccountType::Asset, None, false)
.unwrap();
let bill =
b.l.create_bill(
b.vendor,
d(2026, 4, 1),
d(2026, 5, 1),
vec![DocumentLine {
description: "Laptop".into(),
amount: cents(10_000),
class_id: None,
account: Some(equipment),
quantity: None,
unit_price: None,
}],
)
.unwrap();
match b.l.settle(SettleParams {
date: d(2026, 4, 5),
description: "Laptop, discounted".into(),
cash_account: b.bank,
party: b.vendor,
applications: vec![Application {
doc: DocRef::Bill(bill),
cash: cents(9_800),
discount: cents(200),
discount_reason: Some("the vendor honoured it".into()),
}],
fee: None,
instrument: None,
reference: None,
}) {
Err(LedgerError::DiscountOnAssetBill { code, .. }) => assert_eq!(code, "1500"),
other => panic!("expected DiscountOnAssetBill, got {other:?}"),
}
assert_eq!(
b.state(DocRef::Bill(bill), d(2026, 4, 5)).outstanding,
cents(10_000)
);
b.ties(d(2026, 4, 5));
}