Files
wanderbooks/crates/ledger-core/tests/spec_replayed_extremes.rs
T
swaits 13282cf80e feat(arap): terms, numbers and line accounts in the core (P4b S4.1)
Documents could name only one revenue or general-expenses account per
project, had no number of their own, and took a due date the caller had
to work out. This slice gives the core the pieces the rest of S4 needs,
and journals nothing: an old-shape document still writes byte for byte,
so the owner's books need no upgrade for it.

- Payment terms (B-39): Net n, Net n EOM, due on receipt and a custom
  date, with the early-payment discount and its exact bound
  (kernel::discount_max, Kani + bridge). A due date past the calendar's
  end is refused, never a panic, and a discount is refused with terms
  that have no net days.
- Invoice numbers (B-40, A-2): first_number plus the count of numbered
  invoices, zero-padded to four digits and wider past 9,999, stored at
  issue, kept by a void; legacy invoices are uncounted.
- Line accounts (A-10): a document line may name its account, and the
  entry posts one line per (account, project) part. An invoice line takes
  revenue or a liability holding no role (deferred revenue); a bill line
  an expense or a non-cash asset holding no role. Roles, cash accounts
  and headers are refused.
- A line's amount is exactly its quantity times its unit price
  (kernel::line_amount, Kani + bridge): both or neither, never rounded.
- A vendor's invoice number names one standing bill per vendor, with
  case, spaces and hyphens ignored, unless a duplicate reason says why
  (B-40).
- The record fields S4.3 will journal: number, terms, bill-to and seller
  snapshots, customer reference, memo, the empty sends list S5 fills, and
  the bill's received_on, all optional and defaulted.

The store refuses a line carrying an account, quantity or unit price
before it rehearses or journals anything: those fields are journaled from
S4.3's documents_v2 on, and I-3 will not let a public write replay
without them. `document_line_fields_wait_for_documents_v2` proves the
refusal, the unchanged journal and allocators, and that an ordinary line
journals exactly as before. S4.3 removes this guard in the same change
that journals the fields and adds its byte-for-byte test.

The document commands keep their signatures; create_invoice_with and
create_bill_with take the optional fields for S4.3 and for tests. They
are the existing issue commands with more fields, not a new state
transition, so no Lean Step is added. Kani: line_amount_is_exact runs in
`just prove`; discount_max_is_bounded relates a product to a quotient and
runs in `prove-slow` (163 s), as the proration charge harnesses do.

just check green; just prove green.
2026-10-27 00:02:51 -06:00

485 lines
18 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! Figures derived from what replay accepts: computed exactly where exact
//! arithmetic and chrono's calendar reach, refused (TooLargeToCompute,
//! DateOutOfRange) where they don't, never a panic (rules.md B-28; P0
//! review round 4, 2026-10-17).
//!
//! ```text
//! GOAL: books an earlier version wrote (a draft line of Decimal's
//! largest, shares and bases past the line limit, a document's lines
//! or a payroll run as journaled, dates at chrono's last day) can be
//! read every way the engine reads books, and the server stays up.
//! SOURCE: DECISION (P0 review round 4: posted is forever; derived
//! computations refuse rather than panic).
//! ```
use chrono::{Datelike, NaiveDate};
use ledger_core::{
AccountType, Currency, DocumentLine, Ledger, LedgerError, Money, NewAsset, PartyShare,
PayrollLine, Side,
};
use rust_decimal::Decimal;
fn d(y: i32, m: u32, day: u32) -> NaiveDate {
NaiveDate::from_ymd_opt(y, m, day).unwrap()
}
fn usd(a: Decimal) -> Money {
Money::new(a, Currency::USD)
}
fn too_large(r: Result<impl std::fmt::Debug, LedgerError>) -> bool {
matches!(r, Err(LedgerError::TooLargeToCompute(_)))
}
/// An asset whose life ends in chrono's last month (an earlier version
/// accepted any life whose end chrono holds): its schedule walks into
/// December 262142, prorates that month by its 31 days, and sums to its
/// cost exactly.
#[test]
fn depreciation_reaches_chronos_last_month() {
let mut l = Ledger::new(Currency::USD);
let mut add = |code: &str, ty| l.add_account(code, code, ty, None, false).unwrap();
let cost_account = add("1500", AccountType::Asset);
let accum = add("1510", AccountType::Asset);
let expense = add("6100", AccountType::Expense);
let y = NaiveDate::MAX.year();
let id = l
.replay_register_asset(NewAsset {
description: "edge".into(),
in_service_date: d(y, 1, 15),
cost: usd(Decimal::new(120000, 2)),
salvage_value: usd(Decimal::ZERO),
useful_life_months: 11,
cost_account,
accum_depr_account: accum,
expense_account: expense,
})
.unwrap();
let a = l.get_asset(id).unwrap();
let full = ledger_core::depreciation_schedule(a, NaiveDate::MAX).unwrap();
assert_eq!(full.len(), 12);
assert_eq!(full[0].period, format!("{y}-01"));
assert_eq!(full[11].period, format!("{y}-12"));
assert_eq!(full[11].accumulated, Decimal::new(120000, 2));
assert_eq!(
full.iter().map(|l| l.amount).sum::<Decimal>(),
Decimal::new(120000, 2)
);
// Through November: eleven months, December not yet.
let nov = ledger_core::depreciation_schedule(a, d(y, 11, 30)).unwrap();
assert_eq!(nov.len(), 11);
assert!(nov.last().unwrap().accumulated < Decimal::new(120000, 2));
// December is walked without stepping past chrono's last day, and,
// the life's final month (its days 1-14), charged by the day (P3,
// defect 22): the life holds Jan 15-31 (17/31 of a month), ten whole
// months, then what they leave of 1,200.00, 1,200 × (14/31)/11, over
// December's 14 days, so 1,200/(31 × 11) a day, January's own rate.
// Through Dec 10: 1,200 × (17/31 + 10 + 10/31)/11 = 404,400/341 =
// 1,185.9237 → 1,185.92.
let dec = ledger_core::depreciation_schedule(a, d(y, 12, 10)).unwrap();
assert_eq!(dec.last().unwrap().period, format!("{y}-12"));
let want = (Decimal::from(404_400) / Decimal::from(341))
.round_dp_with_strategy(2, rust_decimal::RoundingStrategy::MidpointAwayFromZero);
assert_eq!(want, Decimal::new(118592, 2));
assert_eq!(dec.last().unwrap().accumulated, want);
assert_eq!(
ledger_core::accumulated_scheduled_through(a, NaiveDate::MAX).unwrap(),
Decimal::new(120000, 2)
);
assert_eq!(
ledger_core::scheduled_book_value(a, NaiveDate::MAX).unwrap(),
Decimal::ZERO
);
}
/// An asset whose life runs past 9999 (or starts before year 1) trues up
/// in its final month like any other: its lifetime is exactly its cost.
#[test]
fn a_life_past_9999_trues_up_to_the_cent() {
let mut l = Ledger::new(Currency::USD);
let mut add = |code: &str, ty| l.add_account(code, code, ty, None, false).unwrap();
let cost_account = add("1500", AccountType::Asset);
let accum = add("1510", AccountType::Asset);
let expense = add("6100", AccountType::Expense);
// March 17th to April 17th: a 31-day first month and a 30-day last, so
// the partial months drift and the true-up is needed.
for (start, months) in [
(d(9999, 3, 17), 61),
(d(-10, 3, 17), 61),
(d(9998, 3, 17), 25),
] {
let id = l
.replay_register_asset(NewAsset {
description: "far".into(),
in_service_date: start,
cost: usd(Decimal::new(100_000, 2)),
salvage_value: usd(Decimal::new(1, 2)),
useful_life_months: months,
cost_account,
accum_depr_account: accum,
expense_account: expense,
})
.unwrap();
let a = l.get_asset(id).unwrap();
let s = ledger_core::depreciation_schedule(a, NaiveDate::MAX).unwrap();
assert_eq!(
s.last().unwrap().accumulated,
Decimal::new(99_999, 2),
"{start} + {months}"
);
assert_eq!(
s.iter().map(|l| l.amount).sum::<Decimal>(),
Decimal::new(99_999, 2),
"{start} + {months}"
);
}
}
/// February has 29 days exactly in the years chrono calls leap, from its
/// first year to its last (year 0 and the negative years included), so a
/// schedule in any of them prorates February rightly.
#[test]
fn february_is_prorated_by_chronos_calendar_in_every_year() {
let mut l = Ledger::new(Currency::USD);
let mut add = |code: &str, ty| l.add_account(code, code, ty, None, false).unwrap();
let cost_account = add("1500", AccountType::Asset);
let accum = add("1510", AccountType::Asset);
let expense = add("6100", AccountType::Expense);
let min = NaiveDate::MIN.year();
let max = NaiveDate::MAX.year();
for year in [
min,
min + 1,
-400,
-100,
-4,
-1,
0,
1,
4,
100,
400,
1900,
2000,
2024,
2026,
max - 1,
max,
] {
let leap = NaiveDate::from_ymd_opt(year, 2, 29).is_some();
// Bought February 1st, two months of life, so February is a whole
// month of it.
let id = l
.replay_register_asset(NewAsset {
description: format!("{year}"),
in_service_date: d(year, 2, 1),
cost: usd(Decimal::from(2900)),
salvage_value: usd(Decimal::ZERO),
useful_life_months: 2,
cost_account,
accum_depr_account: accum,
expense_account: expense,
})
.unwrap();
let a = l.get_asset(id).unwrap();
// Through the 14th: 14 of February's days.
let half = ledger_core::depreciation_schedule(a, d(year, 2, 14)).unwrap();
let days = if leap { 29 } else { 28 };
let want = (Decimal::from(1450) * Decimal::from(14) / Decimal::from(days))
.round_dp_with_strategy(2, rust_decimal::RoundingStrategy::MidpointAwayFromZero);
assert_eq!(half.last().unwrap().amount, want, "{year}");
}
}
/// A draft an earlier version journaled with lines of Decimal's largest:
/// its money flow and debits are refused, its shares don't resolve, its
/// post refuses it.
#[test]
fn a_draft_of_decimals_largest_is_refused_where_summed() {
let mut l = Ledger::new(Currency::USD);
let cash = l
.add_account("1000", "Cash", AccountType::Asset, None, false)
.unwrap();
let card = l
.add_account("2050", "Card", AccountType::Liability, None, false)
.unwrap();
let rent = l
.add_account("6000", "Rent", AccountType::Expense, None, false)
.unwrap();
let p = l
.add_party("Acme", &[ledger_core::Role::Contractor])
.unwrap();
let q = l
.add_party("Beta", &[ledger_core::Role::Contractor])
.unwrap();
let capital = l
.add_account("3000", "Capital", AccountType::Equity, None, false)
.unwrap();
let member = l
.add_party("Tobin Harlow", &[ledger_core::Role::Member])
.unwrap();
let money = ledger_core::parties::money_accounts(&l, &[cash], &[]);
let resolve = |l: &Ledger, e, shares: &[PartyShare]| {
ledger_core::shares::resolve(l, e, shares, &money, &[])
};
for (dr, cr) in [(rent, cash), (rent, card), (card, cash), (cash, card)] {
let e = l.create_entry(d(2026, 3, 1), "huge");
for side in [Side::Debit, Side::Debit] {
l.replay_add_line(e, dr, side, usd(Decimal::MAX)).unwrap();
}
l.replay_add_line(e, cr, Side::Credit, usd(Decimal::MAX))
.unwrap();
l.replay_add_line(e, cr, Side::Credit, usd(Decimal::MAX))
.unwrap();
let entry = l.get_entry(e).unwrap();
assert!(too_large(ledger_core::parties::entry_flow(entry, &money)));
assert!(too_large(ledger_core::shares::target(
&l,
e,
&[PartyShare::whole(p)],
&money
)));
let why = resolve(&l, e, &[PartyShare::whole(p)]).unwrap_err();
assert!(why.contains("too large to compute"), "{why}");
assert_eq!(l.post_entry(e).unwrap_err(), LedgerError::AmountOutOfRange);
}
// A member's capital moved past exact arithmetic (rules.md B-35, case
// 3, B-28): the target is refused, never a panic.
let e = l.create_entry(d(2026, 3, 1), "huge capital");
for _ in 0..2 {
l.replay_add_line(e, cash, Side::Debit, usd(Decimal::MAX))
.unwrap();
l.replay_add_line(e, capital, Side::Credit, usd(Decimal::MAX))
.unwrap();
}
assert!(too_large(ledger_core::shares::target(
&l,
e,
&[PartyShare::whole(member)],
&money
)));
// One line of Decimal's largest flows, and its share resolves whole.
let e = l.create_entry(d(2026, 3, 1), "one huge");
l.replay_add_line(e, rent, Side::Debit, usd(Decimal::MAX))
.unwrap();
l.replay_add_line(e, cash, Side::Credit, usd(Decimal::MAX))
.unwrap();
let entry = l.get_entry(e).unwrap();
assert_eq!(
ledger_core::parties::entry_flow(entry, &money).unwrap().net,
Decimal::MAX
);
let r = resolve(&l, e, &[PartyShare::whole(p)]).unwrap();
assert_eq!(
(r.parts[0].party, r.parts[0].amount, r.parts[0].cash),
(p, Decimal::MAX, Decimal::MAX)
);
// A percent of it: the product is past exact arithmetic.
let halves = [p, q].map(|party| PartyShare {
party,
share: ledger_core::shares::Share::Percent(Decimal::from(50)),
});
let why = resolve(&l, e, &halves).unwrap_err();
assert!(why.contains("too large to compute"), "{why}");
}
/// A document is restored as journaled: its total is exact, or refused
/// past exact arithmetic; what it owes is what the ledger lines tagged
/// with it carry (rules.md B-38), exact, whatever the journal held (paid
/// above its total included); aging refuses a document whose total it
/// can't compute.
#[test]
fn documents_and_aging_are_exact_or_refused() {
let mut l = Ledger::new(Currency::USD);
let ar = l
.add_account("1200", "Receivables", AccountType::Asset, None, false)
.unwrap();
let revenue = l
.add_account("4000", "Revenue", AccountType::Revenue, None, false)
.unwrap();
let cash = l
.add_account("1000", "Cash", AccountType::Asset, None, false)
.unwrap();
let customer = l.add_customer("Client").unwrap();
let line = |a: Decimal| DocumentLine {
description: "x".into(),
amount: a,
class_id: None,
account: None,
quantity: None,
unit_price: None,
};
// An entry as replay rebuilds it: Dr `dr` / Cr `cr` for `a`.
let replayed = |l: &mut Ledger, dr, cr, a: Decimal| {
let e = l.create_entry(d(2026, 3, 1), "replayed");
l.replay_add_line(e, dr, Side::Debit, usd(a)).unwrap();
l.replay_add_line(e, cr, Side::Credit, usd(a)).unwrap();
l.replay_post_entry(e).unwrap();
e
};
let fine = vec![
line(Decimal::new(1, 11)),
line("9.99999999999".parse().unwrap()),
];
let e1 = replayed(&mut l, ar, revenue, Decimal::TEN);
l.replay_invoice(1, customer, d(2026, 3, 1), d(2026, 3, 31), fine, e1)
.unwrap();
let inv = l.get_invoice(1).unwrap();
assert_eq!(inv.total(), Ok(Decimal::TEN));
assert_eq!(inv.balance(), Ok(Decimal::TEN));
let rows = l.ar_aging(d(2026, 6, 1)).unwrap();
// 62 days past due on June 1st: 61-90.
assert_eq!(rows[0].days_90, Decimal::TEN);
assert_eq!(rows[0].total(), Ok(Decimal::TEN));
// Two documents of half Decimal's largest each, their totals exact; no
// line carries them (no entry could), so they owe nothing and age
// nowhere.
let half = Decimal::MAX / Decimal::TWO;
for id in [2, 3] {
l.replay_invoice(
id,
customer,
d(2026, 3, 1),
d(2026, 3, 31),
vec![line(half)],
100 + id,
)
.unwrap();
assert_eq!(l.get_invoice(id).unwrap().total(), Ok(half));
let st = l.invoice_state(id, d(2026, 6, 1)).unwrap().unwrap();
assert_eq!(st.outstanding, Decimal::ZERO);
}
assert_eq!(
l.ar_aging(d(2026, 6, 1)).unwrap()[0].total(),
Ok(Decimal::TEN)
);
// Paid more than its total, as journaled: a negative balance, exact.
let p = replayed(&mut l, cash, ar, Decimal::from(11));
l.replay_invoice_payment(1, Decimal::from(11), p).unwrap();
assert_eq!(l.get_invoice(1).unwrap().balance(), Ok(-Decimal::ONE));
let st = l.invoice_state(1, d(2026, 6, 1)).unwrap().unwrap();
assert_eq!(
(st.paid, st.outstanding),
(Decimal::from(11), -Decimal::ONE)
);
// A payment event naming an amount no line of its entry carries tags
// nothing, and is refused nothing (B-28).
l.replay_invoice_payment(1, -Decimal::ONE, p).unwrap();
assert_eq!(l.get_invoice(1).unwrap().balance(), Ok(-Decimal::ONE));
// A total of lines that overflow, and its balance; aging refuses it.
l.replay_invoice(
4,
customer,
d(2026, 3, 1),
d(2026, 3, 31),
vec![line(Decimal::MAX), line(Decimal::MAX)],
104,
)
.unwrap();
assert!(too_large(l.get_invoice(4).unwrap().total()));
assert!(too_large(l.get_invoice(4).unwrap().balance()));
assert!(too_large(l.ar_aging(d(2026, 6, 1))));
assert!(too_large(l.invoice_state(4, d(2026, 6, 1))));
// Bills alike.
let vendor = l.add_vendor("Supplier").unwrap();
l.replay_bill(
1,
vendor,
d(2026, 3, 1),
d(2026, 3, 31),
vec![line(Decimal::MAX), line(Decimal::ONE)],
106,
)
.unwrap();
assert!(too_large(l.get_bill(1).unwrap().total()));
assert!(too_large(l.ap_aging(d(2026, 6, 1))));
let row = ledger_core::AgingRow {
name: "x".into(),
current: Decimal::MAX,
days_30: Decimal::ONE,
days_60: Decimal::ZERO,
days_90: Decimal::ZERO,
days_90_plus: Decimal::ZERO,
};
assert!(too_large(row.total()));
for (i, one) in [
(0, Decimal::ONE),
(1, Decimal::TWO),
(2, Decimal::TEN),
(3, Decimal::ONE_HUNDRED),
(4, Decimal::ONE_THOUSAND),
] {
let mut r = ledger_core::AgingRow {
current: Decimal::ZERO,
..row.clone()
};
r.days_30 = Decimal::ZERO;
match i {
0 => r.current = one,
1 => r.days_30 = one,
2 => r.days_60 = one,
3 => r.days_90 = one,
_ => r.days_90_plus = one,
}
assert_eq!(r.total(), Ok(one), "bucket {i}");
}
}
/// A payroll run restored as journaled: net pay is exact, or refused past
/// exact arithmetic; a run's plan refuses totals it can't hold.
#[test]
fn payroll_figures_are_exact_or_refused() {
let line = |gross: Decimal, fed: Decimal| PayrollLine {
employee_name: "Dana".into(),
employee: None,
gross_pay: gross,
federal_withholding: fed,
state_withholding: Decimal::new(1, 2),
ss_employee: Decimal::new(2, 2),
medicare_employee: Decimal::new(3, 2),
ss_employer: Decimal::ZERO,
medicare_employer: Decimal::ZERO,
other_deductions: Decimal::new(4, 2),
};
assert_eq!(
line(Decimal::from(100), Decimal::new(1, 12)).net_pay(),
Ok("99.899999999999".parse().unwrap())
);
assert!(too_large(line(-Decimal::MAX, Decimal::ONE).net_pay()));
let accounts = ledger_core::payroll::PayrollAccounts {
salary_expense: 1,
payroll_tax_expense: 2,
federal_payable: 3,
state_payable: 4,
ss_payable: 5,
medicare_payable: 6,
other_payable: 7,
};
let big = line(Decimal::MAX, Decimal::ZERO);
assert!(too_large(ledger_core::payroll::payroll_entry_plan(
&[big.clone(), big],
accounts,
8,
Currency::USD
)));
let plan = ledger_core::payroll::payroll_entry_plan(
&[line(Decimal::from(100), Decimal::ONE)],
accounts,
8,
Currency::USD,
)
.unwrap();
let debits: Decimal = plan
.iter()
.filter(|(_, s, _)| *s == Side::Debit)
.map(|(_, _, m)| m.amount)
.sum();
let credits: Decimal = plan
.iter()
.filter(|(_, s, _)| *s == Side::Credit)
.map(|(_, _, m)| m.amount)
.sum();
assert_eq!(debits, Decimal::from(100));
assert_eq!(credits, Decimal::from(100));
}