fix(core): payments never predate their documents; aging as of a date; refusals leave no draft
Three spec decisions from Stephen (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:
1 parent
cece4c4554
commit
c9810ea0a3
8 files changed
+211
-393
No files matched your search
@@ -128,8 +128,26 @@ fn depreciation_end(asset: &Asset) -> NaiveDate {
|
||||
}
|
||||
}
|
||||
|
||||
/// Straight-line depreciation schedule from the purchase month through the
|
||||
/// month containing `through` (clamped to the depreciation end).
|
||||
/// Straight-line depreciation schedule from the purchase month through
|
||||
/// `through` (clamped to the depreciation end): one line per month, the
|
||||
/// last prorated by days when `through` falls mid-month.
|
||||
///
|
||||
/// ```text
|
||||
/// GOAL: how much of an asset's cost has been used up, month by month.
|
||||
/// SOURCE: docs/spec/rules.md B-15 (straight line; salvage never
|
||||
/// depreciated; partial months by days); DECISION (Stephen,
|
||||
/// 2026-10-13): a schedule through a mid-month date prorates that
|
||||
/// month by days, as partial first and last months do.
|
||||
/// INVARIANTS:
|
||||
/// 1. A full schedule MUST sum to exactly cost - salvage, to the cent.
|
||||
/// 2. Accumulated depreciation NEVER falls; book value NEVER rises and
|
||||
/// NEVER drops below salvage.
|
||||
/// SCENARIOS:
|
||||
/// 1. IF $12,000 over 12 months from Jan 1, through Jun 15:
|
||||
/// - SHOULD end with June at 500.00 (15 of 30 days), accumulated
|
||||
/// 5,500.00
|
||||
/// BECAUSE depreciation accrues by the day within a month.
|
||||
/// ```
|
||||
///
|
||||
/// Partial first and last months are prorated by days held. Each month is
|
||||
/// rounded to cents via the running-total method, and the final month
|
||||
|
||||
@@ -159,6 +159,16 @@ pub enum LedgerError {
|
||||
other_end: chrono::NaiveDate,
|
||||
},
|
||||
|
||||
/// A payment can't apply to an invoice or bill dated after it (Stephen,
|
||||
/// 2026-10-13); a real prepayment is a deposit, recorded differently.
|
||||
#[error("a payment dated {date} can't apply to a document dated {document_date}")]
|
||||
PaymentBeforeDocument {
|
||||
/// The payment's date.
|
||||
date: chrono::NaiveDate,
|
||||
/// The invoice's or bill's date.
|
||||
document_date: chrono::NaiveDate,
|
||||
},
|
||||
|
||||
/// The referenced journal entry does not exist.
|
||||
#[error("entry {0} does not exist")]
|
||||
UnknownEntry(u64),
|
||||
|
||||
@@ -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 (Stephen, 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;
|
||||
}
|
||||
|
||||
@@ -261,18 +261,39 @@ fn schedule_through_purchase_date_covers_one_day() {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn golden_van_depreciation_schedule() {
|
||||
// The canonical van's month-by-month schedule, pinned byte-for-value.
|
||||
// Hand-verified: 61 months, first 548.39 = 1000 x 17/31, last 451.61 =
|
||||
// 1000 x 14/31, lifetime total exactly 60000.00.
|
||||
fn van_schedule_is_the_hand_computed_one() {
|
||||
// Rank 1, computed by hand (rules.md B-15), replacing an unreviewed
|
||||
// golden file (2026-10-13; every row of the old file was checked
|
||||
// against this computation first, and matched).
|
||||
// $60,000 over 60 months is $1,000 a month. Bought 2026-01-15: January
|
||||
// holds 17 of 31 days = 548.39; February 2026 through December 2030 are
|
||||
// 59 full months; life ends 2031-01-15, so January 2031 holds 14 of 31
|
||||
// days = 451.61. 548.39 + 59,000 + 451.61 = 60,000.00 exactly.
|
||||
let (mut ledger, cost, accum, expense) = test_ledger();
|
||||
let id = van_asset(&mut ledger, 60_000, cost, accum, expense);
|
||||
let asset = ledger.get_asset(id).unwrap();
|
||||
let sched = depreciation_schedule(asset, date(2032, 1, 1));
|
||||
let got = serde_json::to_value(&sched).unwrap();
|
||||
let golden: serde_json::Value =
|
||||
serde_json::from_str(include_str!("golden/van_schedule.json")).unwrap();
|
||||
assert_eq!(got, golden);
|
||||
assert_eq!(sched.len(), 61);
|
||||
let mut accumulated = Decimal::ZERO;
|
||||
for (k, line) in sched.iter().enumerate() {
|
||||
let (year, month) = (2026 + (k as i32) / 12, (k as u32) % 12 + 1);
|
||||
let amount = match k {
|
||||
0 => Decimal::new(54839, 2),
|
||||
60 => Decimal::new(45161, 2),
|
||||
_ => Decimal::new(100000, 2),
|
||||
};
|
||||
accumulated += amount;
|
||||
assert_eq!(line.period, format!("{year}-{month:02}"));
|
||||
assert_eq!(line.amount, amount, "{}", line.period);
|
||||
assert_eq!(line.accumulated, accumulated, "{}", line.period);
|
||||
assert_eq!(
|
||||
line.book_value,
|
||||
Decimal::from(60_000) - accumulated,
|
||||
"{}",
|
||||
line.period
|
||||
);
|
||||
}
|
||||
assert_eq!(accumulated, Decimal::from(60_000));
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -1,368 +0,0 @@
|
||||
[
|
||||
{
|
||||
"accumulated": "548.39",
|
||||
"amount": "548.39",
|
||||
"book_value": "59451.61",
|
||||
"period": "2026-01"
|
||||
},
|
||||
{
|
||||
"accumulated": "1548.39",
|
||||
"amount": "1000.00",
|
||||
"book_value": "58451.61",
|
||||
"period": "2026-02"
|
||||
},
|
||||
{
|
||||
"accumulated": "2548.39",
|
||||
"amount": "1000.00",
|
||||
"book_value": "57451.61",
|
||||
"period": "2026-03"
|
||||
},
|
||||
{
|
||||
"accumulated": "3548.39",
|
||||
"amount": "1000.00",
|
||||
"book_value": "56451.61",
|
||||
"period": "2026-04"
|
||||
},
|
||||
{
|
||||
"accumulated": "4548.39",
|
||||
"amount": "1000.00",
|
||||
"book_value": "55451.61",
|
||||
"period": "2026-05"
|
||||
},
|
||||
{
|
||||
"accumulated": "5548.39",
|
||||
"amount": "1000.00",
|
||||
"book_value": "54451.61",
|
||||
"period": "2026-06"
|
||||
},
|
||||
{
|
||||
"accumulated": "6548.39",
|
||||
"amount": "1000.00",
|
||||
"book_value": "53451.61",
|
||||
"period": "2026-07"
|
||||
},
|
||||
{
|
||||
"accumulated": "7548.39",
|
||||
"amount": "1000.00",
|
||||
"book_value": "52451.61",
|
||||
"period": "2026-08"
|
||||
},
|
||||
{
|
||||
"accumulated": "8548.39",
|
||||
"amount": "1000.00",
|
||||
"book_value": "51451.61",
|
||||
"period": "2026-09"
|
||||
},
|
||||
{
|
||||
"accumulated": "9548.39",
|
||||
"amount": "1000.00",
|
||||
"book_value": "50451.61",
|
||||
"period": "2026-10"
|
||||
},
|
||||
{
|
||||
"accumulated": "10548.39",
|
||||
"amount": "1000.00",
|
||||
"book_value": "49451.61",
|
||||
"period": "2026-11"
|
||||
},
|
||||
{
|
||||
"accumulated": "11548.39",
|
||||
"amount": "1000.00",
|
||||
"book_value": "48451.61",
|
||||
"period": "2026-12"
|
||||
},
|
||||
{
|
||||
"accumulated": "12548.39",
|
||||
"amount": "1000.00",
|
||||
"book_value": "47451.61",
|
||||
"period": "2027-01"
|
||||
},
|
||||
{
|
||||
"accumulated": "13548.39",
|
||||
"amount": "1000.00",
|
||||
"book_value": "46451.61",
|
||||
"period": "2027-02"
|
||||
},
|
||||
{
|
||||
"accumulated": "14548.39",
|
||||
"amount": "1000.00",
|
||||
"book_value": "45451.61",
|
||||
"period": "2027-03"
|
||||
},
|
||||
{
|
||||
"accumulated": "15548.39",
|
||||
"amount": "1000.00",
|
||||
"book_value": "44451.61",
|
||||
"period": "2027-04"
|
||||
},
|
||||
{
|
||||
"accumulated": "16548.39",
|
||||
"amount": "1000.00",
|
||||
"book_value": "43451.61",
|
||||
"period": "2027-05"
|
||||
},
|
||||
{
|
||||
"accumulated": "17548.39",
|
||||
"amount": "1000.00",
|
||||
"book_value": "42451.61",
|
||||
"period": "2027-06"
|
||||
},
|
||||
{
|
||||
"accumulated": "18548.39",
|
||||
"amount": "1000.00",
|
||||
"book_value": "41451.61",
|
||||
"period": "2027-07"
|
||||
},
|
||||
{
|
||||
"accumulated": "19548.39",
|
||||
"amount": "1000.00",
|
||||
"book_value": "40451.61",
|
||||
"period": "2027-08"
|
||||
},
|
||||
{
|
||||
"accumulated": "20548.39",
|
||||
"amount": "1000.00",
|
||||
"book_value": "39451.61",
|
||||
"period": "2027-09"
|
||||
},
|
||||
{
|
||||
"accumulated": "21548.39",
|
||||
"amount": "1000.00",
|
||||
"book_value": "38451.61",
|
||||
"period": "2027-10"
|
||||
},
|
||||
{
|
||||
"accumulated": "22548.39",
|
||||
"amount": "1000.00",
|
||||
"book_value": "37451.61",
|
||||
"period": "2027-11"
|
||||
},
|
||||
{
|
||||
"accumulated": "23548.39",
|
||||
"amount": "1000.00",
|
||||
"book_value": "36451.61",
|
||||
"period": "2027-12"
|
||||
},
|
||||
{
|
||||
"accumulated": "24548.39",
|
||||
"amount": "1000.00",
|
||||
"book_value": "35451.61",
|
||||
"period": "2028-01"
|
||||
},
|
||||
{
|
||||
"accumulated": "25548.39",
|
||||
"amount": "1000.00",
|
||||
"book_value": "34451.61",
|
||||
"period": "2028-02"
|
||||
},
|
||||
{
|
||||
"accumulated": "26548.39",
|
||||
"amount": "1000.00",
|
||||
"book_value": "33451.61",
|
||||
"period": "2028-03"
|
||||
},
|
||||
{
|
||||
"accumulated": "27548.39",
|
||||
"amount": "1000.00",
|
||||
"book_value": "32451.61",
|
||||
"period": "2028-04"
|
||||
},
|
||||
{
|
||||
"accumulated": "28548.39",
|
||||
"amount": "1000.00",
|
||||
"book_value": "31451.61",
|
||||
"period": "2028-05"
|
||||
},
|
||||
{
|
||||
"accumulated": "29548.39",
|
||||
"amount": "1000.00",
|
||||
"book_value": "30451.61",
|
||||
"period": "2028-06"
|
||||
},
|
||||
{
|
||||
"accumulated": "30548.39",
|
||||
"amount": "1000.00",
|
||||
"book_value": "29451.61",
|
||||
"period": "2028-07"
|
||||
},
|
||||
{
|
||||
"accumulated": "31548.39",
|
||||
"amount": "1000.00",
|
||||
"book_value": "28451.61",
|
||||
"period": "2028-08"
|
||||
},
|
||||
{
|
||||
"accumulated": "32548.39",
|
||||
"amount": "1000.00",
|
||||
"book_value": "27451.61",
|
||||
"period": "2028-09"
|
||||
},
|
||||
{
|
||||
"accumulated": "33548.39",
|
||||
"amount": "1000.00",
|
||||
"book_value": "26451.61",
|
||||
"period": "2028-10"
|
||||
},
|
||||
{
|
||||
"accumulated": "34548.39",
|
||||
"amount": "1000.00",
|
||||
"book_value": "25451.61",
|
||||
"period": "2028-11"
|
||||
},
|
||||
{
|
||||
"accumulated": "35548.39",
|
||||
"amount": "1000.00",
|
||||
"book_value": "24451.61",
|
||||
"period": "2028-12"
|
||||
},
|
||||
{
|
||||
"accumulated": "36548.39",
|
||||
"amount": "1000.00",
|
||||
"book_value": "23451.61",
|
||||
"period": "2029-01"
|
||||
},
|
||||
{
|
||||
"accumulated": "37548.39",
|
||||
"amount": "1000.00",
|
||||
"book_value": "22451.61",
|
||||
"period": "2029-02"
|
||||
},
|
||||
{
|
||||
"accumulated": "38548.39",
|
||||
"amount": "1000.00",
|
||||
"book_value": "21451.61",
|
||||
"period": "2029-03"
|
||||
},
|
||||
{
|
||||
"accumulated": "39548.39",
|
||||
"amount": "1000.00",
|
||||
"book_value": "20451.61",
|
||||
"period": "2029-04"
|
||||
},
|
||||
{
|
||||
"accumulated": "40548.39",
|
||||
"amount": "1000.00",
|
||||
"book_value": "19451.61",
|
||||
"period": "2029-05"
|
||||
},
|
||||
{
|
||||
"accumulated": "41548.39",
|
||||
"amount": "1000.00",
|
||||
"book_value": "18451.61",
|
||||
"period": "2029-06"
|
||||
},
|
||||
{
|
||||
"accumulated": "42548.39",
|
||||
"amount": "1000.00",
|
||||
"book_value": "17451.61",
|
||||
"period": "2029-07"
|
||||
},
|
||||
{
|
||||
"accumulated": "43548.39",
|
||||
"amount": "1000.00",
|
||||
"book_value": "16451.61",
|
||||
"period": "2029-08"
|
||||
},
|
||||
{
|
||||
"accumulated": "44548.39",
|
||||
"amount": "1000.00",
|
||||
"book_value": "15451.61",
|
||||
"period": "2029-09"
|
||||
},
|
||||
{
|
||||
"accumulated": "45548.39",
|
||||
"amount": "1000.00",
|
||||
"book_value": "14451.61",
|
||||
"period": "2029-10"
|
||||
},
|
||||
{
|
||||
"accumulated": "46548.39",
|
||||
"amount": "1000.00",
|
||||
"book_value": "13451.61",
|
||||
"period": "2029-11"
|
||||
},
|
||||
{
|
||||
"accumulated": "47548.39",
|
||||
"amount": "1000.00",
|
||||
"book_value": "12451.61",
|
||||
"period": "2029-12"
|
||||
},
|
||||
{
|
||||
"accumulated": "48548.39",
|
||||
"amount": "1000.00",
|
||||
"book_value": "11451.61",
|
||||
"period": "2030-01"
|
||||
},
|
||||
{
|
||||
"accumulated": "49548.39",
|
||||
"amount": "1000.00",
|
||||
"book_value": "10451.61",
|
||||
"period": "2030-02"
|
||||
},
|
||||
{
|
||||
"accumulated": "50548.39",
|
||||
"amount": "1000.00",
|
||||
"book_value": "9451.61",
|
||||
"period": "2030-03"
|
||||
},
|
||||
{
|
||||
"accumulated": "51548.39",
|
||||
"amount": "1000.00",
|
||||
"book_value": "8451.61",
|
||||
"period": "2030-04"
|
||||
},
|
||||
{
|
||||
"accumulated": "52548.39",
|
||||
"amount": "1000.00",
|
||||
"book_value": "7451.61",
|
||||
"period": "2030-05"
|
||||
},
|
||||
{
|
||||
"accumulated": "53548.39",
|
||||
"amount": "1000.00",
|
||||
"book_value": "6451.61",
|
||||
"period": "2030-06"
|
||||
},
|
||||
{
|
||||
"accumulated": "54548.39",
|
||||
"amount": "1000.00",
|
||||
"book_value": "5451.61",
|
||||
"period": "2030-07"
|
||||
},
|
||||
{
|
||||
"accumulated": "55548.39",
|
||||
"amount": "1000.00",
|
||||
"book_value": "4451.61",
|
||||
"period": "2030-08"
|
||||
},
|
||||
{
|
||||
"accumulated": "56548.39",
|
||||
"amount": "1000.00",
|
||||
"book_value": "3451.61",
|
||||
"period": "2030-09"
|
||||
},
|
||||
{
|
||||
"accumulated": "57548.39",
|
||||
"amount": "1000.00",
|
||||
"book_value": "2451.61",
|
||||
"period": "2030-10"
|
||||
},
|
||||
{
|
||||
"accumulated": "58548.39",
|
||||
"amount": "1000.00",
|
||||
"book_value": "1451.61",
|
||||
"period": "2030-11"
|
||||
},
|
||||
{
|
||||
"accumulated": "59548.39",
|
||||
"amount": "1000.00",
|
||||
"book_value": "451.61",
|
||||
"period": "2030-12"
|
||||
},
|
||||
{
|
||||
"accumulated": "60000",
|
||||
"amount": "451.61",
|
||||
"book_value": "0",
|
||||
"period": "2031-01"
|
||||
}
|
||||
]
|
||||
@@ -1162,7 +1162,10 @@ proptest! {
|
||||
let rows = l.ar_aging(as_of);
|
||||
let mut want: std::collections::BTreeMap<String, [Decimal; 5]> = Default::default();
|
||||
for (i, doc) in docs.iter().enumerate() {
|
||||
let open = totals[i] - expect_paid[i];
|
||||
// Payments (dated base + 5) count only once made: aging
|
||||
// as of a date sees what existed then (Stephen, 2026-10-13).
|
||||
let paid = if as_of >= base + Duration::days(5) { expect_paid[i] } else { Decimal::ZERO };
|
||||
let open = totals[i] - paid;
|
||||
if open.is_zero() {
|
||||
continue;
|
||||
}
|
||||
@@ -1175,7 +1178,8 @@ proptest! {
|
||||
prop_assert_eq!(rows.len(), got.len(), "one row per customer");
|
||||
prop_assert_eq!(&got, &want);
|
||||
let row_sum: Decimal = rows.iter().map(|r| r.total()).sum();
|
||||
prop_assert_eq!(row_sum, open_sum);
|
||||
let want_sum: Decimal = want.values().flatten().copied().sum();
|
||||
prop_assert_eq!(row_sum, want_sum);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1247,7 +1251,9 @@ proptest! {
|
||||
let rows = l.ap_aging(as_of);
|
||||
let mut want: std::collections::BTreeMap<String, [Decimal; 5]> = Default::default();
|
||||
for (i, doc) in docs.iter().enumerate() {
|
||||
let open = totals[i] - expect_paid[i];
|
||||
// Payments (dated base + 3) count only once made.
|
||||
let paid = if as_of >= base + Duration::days(3) { expect_paid[i] } else { Decimal::ZERO };
|
||||
let open = totals[i] - paid;
|
||||
if open.is_zero() {
|
||||
continue;
|
||||
}
|
||||
@@ -1262,3 +1268,69 @@ proptest! {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Decisions (Stephen, 2026-10-13): no payment before its document; aging
|
||||
// as of a date sees only what existed by then.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// A payment can't apply to an invoice or bill that didn't exist yet (a
|
||||
/// real prepayment is a deposit, recorded differently). Refused, books
|
||||
/// unchanged.
|
||||
#[test]
|
||||
fn a_payment_never_predates_its_document() {
|
||||
let (mut l, cash) = books();
|
||||
let c = l.add_customer("Acme").unwrap();
|
||||
let v = l.add_vendor("Vend").unwrap();
|
||||
let inv = l
|
||||
.create_invoice(c, d(2026, 3, 10), d(2026, 4, 10), vec![line("w", "100.00")])
|
||||
.unwrap();
|
||||
let bill = l
|
||||
.create_bill(v, d(2026, 3, 10), d(2026, 4, 10), vec![line("w", "100.00")])
|
||||
.unwrap();
|
||||
let before = fingerprint(&l);
|
||||
assert_eq!(
|
||||
l.receive_payment(inv, d(2026, 3, 9), dec("10.00"), cash),
|
||||
Err(LedgerError::PaymentBeforeDocument {
|
||||
date: d(2026, 3, 9),
|
||||
document_date: d(2026, 3, 10),
|
||||
})
|
||||
);
|
||||
assert!(matches!(
|
||||
l.pay_bill(bill, d(2026, 3, 1), dec("10.00"), cash),
|
||||
Err(LedgerError::PaymentBeforeDocument { .. })
|
||||
));
|
||||
assert_eq!(fingerprint(&l), before);
|
||||
// The same day is fine.
|
||||
l.receive_payment(inv, d(2026, 3, 10), dec("10.00"), cash)
|
||||
.unwrap();
|
||||
}
|
||||
|
||||
/// Aging as of a date shows only documents issued by then, less only the
|
||||
/// payments made by then.
|
||||
#[test]
|
||||
fn aging_as_of_a_date_sees_only_what_existed_then() {
|
||||
let (mut l, cash) = books();
|
||||
let c = l.add_customer("Acme").unwrap();
|
||||
let early = l
|
||||
.create_invoice(c, d(2026, 1, 5), d(2026, 2, 4), vec![line("a", "300.00")])
|
||||
.unwrap();
|
||||
l.create_invoice(c, d(2026, 3, 1), d(2026, 3, 31), vec![line("b", "500.00")])
|
||||
.unwrap();
|
||||
l.receive_payment(early, d(2026, 2, 20), dec("100.00"), cash)
|
||||
.unwrap();
|
||||
let total = |as_of| -> Decimal {
|
||||
l.ar_aging(as_of)
|
||||
.iter()
|
||||
.map(|r| buckets(r).iter().copied().sum::<Decimal>())
|
||||
.sum()
|
||||
};
|
||||
// Jan 31: only the first invoice exists, nothing paid yet.
|
||||
assert_eq!(total(d(2026, 1, 31)), dec("300.00"));
|
||||
// Feb 28: paid 100 of it; the March invoice doesn't exist yet.
|
||||
assert_eq!(total(d(2026, 2, 28)), dec("200.00"));
|
||||
// Mar 15: both.
|
||||
assert_eq!(total(d(2026, 3, 15)), dec("700.00"));
|
||||
// Before anything was issued: nothing.
|
||||
assert!(l.ar_aging(d(2025, 12, 31)).is_empty());
|
||||
}
|
||||
@@ -285,16 +285,17 @@ fn full_months_each_take_one_twelfth_b15() {
|
||||
}
|
||||
}
|
||||
|
||||
/// Oracle: interface doc "through the month containing `through`": through
|
||||
/// 2026-06-15 covers Jan..Jun.
|
||||
/// Oracle: depreciation_schedule SCENARIO 1 (DECISION, Stephen 2026-10-13,
|
||||
/// resolving this test's original disagreement in favor of the code): a
|
||||
/// schedule through 2026-06-15 prorates June by days: 15/30 of 1,000.
|
||||
#[test]
|
||||
#[ignore = "SPEC-DISAGREEMENT: schedule through 2026-06-15 prorates June to 500.00 (acc 5500.00), doc says through the month containing it (acc 6000.00)"]
|
||||
fn schedule_runs_through_month_containing_through_date() {
|
||||
fn schedule_through_a_mid_month_date_prorates_that_month_by_days() {
|
||||
let a = asset("12000.00", "0.00", 12, date(2026, 1, 1));
|
||||
let s = depreciation_schedule(&a, date(2026, 6, 15));
|
||||
assert_eq!(s.len(), 6);
|
||||
assert_eq!(s.last().unwrap().period, "2026-06");
|
||||
assert_eq!(s.last().unwrap().accumulated, d("6000.00"));
|
||||
assert_eq!(s.last().unwrap().amount, d("500.00"));
|
||||
assert_eq!(s.last().unwrap().accumulated, d("5500.00"));
|
||||
}
|
||||
|
||||
/// Oracle: interface doc "clamped to the depreciation end": a schedule asked
|
||||
|
||||
+11
-4
@@ -245,7 +245,8 @@ depreciation that evenly splits the depreciable amount across the useful
|
||||
life of the asset," with "(Cost – salvage value)/Useful life"; partial
|
||||
periods: "A common method is to allocate depreciation expense based on the
|
||||
number of months the asset is owned in a year." wanderbooks prorates
|
||||
partial months by days. ASC 360-10-35-4 not yet verified (see README).
|
||||
partial months by days, including a schedule through a mid-month date
|
||||
(DECISION, Stephen 2026-10-13). ASC 360-10-35-4 not yet verified (see README).
|
||||
Tax conventions (half-year, mid-month, mid-quarter): IRS Pub. 946
|
||||
(`sources/irs-depreciation.md`), for the CPA.
|
||||
|
||||
@@ -291,10 +292,16 @@ more than 90 days past due"; it is illustrative, not a rule. The FFIEC
|
||||
Call Report instructions (bank regulators, not binding here): "The past
|
||||
due status … should be determined in accordance with its contractual
|
||||
repayment terms." A document due today is not yet past due: DECISION
|
||||
(Stephen, 2026-10-13).
|
||||
(Stephen, 2026-10-13). Aging as of a date shows only documents issued by
|
||||
then, less only payments made by then; and a payment never predates its
|
||||
invoice or bill (a real prepayment is a deposit): DECISIONS (Stephen,
|
||||
2026-10-13).
|
||||
|
||||
**Enforced:** `arap::aging_bucket`. **Checked:** `aging_boundaries`,
|
||||
`aging_is_exclusive_and_only_ages`.
|
||||
**Enforced:** `arap::aging_bucket`, `Ledger::ar_aging`/`ap_aging`,
|
||||
`receive_payment`/`pay_bill` (`PaymentBeforeDocument`). **Checked:**
|
||||
`aging_boundaries`, `aging_is_exclusive_and_only_ages`,
|
||||
`aging_as_of_a_date_sees_only_what_existed_then`,
|
||||
`a_payment_never_predates_its_document`.
|
||||
|
||||
## B-19 1099 reporting thresholds
|
||||
|
||||
|
||||
Reference in new issue
Block a user