fix(core): payments never predate their documents; aging as of a date; refusals leave no draft

Three spec decisions from the owner (2026-10-13), raised by the spec-blind
test writers, each test-first:

- A payment dated before its invoice or bill is refused
  (PaymentBeforeDocument); a real prepayment is a deposit. Test:
  a_payment_never_predates_its_document. Replay is unaffected (it goes
  through replay_invoice_payment / replay_bill_payment).
- Aging as of a date shows only documents issued by then, less only the
  payments made by then. The ledger now records each document's payment
  entries (live and on replay), and aging counts those dated by as_of.
  Test: aging_as_of_a_date_sees_only_what_existed_then; the A/R and A/P
  conservation properties' aging oracles updated to the confirmed rule
  (they had assumed aging ignored dates).
- A schedule through a mid-month date prorates that month by days; the
  code was right and its doc wrong. The spec-blind test that disagreed is
  rewritten to the confirmed spec and no longer ignored.

Also found while wiring payments: a payment to an unknown cash account
created the payment's draft and then failed, leaving it behind; the cash
account is now checked before anything is created.

The unreviewed golden van_schedule.json is gone: every row was first
checked against an independent hand computation (all 61 matched), then
replaced by van_schedule_is_the_hand_computed_one, which asserts that
computation directly (548.39, 59 x 1,000.00, 451.61; total 60,000.00).

Tests: 452. No spec-disagreements left ignored.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GWVo7x3UHw5kwCUaWxiejX
This commit is contained in:
swaitsandClaude Opus 5.5 committed 2026-10-13 14:47:10 -06:00
1 parent ff7a9172b0
commit f9bd507765
8 files changed
+211 -393

No files matched your search

+60 -3
View File
@@ -212,6 +212,10 @@ pub struct Ledger {
next_recurring_id: RecurringId,
/// Who each entry paid or was billed by, for 1099 reporting.
entry_vendors: HashMap<EntryId, VendorId>,
/// Each invoice's payments (entry, amount), for aging as of a date.
invoice_payments: HashMap<InvoiceId, Vec<(EntryId, Decimal)>>,
/// Each bill's payments (entry, amount), for aging as of a date.
bill_payments: HashMap<BillId, Vec<(EntryId, Decimal)>>,
}
impl Ledger {
@@ -247,6 +251,8 @@ impl Ledger {
recurring: std::collections::BTreeMap::new(),
next_recurring_id: 1,
entry_vendors: HashMap::new(),
invoice_payments: HashMap::new(),
bill_payments: HashMap::new(),
}
}
@@ -1848,7 +1854,15 @@ impl Ledger {
if let Some(pid) = self.closed_period_containing(date) {
return Err(LedgerError::ClosedPeriod(pid, date.to_string()));
}
if date < invoice.date {
return Err(LedgerError::PaymentBeforeDocument {
date,
document_date: invoice.date,
});
}
self.check_controls(&[RECEIVABLES])?;
// Every check before the entry exists: a refusal leaves no draft.
self.check_line_account(cash_account)?;
let ar = self.ensure_control(&RECEIVABLES);
let entry = self.create_entry(date, &format!("Payment on invoice {}", invoice_id));
let money = Money::new(amount, self.base_currency);
@@ -1858,6 +1872,10 @@ impl Ledger {
let inv = self.invoices.get_mut(&invoice_id).expect("invoice exists");
inv.amount_paid += amount;
inv.status = arap::derive_status(inv.amount_paid, inv.total());
self.invoice_payments
.entry(invoice_id)
.or_default()
.push((entry, amount));
Ok(entry)
}
@@ -1897,7 +1915,15 @@ impl Ledger {
if let Some(pid) = self.closed_period_containing(date) {
return Err(LedgerError::ClosedPeriod(pid, date.to_string()));
}
if date < bill.date {
return Err(LedgerError::PaymentBeforeDocument {
date,
document_date: bill.date,
});
}
self.check_controls(&[PAYABLES])?;
// Every check before the entry exists: a refusal leaves no draft.
self.check_line_account(cash_account)?;
let ap = self.ensure_control(&PAYABLES);
let entry = self.create_entry(date, &format!("Payment on bill {}", bill_id));
let money = Money::new(amount, self.base_currency);
@@ -1907,6 +1933,10 @@ impl Ledger {
let b = self.bills.get_mut(&bill_id).expect("bill exists");
b.amount_paid += amount;
b.status = arap::derive_status(b.amount_paid, b.total());
self.bill_payments
.entry(bill_id)
.or_default()
.push((entry, amount));
self.entry_vendors.insert(entry, bill.vendor_id);
Ok(entry)
}
@@ -1991,12 +2021,15 @@ impl Ledger {
amount: Decimal,
entry_id: EntryId,
) -> Result<(), LedgerError> {
let _ = entry_id;
let inv = self
.invoices
.get_mut(&invoice_id)
.ok_or(LedgerError::UnknownInvoice(invoice_id))?;
inv.amount_paid += amount;
self.invoice_payments
.entry(invoice_id)
.or_default()
.push((entry_id, amount));
inv.status = arap::derive_status(inv.amount_paid, inv.total());
Ok(())
}
@@ -2016,6 +2049,10 @@ impl Ledger {
.ok_or(LedgerError::UnknownBill(bill_id))?;
bill.amount_paid += amount;
bill.status = arap::derive_status(bill.amount_paid, bill.total());
self.bill_payments
.entry(bill_id)
.or_default()
.push((entry_id, amount));
let vendor = bill.vendor_id;
self.entry_vendors.insert(entry_id, vendor);
Ok(())
@@ -2026,7 +2063,12 @@ impl Ledger {
pub fn ar_aging(&self, as_of: NaiveDate) -> Vec<AgingRow> {
let mut by_customer: HashMap<CustomerId, AgingRow> = HashMap::new();
for inv in self.invoices.values() {
let balance = inv.balance();
// Only what existed by `as_of`: issued by then, less payments
// made by then (owner, 2026-10-13).
if inv.date > as_of {
continue;
}
let balance = inv.total() - self.paid_by(self.invoice_payments.get(&inv.id), as_of);
if balance.is_zero() {
continue;
}
@@ -2056,12 +2098,27 @@ impl Ledger {
rows
}
/// What a document's payments add up to through `as_of`: payments are
/// posted entries, dated.
fn paid_by(&self, payments: Option<&Vec<(EntryId, Decimal)>>, as_of: NaiveDate) -> Decimal {
payments
.into_iter()
.flatten()
.filter(|(e, _)| self.get_entry(*e).is_some_and(|e| e.date() <= as_of))
.map(|(_, amount)| *amount)
.sum()
}
/// A/P aging as of a date: one row per vendor with an outstanding
/// balance, bucketed by days past due.
pub fn ap_aging(&self, as_of: NaiveDate) -> Vec<AgingRow> {
let mut by_vendor: HashMap<VendorId, AgingRow> = HashMap::new();
for bill in self.bills.values() {
let balance = bill.balance();
// Only what existed by `as_of` (see `ar_aging`).
if bill.date > as_of {
continue;
}
let balance = bill.total() - self.paid_by(self.bill_payments.get(&bill.id), as_of);
if balance.is_zero() {
continue;
}