P3: depreciation straight-line by days through the final month, schedules by day (defects 22, 9, D-37)
The final month takes what the months before leave, spread over its days; a disposed asset stops on its disposal day. A skipped month is caught up in the month the run is made; a reversed run un-posts its month. New schedules spread by day; equal-months schedules must start on the 1st. Amounts round to the currency's own minor unit. Kani and Lean proofs; an exhaustive search over runs, reversals and disposals. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01T3gXMVt7XJPi6ens1or9Dj
This commit is contained in:
69 files changed
+10529
-841
No files matched your search
@@ -300,7 +300,7 @@ proptest! {
|
||||
}).unwrap();
|
||||
let asset = ledger.get_asset(id).unwrap();
|
||||
let far = asset.purchase_date.checked_add_months(chrono::Months::new(life + 24)).unwrap();
|
||||
let sched = depreciation_schedule(asset, far);
|
||||
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);
|
||||
@@ -314,8 +314,8 @@ proptest! {
|
||||
}
|
||||
// Book value at any date matches the schedule.
|
||||
if let Some(last) = sched.last() {
|
||||
prop_assert_eq!(book_value(asset, far), last.book_value);
|
||||
prop_assert_eq!(accumulated_depreciation(asset, far), last.accumulated);
|
||||
prop_assert_eq!(book_value(asset, far).unwrap(), last.book_value);
|
||||
prop_assert_eq!(accumulated_depreciation(asset, far).unwrap(), last.accumulated);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -471,3 +471,414 @@ proptest! {
|
||||
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(),
|
||||
purchase_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_depreciation(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_depreciation(a, on + chrono::Days::new(later)).unwrap(),
|
||||
want.accumulated
|
||||
);
|
||||
prop_assert_eq!(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_depreciation(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);
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user