Files
wanderbooks/crates/ledger-core/tests/properties.rs
T
swaitsandClaude Opus 5.5 2f999c6050
check / check (push) Canceled after 0s
P3: assets' posted and scheduled depreciation, their bases, and the K-1 on the tax basis (defect 13, D-41)
Book value is cost less what the books posted; the schedule's figure is
apart. An asset's acquired date comes from its entry. Contributed
property keeps its section 723 carryover basis apart from its depreciable
basis (the lower of value and adjusted basis), with its 704(c) built-in
gain and method. The K-1 worksheet reports tax-basis capital (Form 1065
Item L) beside the book figures, leaving open what only the CPA knows.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01T3gXMVt7XJPi6ens1or9Dj
2026-10-21 14:04:12 -06:00

936 lines
40 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.
//! Property tests for the ledger engine: invariants that must hold for
//! *every* input, not just the hand-picked examples in `invariants.rs`.
//!
//! These are the properties mutation testing will try to break: if a
//! mutant survives the example tests but breaks a property, the property
//! catches it.
use chrono::NaiveDate;
use ledger_core::{
AccountType, Currency, Ledger, Money, NewAsset, Side, accumulated_scheduled_through,
depreciation_schedule,
reports::{
balance_sheet, balance_sheet_as_of, profit_and_loss_for_period, trial_balance,
unclosed_earnings,
},
scheduled_book_value,
};
use proptest::prelude::*;
use rust_decimal::Decimal;
fn arb_account_type() -> impl Strategy<Value = AccountType> {
prop_oneof![
Just(AccountType::Asset),
Just(AccountType::Liability),
Just(AccountType::Equity),
Just(AccountType::Revenue),
Just(AccountType::Expense),
]
}
/// A random chart: 2..=6 accounts with unique codes, no placeholders.
fn arb_chart() -> impl Strategy<Value = Vec<(String, String, AccountType)>> {
prop::collection::vec((1u32..9999u32, arb_account_type()), 2..=6).prop_map(|v| {
v.into_iter()
.enumerate()
.map(|(i, (_, t))| (format!("{:04}", 1000 + i), format!("Account {i}"), t))
.collect()
})
}
#[derive(Clone, Debug)]
struct GenEntry {
lines: Vec<(u64, Side, Decimal)>,
date: NaiveDate,
}
/// A random *balanced* entry over `num_accounts` (1-based ids): 2..=4
/// random lines plus a plug line for the imbalance. Amounts are whole
/// cents, so the plug is always exact.
fn arb_entry(num_accounts: usize) -> impl Strategy<Value = GenEntry> {
(
prop::collection::vec(
(
1u64..=(num_accounts as u64),
prop_oneof![Just(Side::Debit), Just(Side::Credit)],
1u32..1_000_000u32,
),
2..=4,
),
1u32..=12u32,
1u32..=28u32,
)
.prop_map(|(raw, month, day)| {
let mut lines: Vec<(u64, Side, Decimal)> = raw
.into_iter()
.map(|(a, s, cents)| (a, s, Decimal::new(cents as i64, 2)))
.collect();
let net: Decimal = lines
.iter()
.map(|(_, s, amt)| match s {
Side::Debit => *amt,
Side::Credit => -*amt,
})
.sum();
if net != Decimal::ZERO {
let (side, amt) = if net > Decimal::ZERO {
(Side::Credit, net)
} else {
(Side::Debit, -net)
};
lines.push((1, side, amt));
}
GenEntry {
lines,
date: NaiveDate::from_ymd_opt(2026, month, day).unwrap(),
}
})
}
fn arb_ops() -> impl Strategy<Value = (Vec<(String, String, AccountType)>, Vec<GenEntry>)> {
arb_chart().prop_flat_map(|chart| {
let n = chart.len();
(Just(chart), prop::collection::vec(arb_entry(n), 0..=10))
})
}
fn build_ledger(
chart: &[(String, String, AccountType)],
entries: &[GenEntry],
) -> (Ledger, Vec<u64>) {
let mut ledger = Ledger::new(Currency::USD);
for (code, name, typ) in chart {
ledger.add_account(code, name, *typ, None, false).unwrap();
}
let mut ids = Vec::new();
for e in entries {
let id = ledger.create_entry(e.date, "prop");
for (a, s, amt) in &e.lines {
ledger
.add_line(id, *a, *s, Money::new(*amt, Currency::USD))
.unwrap();
}
ledger.post_entry(id).unwrap();
ids.push(id);
}
(ledger, ids)
}
proptest! {
#![proptest_config(ProptestConfig::with_cases(256))]
/// The trial balance always balances, no matter the operations.
#[test]
fn prop_trial_balance_balances((chart, entries) in arb_ops()) {
let (ledger, _) = build_ledger(&chart, &entries);
let tb = trial_balance(&ledger);
prop_assert_eq!(tb.total_debits, tb.total_credits);
let debit_sum: Decimal = tb.lines.iter().map(|l| l.debit).sum();
let credit_sum: Decimal = tb.lines.iter().map(|l| l.credit).sum();
prop_assert_eq!(debit_sum, tb.total_debits);
prop_assert_eq!(credit_sum, tb.total_credits);
}
/// The accounting equation holds for every ledger state:
/// assets == liabilities + equity (with current earnings folded in).
#[test]
fn prop_balance_sheet_balances((chart, entries) in arb_ops()) {
let (ledger, _) = build_ledger(&chart, &entries);
let bs = balance_sheet(&ledger).unwrap();
prop_assert_eq!(bs.assets, bs.liabilities + bs.equity);
}
/// Period P&Ls partition the year: split anywhere, the two halves sum
/// to the whole, and the whole is the life-of-ledger P&L.
#[test]
fn prop_period_pnl_partitions((chart, entries) in arb_ops(), split in 0u32..365) {
let (ledger, _) = build_ledger(&chart, &entries);
let jan1 = NaiveDate::from_ymd_opt(2026, 1, 1).unwrap();
let dec31 = NaiveDate::from_ymd_opt(2026, 12, 31).unwrap();
let cut = jan1 + chrono::Days::new(split as u64);
let first = profit_and_loss_for_period(&ledger, jan1, cut, None).unwrap();
let rest = if cut < dec31 {
Some(profit_and_loss_for_period(&ledger, cut.succ_opt().unwrap(), dec31, None).unwrap())
} else {
None
};
let whole = profit_and_loss_for_period(&ledger, jan1, dec31, None).unwrap();
let (r2, e2) = rest.map_or((Decimal::ZERO, Decimal::ZERO), |p| (p.total_revenue, p.total_expenses));
prop_assert_eq!(first.total_revenue + r2, whole.total_revenue);
prop_assert_eq!(first.total_expenses + e2, whole.total_expenses);
let all = unclosed_earnings(&ledger).unwrap();
prop_assert_eq!((whole.total_revenue, whole.total_expenses), (all.revenue, all.expenses));
}
/// The accounting equation holds as of every date, and a period's net
/// income is exactly the change in current earnings across it.
#[test]
fn prop_balance_sheet_as_of_balances((chart, entries) in arb_ops(), a in 0u32..365, b in 0u32..365) {
let (ledger, _) = build_ledger(&chart, &entries);
let jan1 = NaiveDate::from_ymd_opt(2026, 1, 1).unwrap();
let (lo, hi) = (a.min(b), a.max(b));
let start = jan1 + chrono::Days::new(lo as u64);
let end = jan1 + chrono::Days::new(hi as u64);
let before = balance_sheet_as_of(&ledger, start.pred_opt().unwrap()).unwrap();
let after = balance_sheet_as_of(&ledger, end).unwrap();
prop_assert!(before.balanced());
prop_assert!(after.balanced());
let pnl = profit_and_loss_for_period(&ledger, start, end, None).unwrap();
prop_assert_eq!(after.current_earnings - before.current_earnings, pnl.net_income());
// Past the last entry, as-of agrees with the life-of-ledger sheet.
let all = balance_sheet(&ledger).unwrap();
let last = balance_sheet_as_of(&ledger, NaiveDate::from_ymd_opt(2026, 12, 31).unwrap()).unwrap();
prop_assert_eq!(
(last.total_assets, last.total_liabilities, last.total_equity),
(all.assets, all.liabilities, all.equity)
);
}
/// Every posted entry balances on its own, exactly.
#[test]
fn prop_posted_entries_balance((chart, entries) in arb_ops()) {
let (ledger, ids) = build_ledger(&chart, &entries);
for id in ids {
let e = ledger.get_entry(id).unwrap();
let debits: Decimal = e.lines().iter()
.filter(|l| l.side == Side::Debit)
.map(|l| l.amount.amount).sum();
let credits: Decimal = e.lines().iter()
.filter(|l| l.side == Side::Credit)
.map(|l| l.amount.amount).sum();
prop_assert_eq!(debits, credits);
}
}
/// Reversing every entry returns the trial balance to zero:
/// reversals are exact inverses.
#[test]
fn prop_reversals_restore_zero((chart, entries) in arb_ops()) {
let (mut ledger, ids) = build_ledger(&chart, &entries);
for id in &ids {
ledger.reverse_entry(*id, NaiveDate::from_ymd_opt(2026, 12, 31).unwrap(), "reversal").unwrap();
}
let tb = trial_balance(&ledger);
prop_assert_eq!(tb.total_debits, Decimal::ZERO);
prop_assert_eq!(tb.total_credits, Decimal::ZERO);
}
/// Posted entries are immutable: serializing them before and after
/// further operations yields identical bytes.
#[test]
fn prop_posted_entries_immutable((chart, entries) in arb_ops(), extra in prop::collection::vec(arb_entry(2), 0..=5)) {
prop_assume!(chart.len() >= 2);
let (mut ledger, ids) = build_ledger(&chart, &entries);
let before: Vec<String> = ids.iter().map(|id| {
serde_json::to_string(&serde_json::to_value(ledger.get_entry(*id).unwrap()).unwrap()).unwrap()
}).collect();
// More operations: entries sized for a 2-account chart would break
// on bigger charts, so rebuild them against this chart.
let n = chart.len();
let extra_entries: Vec<GenEntry> = extra.into_iter().map(|mut e| {
for (a, _, _) in e.lines.iter_mut() {
*a = (*a % n as u64) + 1;
}
// Rebalance after remapping accounts (net may have changed).
let net: Decimal = e.lines.iter().map(|(_, s, amt)| match s {
Side::Debit => *amt, Side::Credit => -*amt,
}).sum();
if net != Decimal::ZERO {
let (side, amt) = if net > Decimal::ZERO { (Side::Credit, net) } else { (Side::Debit, -net) };
e.lines.push((1, side, amt));
}
e
}).collect();
for e in &extra_entries {
let id = ledger.create_entry(e.date, "extra");
for (a, s, amt) in &e.lines {
ledger.add_line(id, *a, *s, Money::new(*amt, Currency::USD)).unwrap();
}
ledger.post_entry(id).unwrap();
}
for id in &ids {
ledger.reverse_entry(*id, NaiveDate::from_ymd_opt(2026, 12, 31).unwrap(), "rev").unwrap();
}
let after: Vec<String> = ids.iter().map(|id| {
serde_json::to_string(&serde_json::to_value(ledger.get_entry(*id).unwrap()).unwrap()).unwrap()
}).collect();
prop_assert_eq!(before, after);
}
/// A journal entry survives a JSON round-trip unchanged.
#[test]
fn prop_entry_json_roundtrip((chart, entries) in arb_ops()) {
let (ledger, ids) = build_ledger(&chart, &entries);
for id in ids {
let e = ledger.get_entry(id).unwrap();
let v = serde_json::to_value(e).unwrap();
let s = serde_json::to_string(&v).unwrap();
let v2: serde_json::Value = serde_json::from_str(&s).unwrap();
prop_assert_eq!(v, v2);
}
}
/// Depreciation schedules are exact: the lifetime total is precisely
/// cost minus salvage, book value plus accumulated always equals cost,
/// and amounts are never negative.
#[test]
fn prop_depreciation_schedule_exact(
cost_cents in 100u64..10_000_000u64,
salvage_cents in 0u64..5_000_000u64,
life in 1u32..360u32,
y in 2000i32..2027i32,
m in 1u32..=12u32,
d in 1u32..=28u32,
) {
let cost_cents = cost_cents.max(salvage_cents + 1);
let cost = Decimal::new(cost_cents as i64, 2);
let salvage = Decimal::new(salvage_cents as i64, 2);
let mut ledger = Ledger::new(Currency::USD);
let ca = ledger.add_account("1500", "Equipment", AccountType::Asset, None, false).unwrap();
let aa = ledger.add_account("1501", "Accum", AccountType::Asset, None, false).unwrap();
let ex = ledger.add_account("6100", "Depr", AccountType::Expense, None, false).unwrap();
let id = ledger.register_asset(NewAsset {
description: "prop".to_string(),
in_service_date: NaiveDate::from_ymd_opt(y, m, d).unwrap(),
cost: Money::new(cost, Currency::USD),
salvage_value: Money::new(salvage, Currency::USD),
useful_life_months: life,
cost_account: ca,
accum_depr_account: aa,
expense_account: ex,
}).unwrap();
let asset = ledger.get_asset(id).unwrap();
let far = asset.in_service_date.checked_add_months(chrono::Months::new(life + 24)).unwrap();
let sched = depreciation_schedule(asset, far).unwrap();
// The lifetime total is exactly cost minus salvage: never a penny off.
let total: Decimal = sched.iter().map(|l| l.amount).sum();
prop_assert_eq!(total, cost - salvage);
for l in &sched {
prop_assert!(l.amount >= Decimal::ZERO);
prop_assert_eq!(l.book_value + l.accumulated, cost);
}
// Accumulated is monotone non-decreasing.
for w in sched.windows(2) {
prop_assert!(w[1].accumulated >= w[0].accumulated);
}
// Book value at any date matches the schedule.
if let Some(last) = sched.last() {
prop_assert_eq!(scheduled_book_value(asset, far).unwrap(), last.book_value);
prop_assert_eq!(accumulated_scheduled_through(asset, far).unwrap(), last.accumulated);
}
}
}
// ---------------------------------------------------------------------------
// Member splits (periods::allocate): conservation, a cent of each exact
// share, fairness, and sign symmetry. Rank 2: laws, not recomputation.
// ---------------------------------------------------------------------------
/// 1 to 5 positive shares summing to exactly 1 (the last takes the rest).
fn arb_shares() -> impl Strategy<Value = Vec<Decimal>> {
prop::collection::vec(1u32..1000, 1..=5).prop_map(|weights| {
let total: u32 = weights.iter().sum();
let mut shares: Vec<Decimal> = weights
.iter()
.map(|w| Decimal::from(*w) / Decimal::from(total))
.collect();
let rest: Decimal = shares[..shares.len() - 1].iter().sum();
*shares.last_mut().unwrap() = Decimal::ONE - rest;
shares
})
}
proptest! {
#![proptest_config(ProptestConfig::with_cases(2000))]
#[test]
fn prop_split_parts_sum_to_the_whole_within_a_cent_of_each_share(
cents in -10_000_000_000i64..10_000_000_000, shares in arb_shares(),
) {
let amount = Decimal::new(cents, 2);
let parts = ledger_core::periods::allocate(amount, &shares);
prop_assert_eq!(parts.len(), shares.len());
prop_assert_eq!(parts.iter().copied().sum::<Decimal>(), amount);
for (p, s) in parts.iter().zip(&shares) {
prop_assert!((*p - amount * s).abs() < Decimal::new(1, 2), "{p} vs share {s}");
prop_assert!(p.scale() <= 2 || p.normalize().scale() <= 2, "{p} is not whole cents");
}
}
#[test]
fn prop_split_never_rounds_up_a_smaller_remainder_over_a_larger(
cents in 0i64..10_000_000_000, shares in arb_shares(),
) {
let amount = Decimal::new(cents, 2);
let parts = ledger_core::periods::allocate(amount, &shares);
let exact: Vec<Decimal> = shares.iter().map(|s| amount * s).collect();
let floor = |e: &Decimal| e.round_dp_with_strategy(2, rust_decimal::RoundingStrategy::ToZero);
for i in 0..parts.len() {
for j in 0..parts.len() {
let (ri, rj) = (exact[i] - floor(&exact[i]), exact[j] - floor(&exact[j]));
let (up_i, up_j) = (parts[i] > floor(&exact[i]), parts[j] > floor(&exact[j]));
if ri > rj {
prop_assert!(up_i || !up_j, "member {j} rounded up over member {i}");
}
}
}
}
#[test]
fn prop_split_of_a_loss_is_the_negated_split_of_the_gain(
cents in 0i64..10_000_000_000, shares in arb_shares(),
) {
let amount = Decimal::new(cents, 2);
let gain = ledger_core::periods::allocate(amount, &shares);
let loss = ledger_core::periods::allocate(-amount, &shares);
prop_assert_eq!(loss, gain.iter().map(|p| -*p).collect::<Vec<_>>());
}
}
/// A fiscal year start as the books may hold one: any month and day 2001
/// has (a new setting is the first of a month; replay takes any day every
/// year has, rules.md B-28).
fn arb_fiscal_start() -> impl Strategy<Value = (u32, u32)> {
(1u32..=12, 1u32..=31).prop_filter("a day 2001 has", |&(m, d)| {
NaiveDate::from_ymd_opt(2001, m, d).is_some()
})
}
/// A day in the books' calendar, 0001-01-01 through 9999-12-31.
fn arb_calendar_day() -> impl Strategy<Value = NaiveDate> {
let span = (ledger_core::calendar::LAST_DAY - ledger_core::calendar::FIRST_DAY).num_days();
prop_oneof![
// Near the edges as often as anywhere: year 1 under a July year
// starts in year 0 (clamped), 9999-12-31 is the last day.
(0i64..800).prop_map(|n| ledger_core::calendar::FIRST_DAY + chrono::Days::new(n as u64)),
(0i64..800).prop_map(|n| ledger_core::calendar::LAST_DAY - chrono::Days::new(n as u64)),
(0i64..=span).prop_map(|n| ledger_core::calendar::FIRST_DAY + chrono::Days::new(n as u64)),
]
}
proptest! {
#![proptest_config(ProptestConfig::with_cases(5000))]
/// rules.md B-30, PeriodDefaults INVARIANTS 1-5, for every fiscal
/// year start the books hold, every today in the calendar, and every
/// mix of given and omitted dates: the resolved dates are the given
/// ones or the defaults, never swapped; Err exactly when the resolved
/// start is after the resolved end, as InvalidDateRange when both
/// were given and StartAfterToday when the end was defaulted.
#[test]
fn prop_period_defaults_resolve(
md in arb_fiscal_start(),
today in arb_calendar_day(),
start in prop::option::of(arb_calendar_day()),
end in prop::option::of(arb_calendar_day()),
) {
use ledger_core::calendar::{FIRST_DAY, fiscal_year_start_on, period_defaults};
use chrono::Datelike;
let company = ledger_core::settings::Company {
fiscal_year_start: md,
..Default::default()
};
let p = period_defaults(&company, today);
let want_end = end.unwrap_or(today);
let fy = fiscal_year_start_on(md, want_end);
let want_start = start.unwrap_or(fy);
// The fiscal year start of the end: on or before it, md in its
// own year, and the next one after the end (unless year 0's,
// clamped to the calendar's first day).
prop_assert!(fy <= want_end && fy >= FIRST_DAY);
let next = NaiveDate::from_ymd_opt(fy.year() + 1, md.0, md.1).unwrap();
if (fy.month(), fy.day()) == md {
prop_assert!(want_end < next, "{fy} {want_end}");
let this = NaiveDate::from_ymd_opt(want_end.year(), md.0, md.1).unwrap();
prop_assert_eq!(fy, if this <= want_end { this } else {
NaiveDate::from_ymd_opt(want_end.year() - 1, md.0, md.1).unwrap()
});
} else {
// Clamped: the year began in year 0.
prop_assert_eq!(fy, FIRST_DAY);
prop_assert!(want_end < NaiveDate::from_ymd_opt(1, md.0, md.1).unwrap());
}
match p.statement(start, end) {
Ok((s, e)) => {
prop_assert!(want_start <= want_end);
prop_assert_eq!((s, e), (want_start, want_end));
}
Err(err) => {
prop_assert!(want_start > want_end);
let (s, e) = (want_start.to_string(), want_end.to_string());
let expected = if end.is_some() {
ledger_core::LedgerError::InvalidDateRange { start: s, end: e }
} else {
ledger_core::LedgerError::StartAfterToday { start: s, today: e }
};
prop_assert_eq!(err, expected);
}
}
prop_assert_eq!(p.statement(None, None), Ok(p.statement_range()));
prop_assert_eq!(p.as_of(None), today);
prop_assert_eq!(p.as_of(start), start.unwrap_or(today));
prop_assert_eq!(p.calendar_year(), today.year());
}
}
// ---------------------------------------------------------------------------
// P3: depreciation by the day through the final month (defect 22), and
// schedules spread by day (defect 9). Each law is checked against arithmetic
// written here, never read back from the code.
// ---------------------------------------------------------------------------
use chrono::Datelike;
use ledger_core::schedules::{amortization_plan, daily_plan, month_end};
use ledger_core::{AssetStatus, disposal_amounts};
mod p3 {
use super::*;
use chrono::Months;
/// A USD asset on a ledger, as a command registers it.
pub fn asset_on(
cost_cents: u64,
salvage_cents: u64,
life: u32,
start: NaiveDate,
) -> (Ledger, ledger_core::AssetId) {
let mut ledger = Ledger::new(Currency::USD);
let ca = ledger
.add_account("1500", "Equipment", AccountType::Asset, None, false)
.unwrap();
let aa = ledger
.add_account("1501", "Accum", AccountType::Asset, None, false)
.unwrap();
let ex = ledger
.add_account("6100", "Depr", AccountType::Expense, None, false)
.unwrap();
let id = ledger
.register_asset(NewAsset {
description: "prop".to_string(),
in_service_date: start,
cost: Money::new(Decimal::new(cost_cents as i64, 2), Currency::USD),
salvage_value: Money::new(Decimal::new(salvage_cents as i64, 2), Currency::USD),
useful_life_months: life,
cost_account: ca,
accum_depr_account: aa,
expense_account: ex,
})
.unwrap();
(ledger, id)
}
pub fn arb_day() -> impl Strategy<Value = NaiveDate> {
(2000i32..=2100, 1u32..=12, 1u32..=31).prop_map(|(y, m, d)| {
(1..=d)
.rev()
.find_map(|d| NaiveDate::from_ymd_opt(y, m, d))
.unwrap()
})
}
/// The days in `d`'s month.
pub fn days_in(d: NaiveDate) -> u32 {
month_end(d).day()
}
/// The last day of `d`'s month.
pub fn month_end_of(d: NaiveDate) -> NaiveDate {
month_end(d)
}
/// Before P3: the per-month running total in Decimal, rounded half away
/// from zero to the cent, at each month end of the life (the final
/// month's plug left out: it only ever set the life's end to cost −
/// salvage, as P3 does). A copy of assets.rs as it was, the oracle for
/// "no posted month moves".
pub fn legacy_month_ends(
depreciable: Decimal,
life: u32,
start: NaiveDate,
) -> Vec<(NaiveDate, Decimal)> {
let end = start.checked_add_months(Months::new(life)).unwrap();
let rate = depreciable / Decimal::from(life);
let mut out = Vec::new();
let mut exact = Decimal::ZERO;
let mut month = start.with_day(1).unwrap();
while month < end {
let next = month.checked_add_months(Months::new(1)).unwrap();
let held = (next.min(end) - start.max(month)).num_days();
exact += rate * Decimal::from(held) / Decimal::from(days_in(month));
out.push((
month_end(month),
exact.round_dp_with_strategy(
2,
rust_decimal::RoundingStrategy::MidpointAwayFromZero,
),
));
month = next;
}
out
}
/// The exact cumulative charge through the end of the life's `i`th
/// month (not the last), in cents, as a fraction: (numerator,
/// denominator) of D × (months held, each days held over days in the
/// month) / life, in whole MONTH_L-ths.
pub fn exact_through(
depreciable_cents: i128,
life: u32,
start: NaiveDate,
through: NaiveDate,
) -> (i128, i128) {
const L: i128 = 377_580;
let mut elapsed = 0i128;
let mut month = start.with_day(1).unwrap();
while month <= through {
let next = month.checked_add_months(Months::new(1)).unwrap();
let from = start.max(month);
let to = next.min(through.succ_opt().unwrap());
elapsed += i128::from((to - from).num_days() as i32) * (L / i128::from(days_in(month)));
month = next;
}
(depreciable_cents * elapsed, i128::from(life) * L)
}
}
proptest! {
#![proptest_config(ProptestConfig::with_cases(500))]
/// B-15 INVARIANT 5 (defect 22): accumulated depreciation never falls,
/// never exceeds cost − salvage, is 0 before the in-service date and
/// cost − salvage from the life's last day on; each schedule's lines
/// sum to its accumulated and none is negative. Sampled through: every
/// day of the first and final months and of two months between, a
/// dozen month ends, and the days around the life (P3 depreciation
/// review R9: stepping every day of 600-month lives is P12's).
#[test]
fn prop_depreciation_is_monotone_and_bounded(
cost_cents in 2u64..=100_000_000_000,
salvage_frac in 0u64..1000,
life in 1u32..=600,
start in p3::arb_day(),
picks in prop::collection::vec(0u32..600, 14),
) {
let salvage_cents = (cost_cents - 1) * salvage_frac / 1000;
let (ledger, id) = p3::asset_on(cost_cents, salvage_cents, life, start);
let a = ledger.get_asset(id).unwrap();
let dep = Decimal::new((cost_cents - salvage_cents) as i64, 2);
let end = start.checked_add_months(chrono::Months::new(life)).unwrap();
let last_day = end.pred_opt().unwrap();
let month_of = |k: u32| start.with_day(1).unwrap().checked_add_months(chrono::Months::new(k)).unwrap();
let mut days: Vec<NaiveDate> = vec![
start.pred_opt().unwrap(), start, last_day, end, end + chrono::Days::new(400),
];
let months = life;
for k in [0, months, picks[0] % (months + 1), picks[1] % (months + 1)] {
let m = month_of(k);
days.extend((0..p3::days_in(m)).map(|i| m + chrono::Days::new(u64::from(i))));
}
days.extend(picks[2..].iter().map(|&k| p3::month_end_of(month_of(k % (months + 1)))));
days.sort();
days.dedup();
let mut prev = Decimal::ZERO;
for day in days {
let s = depreciation_schedule(a, day).unwrap();
let acc = accumulated_scheduled_through(a, day).unwrap();
prop_assert_eq!(s.iter().map(|l| l.amount).sum::<Decimal>(), acc);
prop_assert!(s.iter().all(|l| l.amount >= Decimal::ZERO));
prop_assert!(acc >= prev, "{}: {} < {}", day, acc, prev);
prop_assert!(acc <= dep);
if day < start {
prop_assert_eq!(acc, Decimal::ZERO);
}
if day >= last_day {
prop_assert_eq!(acc, dep);
}
prev = acc;
}
}
/// Defect 22, reproduction B as a law: a disposed asset's schedule
/// shows exactly what its disposal computed, for any disposal day in or
/// after the life, and is unchanged through any later date.
#[test]
fn prop_disposed_schedule_equals_what_disposal_computed(
cost_cents in 2u64..=100_000_000_000,
salvage_frac in 0u64..1000,
life in 1u32..=600,
start in p3::arb_day(),
after in 0u64..20_000,
later in 0u64..1_000,
) {
let salvage_cents = (cost_cents - 1) * salvage_frac / 1000;
let (mut ledger, id) = p3::asset_on(cost_cents, salvage_cents, life, start);
let on = start + chrono::Days::new(after);
let proceeds = Money::new(Decimal::ONE, Currency::USD);
let want = disposal_amounts(ledger.get_asset(id).unwrap(), on, proceeds).unwrap();
if ledger_core::calendar::check(on).is_err() {
return Ok(());
}
ledger.dispose_asset(id, on, proceeds).unwrap();
let a = ledger.get_asset(id).unwrap();
let disposed = matches!(a.status, AssetStatus::Disposed { .. });
prop_assert!(disposed);
let full = depreciation_schedule(a, NaiveDate::MAX).unwrap();
prop_assert_eq!(full.last().map_or(Decimal::ZERO, |l| l.accumulated), want.accumulated);
prop_assert_eq!(
accumulated_scheduled_through(a, on + chrono::Days::new(later)).unwrap(),
want.accumulated
);
prop_assert_eq!(scheduled_book_value(a, on).unwrap(), want.book_value);
}
/// B-15 rule 3 (defect 22), against arithmetic written here: through
/// any day k of the life's final month (its d days), accumulated is C +
/// (D − C) × k / d rounded half up to the cent, C the exact charge of
/// the months before (D × each month's days held over its days, over
/// the life), D cost less salvage; on its last day exactly D. So the
/// final month is prorated by its days, never plugged.
#[test]
fn prop_the_final_month_takes_the_remainder_by_its_days(
cost_cents in 2u64..=100_000_000_000,
salvage_frac in 0u64..1000,
life in 1u32..=600,
start in p3::arb_day(),
) {
let salvage_cents = (cost_cents - 1) * salvage_frac / 1000;
let (ledger, id) = p3::asset_on(cost_cents, salvage_cents, life, start);
let a = ledger.get_asset(id).unwrap();
let dep = i128::from(cost_cents - salvage_cents);
let end = start.checked_add_months(chrono::Months::new(life)).unwrap();
let last_day = end.pred_opt().unwrap();
let final_first = last_day.with_day(1).unwrap().max(start);
let d = i128::from((end - final_first).num_days() as i32);
let (num, den) = p3::exact_through(dep, life, start, final_first.pred_opt().unwrap());
for k in 1..=d {
let through = final_first + chrono::Days::new((k - 1) as u64);
// C + (D − C) k / d = (num d + (D den − num) k) / (den d).
let (x, y) = (num * d + (dep * den - num) * k, den * d);
let want = if k == d {
dep
} else {
x / y + i128::from(2 * (x % y) >= y)
};
prop_assert_eq!(
accumulated_scheduled_through(a, through).unwrap(),
Decimal::new(want as i64, 2),
"{} of {}", through, last_day
);
}
}
/// B-15 INVARIANT 6 (P3 depreciation brief 2.3, R1, R8): through every
/// month end of the life, the schedule is the pre-P3 running total
/// (USD, a two-place currency), except where the exact cumulative is a
/// half-cent tie, which the old 28-digit Decimal quotients could round
/// either way: there the new figure is the exact one, half a cent up.
/// So no posted month moves, but a tie's cent.
#[test]
fn prop_month_end_schedules_match_the_legacy_arithmetic_except_half_cent_ties(
cost_cents in 2u64..=100_000_000_000,
salvage_frac in 0u64..1000,
life in 1u32..=600,
start in p3::arb_day(),
) {
let salvage_cents = (cost_cents - 1) * salvage_frac / 1000;
let (ledger, id) = p3::asset_on(cost_cents, salvage_cents, life, start);
let a = ledger.get_asset(id).unwrap();
let dep_cents = i128::from(cost_cents - salvage_cents);
let dep = Decimal::new(dep_cents as i64, 2);
let legacy = p3::legacy_month_ends(dep, life, start);
let new = depreciation_schedule(a, NaiveDate::MAX).unwrap();
prop_assert_eq!(legacy.len(), new.len());
let n = new.len();
for (i, ((at, old), line)) in legacy.iter().zip(&new).enumerate() {
if i + 1 == n {
// The final month: cost − salvage, then (the plug) as now.
prop_assert_eq!(line.accumulated, dep);
continue;
}
if line.accumulated != *old {
let (num, den) = p3::exact_through(dep_cents, life, start, *at);
// A half-cent tie: num/den = q + 1/2 exactly.
prop_assert_eq!(2 * (num % den), den, "{}: {} vs {}, not a tie", at, line.accumulated, old);
let up = Decimal::new(((num / den) + 1) as i64, 2);
prop_assert_eq!(line.accumulated, up);
prop_assert_eq!((line.accumulated - *old).abs(), Decimal::new(1, 2));
}
}
}
}
proptest! {
#![proptest_config(ProptestConfig::with_cases(2000))]
/// daily_plan INVARIANTS 1-3 (defect 9): the plan sums to the total
/// exactly, every amount is > 0, the dates are distinct month ends
/// (ascending) from the start's month through the last day's; from the
/// 1st with at least a cent a month, there are `months` postings, each
/// but the last within a cent of total/months, and the last takes the
/// rest.
#[test]
fn prop_daily_plan_sums_exactly_and_lands_on_month_ends(
total_cents in 1i64..=100_000_000_000,
start in p3::arb_day(),
months in 1u32..=600,
on_the_first in any::<bool>(),
) {
let start = if on_the_first { start.with_day(1).unwrap() } else { start };
let total = Money::new(Decimal::new(total_cents, 2), Currency::USD);
let plan = daily_plan(total, start, months).unwrap();
let last_day = start.checked_add_months(chrono::Months::new(months)).unwrap().pred_opt().unwrap();
prop_assert_eq!(plan.iter().map(|(_, a)| *a).sum::<Decimal>(), total.amount);
prop_assert!(plan.iter().all(|(_, a)| *a > Decimal::ZERO));
prop_assert!(plan.windows(2).all(|w| w[0].0 < w[1].0));
for (d, _) in &plan {
prop_assert_eq!(*d, month_end(*d));
prop_assert!(*d >= month_end(start) && *d <= month_end(last_day));
}
if on_the_first && total_cents >= i64::from(months) {
prop_assert_eq!(plan.len() as u32, months);
let even = total.amount / Decimal::from(months);
for (_, a) in &plan[..plan.len() - 1] {
prop_assert!((*a - even).abs() <= Decimal::new(1, 2), "{} vs {}", a, even);
}
let others: Decimal = plan[..plan.len() - 1].iter().map(|(_, a)| *a).sum();
prop_assert_eq!(plan.last().unwrap().1, total.amount - others);
}
}
/// The equal-months plan is unchanged forever (a posted month's replay
/// re-derives it): a copy of amortization_plan as it was before P3.
#[test]
fn prop_monthly_even_plan_is_unchanged(
total_cents in 1i64..=100_000_000_000,
start in p3::arb_day(),
months in 1u32..=600,
) {
let total = Decimal::new(total_cents, 2);
let each = (total / Decimal::from(months)).round_dp(2);
let first = start.with_day(1).unwrap();
let want: Vec<(NaiveDate, Decimal)> = (0..months)
.map(|i| {
let m = first.checked_add_months(chrono::Months::new(i)).unwrap();
let a = if i + 1 == months { total - each * Decimal::from(months - 1) } else { each };
(month_end(m), a)
})
.collect();
prop_assert_eq!(amortization_plan(total, start, months).unwrap(), want);
}
}
/// The half-cent tie the P3 depreciation review (R8) names: 1,000.03 over
/// 36 months from 2026-01-01. Through month 18 the exact figure is
/// 100,003 × 18/36 = 50,001.5 cents, a tie: P3 rounds it up, 500.02, and
/// month 19 (100,003 × 19/36 = 52,779.36 cents) is 527.79. The old
/// running total in 28-digit Decimal lands a hair under the tie and gives
/// 500.01, so that cent moves from month 19 to month 18 (the change-log
/// case); every other month end is the same, and the life ends at
/// 1,000.03 either way.
#[test]
fn a_half_cent_tie_rounds_up_exactly() {
let start = NaiveDate::from_ymd_opt(2026, 1, 1).unwrap();
let (ledger, id) = p3::asset_on(100_003, 0, 36, start);
let new = depreciation_schedule(ledger.get_asset(id).unwrap(), NaiveDate::MAX).unwrap();
assert_eq!(new[17].accumulated, Decimal::new(50_002, 2));
assert_eq!(new[18].accumulated, Decimal::new(52_779, 2));
assert_eq!(new[35].accumulated, Decimal::new(100_003, 2));
let old = p3::legacy_month_ends(Decimal::new(100_003, 2), 36, start);
let moved: Vec<usize> = (0..35)
.filter(|&i| old[i].1 != new[i].accumulated)
.collect();
assert_eq!(moved, [17], "only the tie moves");
assert_eq!(old[17].1, Decimal::new(50_001, 2));
}
proptest! {
#![proptest_config(ProptestConfig::with_cases(500))]
/// assets::run_amounts INVARIANT 1 (P3 review round 2: it had no test
/// but the viewer's): what it says a run of each month posts, and what
/// the books carry past the schedule then, is run_amount's for that
/// month, over any runs and reversals (each in its own month, a
/// reversal negative), through any month.
#[test]
fn prop_run_amounts_are_each_months_run_amount(
cost_cents in 2u64..=10_000_000,
life in 1u32..=24,
start in p3::arb_day(),
effects in prop::collection::vec((0u32..30, -500_000i64..=500_000), 0..8),
through in 0u32..36,
) {
let (ledger, id) = p3::asset_on(cost_cents, 0, life, start);
let a = ledger.get_asset(id).unwrap();
let first = start.with_day(1).unwrap();
let ym = |i: u32| {
let d = first.checked_add_months(chrono::Months::new(i)).unwrap();
(chrono::Datelike::year(&d), chrono::Datelike::month(&d))
};
let posted: Vec<ledger_core::PostedRun> = effects
.iter()
.map(|&(i, cents)| {
let (year, month) = ym(i);
ledger_core::PostedRun { year, month, amount: Decimal::new(cents, 2) }
})
.collect();
let rows = ledger_core::run_amounts(a, ym(through), &posted).unwrap();
prop_assert!(!rows.is_empty());
prop_assert_eq!((rows[0].0, rows[0].1), ym(0));
for (k, &(y, m, r)) in rows.iter().enumerate() {
prop_assert_eq!((y, m), ym(k as u32), "the months in turn");
let one = ledger_core::run_amount(a, y, m, &posted).unwrap();
prop_assert_eq!(r, one, "{}-{:02}", y, m);
}
}
}
fn arb_basis_rule() -> impl Strategy<Value = ledger_core::BasisRule> {
prop_oneof![
Just(ledger_core::BasisRule::Purchase),
Just(ledger_core::BasisRule::Carryover723),
Just(ledger_core::BasisRule::PersonalUseConversion),
]
}
proptest! {
#![proptest_config(ProptestConfig::with_cases(5000))]
/// kernel::basis_figures INVARIANTS 1-4 on Decimal money (rules.md
/// B-25): a purchase depreciates from cost and has no §723 basis or
/// built-in gain; a contribution's §723 basis is the adjusted basis,
/// its built-in gain fair market value less that basis, negative
/// exactly when the basis exceeds the value; carryover depreciates
/// from the basis, personal use from the lesser (at most each, equal
/// to one).
#[test]
fn prop_basis_figures_laws(
rule in arb_basis_rule(),
cost in 1i64..=100_000_000_000,
adjusted in 0i64..=100_000_000_000,
fmv in 0i64..=100_000_000_000,
) {
use ledger_core::BasisRule::*;
let (cost, adjusted, fmv) = (Decimal::new(cost, 2), Decimal::new(adjusted, 2), Decimal::new(fmv, 2));
let f = ledger_core::kernel::basis_figures(rule, cost, adjusted, fmv).unwrap();
match rule {
Purchase => {
prop_assert_eq!(f.depreciable_basis, cost);
prop_assert_eq!(f.tax_basis_723, None);
prop_assert_eq!(f.built_in_gain_704c, None);
}
Carryover723 | PersonalUseConversion => {
prop_assert_eq!(f.tax_basis_723, Some(adjusted));
let g = f.built_in_gain_704c.unwrap();
prop_assert_eq!(g, fmv - adjusted);
prop_assert_eq!(g < Decimal::ZERO, adjusted > fmv);
if rule == Carryover723 {
prop_assert_eq!(f.depreciable_basis, adjusted);
} else {
prop_assert!(f.depreciable_basis <= adjusted && f.depreciable_basis <= fmv);
prop_assert!(f.depreciable_basis == adjusted || f.depreciable_basis == fmv);
}
}
}
}
}