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:
swaitsandClaude Opus 5.5 committed 2026-10-21 14:04:11 -06:00
1 parent 1304b9c0ad
commit b0a9fd8ad4
69 files changed
+10529 -841

No files matched your search

+13 -9
View File
@@ -70,7 +70,7 @@ fn schedule_sums_exactly_to_depreciable_amount() {
// Odd numbers chosen to not divide evenly: 100000 / 60.
let id = van_asset(&mut ledger, 100_000, cost, accum, expense);
let asset = ledger.get_asset(id).unwrap();
let sched = depreciation_schedule(asset, date(2031, 12, 31));
let sched = depreciation_schedule(asset, date(2031, 12, 31)).unwrap();
// 61 calendar months touched (partial first and last), 60 months' worth.
assert_eq!(sched.len(), 61);
let total: Decimal = sched.iter().map(|l| l.amount).sum();
@@ -94,7 +94,7 @@ fn partial_months_complement_to_whole() {
let (mut ledger, cost, accum, expense) = test_ledger();
let id = van_asset(&mut ledger, 100_000, cost, accum, expense);
let asset = ledger.get_asset(id).unwrap();
let sched = depreciation_schedule(asset, date(2031, 12, 31));
let sched = depreciation_schedule(asset, date(2031, 12, 31)).unwrap();
// Jan 2026 (17/31) + Jan 2031 (14/31) = exactly one month's charge.
let monthly = Decimal::from(100_000) / Decimal::from(60);
let monthly_rounded =
@@ -122,7 +122,7 @@ fn salvage_value_is_never_depreciated() {
})
.unwrap();
let asset = ledger.get_asset(id).unwrap();
let sched = depreciation_schedule(asset, date(2030, 12, 31));
let sched = depreciation_schedule(asset, date(2030, 12, 31)).unwrap();
let total: Decimal = sched.iter().map(|l| l.amount).sum();
assert_eq!(total, Decimal::from(45_000));
assert_eq!(sched.last().unwrap().book_value, Decimal::from(5_000));
@@ -133,11 +133,11 @@ fn book_value_tracks_accumulated() {
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 acc = accumulated_depreciation(asset, date(2026, 12, 31));
let acc = accumulated_depreciation(asset, date(2026, 12, 31)).unwrap();
// Jan 2026 prorated 17/31 of 1000 = 548.39, then 11 full months of 1000.
assert_eq!(acc, Decimal::new(54839, 2) + Decimal::from(11_000));
assert_eq!(
book_value(asset, date(2026, 12, 31)),
book_value(asset, date(2026, 12, 31)).unwrap(),
Decimal::from(60_000) - acc
);
}
@@ -281,9 +281,13 @@ fn schedule_through_purchase_date_covers_one_day() {
.unwrap();
let asset = ledger.get_asset(id).unwrap();
// Through the day before purchase: nothing.
assert!(depreciation_schedule(asset, date(2026, 1, 14)).is_empty());
assert!(
depreciation_schedule(asset, date(2026, 1, 14))
.unwrap()
.is_empty()
);
// Through the purchase date itself: one day of the first month.
let one_day = depreciation_schedule(asset, date(2026, 1, 15));
let one_day = depreciation_schedule(asset, date(2026, 1, 15)).unwrap();
assert_eq!(one_day.len(), 1);
assert_eq!(one_day[0].period, "2026-01");
// 62000/62 = 1000/month; 1/31 of that = 32.26.
@@ -302,7 +306,7 @@ fn van_schedule_is_the_hand_computed_one() {
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 sched = depreciation_schedule(asset, date(2032, 1, 1)).unwrap();
assert_eq!(sched.len(), 61);
let mut accumulated = Decimal::ZERO;
for (k, line) in sched.iter().enumerate() {
@@ -421,7 +425,7 @@ fn one_month_useful_life_is_valid() {
})
.unwrap();
let asset = ledger.get_asset(id).unwrap();
let sched = depreciation_schedule(asset, date(2026, 12, 31));
let sched = depreciation_schedule(asset, date(2026, 12, 31)).unwrap();
// A one-month life starting Mar 10 spans two calendar months.
assert_eq!(sched.len(), 2);
// March: 22/31 of 300 = 212.90.
File diff suppressed because it is too large. Load diff
+1
View File
@@ -1257,6 +1257,7 @@ fn id_allocators_predict_the_next_id_and_never_repeat() {
months: 12,
balance_account: c.equipment,
pl_account: c.rent,
convention: ledger_core::ScheduleConvention::MonthlyEven,
};
let mut seen = Vec::new();
for n in ["a", "b"] {
+92
View File
@@ -369,4 +369,96 @@ proptest! {
let ri = ti.and_then(kernel::reverse_target);
prop_assert_eq!(ri.map(as_dec), td.and_then(kernel::reverse_target));
}
/// The proration Kani proves at i64 is the engine's at i128, part for
/// part, on calendar-shaped parts (each month 28 to 31 days, 1 to all
/// of them) and on parts no calendar has, refusals included.
#[test]
fn prorate_agrees_at_i64_and_i128(
total in 0i64..=(1 << 30),
months in 1u32..=24,
parts in prop::collection::vec((28u32..=31, 0u32..=32), 1..=26),
tenths in any::<bool>(),
) {
let parts: Vec<kernel::MonthPart> = parts
.into_iter()
.map(|(month_days, days)| kernel::MonthPart { days, month_days })
.collect();
let unit = if tenths { 10 } else { 1 };
let mut oi = vec![0i64; parts.len()];
let mut ow = vec![0i128; parts.len()];
let ri = kernel::prorate_by_days(total, months, &parts, unit, &mut oi);
let rw = kernel::prorate_by_days(i128::from(total), months, &parts, i128::from(unit), &mut ow);
prop_assert_eq!(ri.is_some(), rw.is_some());
if ri.is_some() {
prop_assert_eq!(oi.iter().map(|&o| i128::from(o)).collect::<Vec<_>>(), ow);
}
}
/// The charge Kani proves at i32 (small totals, 1 or 2 months) is the
/// engine's at i128, through every day of a part, refusals included.
#[test]
fn prorated_through_agrees_at_i32_and_i128(
total in 0i32..=2_048,
months in 1u32..=2,
month_days in 28u32..=31,
days in 0u32..=32,
k in 0u32..=32,
before in 0i32..=800_000,
last in any::<bool>(),
tenths in any::<bool>(),
) {
let part = kernel::MonthPart { days, month_days };
let unit = if tenths { 10 } else { 1 };
let narrow = kernel::prorated_through(total, months, before, part, k, last, unit);
let wide = kernel::prorated_through(
i128::from(total), months, i128::from(before), part, k, last, i128::from(unit),
);
// At i32 a product past its range is refused; at i128 it isn't.
if let Some(n) = narrow {
prop_assert_eq!(Some(i128::from(n)), wide);
} else if wide.is_some() {
// Refused at i32 only when a product the kernel forms there
// overflows it.
let w = i128::from(months) * i128::from(kernel::MONTH_L);
let (t, b, k, d, u) = (
i128::from(total),
i128::from(before),
i128::from(k),
i128::from(days),
i128::from(unit),
);
let formed: Vec<i128> = if last {
let elapsed = b * d + (w - b) * k;
vec![b * d, (w - b) * k, elapsed, t * elapsed, w * d, w * d * u]
} else {
let elapsed = b + k * i128::from(kernel::MONTH_L / month_days);
vec![elapsed, t * elapsed, w * u]
};
prop_assert!(
formed.iter().any(|&p| p > i128::from(i32::MAX)),
"refused at i32 though nothing overflows it: {formed:?}"
);
}
}
/// catch_up and true_up agree at i64 and on Decimal (the engine's).
#[test]
fn catch_up_and_true_up_agree(
pairs in prop::collection::vec((arb_cents(), arb_cents()), 0..6),
) {
let (si, pi): (Vec<i64>, Vec<i64>) = pairs.iter().copied().unzip();
let sd: Vec<Decimal> = si.iter().copied().map(dec).collect();
let pd: Vec<Decimal> = pi.iter().copied().map(dec).collect();
prop_assert_eq!(
kernel::catch_up(&si, &pi).map(dec),
kernel::catch_up(&sd, &pd)
);
if let Some(&(t, p)) = pairs.first() {
prop_assert_eq!(
kernel::true_up(t, p).map(|(u, d)| (dec(u), dec(d))),
kernel::true_up(dec(t), dec(p))
);
}
}
}
+414 -3
View File
@@ -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);
}
}
}
+402 -6
View File
@@ -61,17 +61,19 @@ fn post(l: &mut Ledger, date: NaiveDate, dr: u64, cr: u64, amt: &str) {
#[test]
fn a_prepaid_expense_is_expensed_month_by_month() {
let mut b = books();
// A year of insurance, paid March 15: an asset, not an expense yet.
post(&mut b.l, d(3, 15), b.prepaid, b.cash, "1200");
// A year of insurance from March 1, paid that day: an asset, not an
// expense yet. Equal months, so it starts on the 1st (D-37).
post(&mut b.l, d(3, 1), b.prepaid, b.cash, "1200");
let id =
b.l.register_schedule(NewSchedule {
description: "Liability insurance".into(),
kind: ScheduleKind::PrepaidExpense,
total: usd("1200"),
start: d(3, 15),
start: d(3, 1),
months: 12,
balance_account: b.prepaid,
pl_account: b.insurance,
convention: ledger_core::ScheduleConvention::MonthlyEven,
})
.unwrap();
// Through May: March, April, May, at month end, 100 each.
@@ -113,17 +115,19 @@ fn a_prepaid_expense_is_expensed_month_by_month() {
#[test]
fn deferred_revenue_is_earned_to_the_cent() {
let mut b = books();
// Three months of a subscription collected up front.
post(&mut b.l, d(1, 5), b.cash, b.deferred, "1000");
// Three months of a subscription from January 1, collected up front.
// Equal months, so it starts on the 1st (D-37).
post(&mut b.l, d(1, 1), b.cash, b.deferred, "1000");
let id =
b.l.register_schedule(NewSchedule {
description: "Quarterly plan, Acme".into(),
kind: ScheduleKind::DeferredRevenue,
total: usd("1000"),
start: d(1, 5),
start: d(1, 1),
months: 3,
balance_account: b.deferred,
pl_account: b.revenue,
convention: ledger_core::ScheduleConvention::MonthlyEven,
})
.unwrap();
let plan: Vec<Decimal> =
@@ -158,6 +162,7 @@ fn schedules_respect_closed_periods_and_account_types() {
months: 12,
balance_account: b.prepaid,
pl_account: b.insurance,
convention: ledger_core::ScheduleConvention::MonthlyEven,
})
.unwrap();
assert!(matches!(
@@ -174,6 +179,7 @@ fn schedules_respect_closed_periods_and_account_types() {
months: 12,
balance_account: b.deferred,
pl_account: b.insurance,
convention: ledger_core::ScheduleConvention::MonthlyEven,
});
assert!(matches!(bad, Err(LedgerError::InvalidSchedule(_))));
}
@@ -276,6 +282,7 @@ fn schedule_length_is_one_to_six_hundred_months() {
months,
balance_account: b.prepaid,
pl_account: b.insurance,
convention: ledger_core::ScheduleConvention::MonthlyEven,
};
let chart = b.l.chart();
for ok in [1, 600] {
@@ -376,6 +383,7 @@ fn a_schedule_total_no_month_could_post_is_refused() {
months: 6,
balance_account: b.prepaid,
pl_account: b.insurance,
convention: ledger_core::ScheduleConvention::MonthlyEven,
};
let huge = new("46116860184273879.03");
let sub_cent = new("120.005");
@@ -405,6 +413,7 @@ fn a_refused_schedule_month_leaves_nothing_behind() {
months: 6,
balance_account: b.prepaid,
pl_account: b.insurance,
convention: ledger_core::ScheduleConvention::MonthlyEven,
})
.unwrap();
let before = b.l.clone();
@@ -415,3 +424,390 @@ fn a_refused_schedule_month_leaves_nothing_behind() {
b.l.differs_from(&before)
);
}
// ------------------------------------------- defect 9: schedules by the day
use ledger_core::ScheduleConvention;
fn new(
b: &Books,
total: Money,
start: NaiveDate,
months: u32,
convention: ScheduleConvention,
) -> NewSchedule {
NewSchedule {
description: "Liability insurance".into(),
kind: ScheduleKind::PrepaidExpense,
total,
start,
months,
balance_account: b.prepaid,
pl_account: b.insurance,
convention,
}
}
fn ymd(y: i32, m: u32, day: u32) -> NaiveDate {
NaiveDate::from_ymd_opt(y, m, day).unwrap()
}
/// Defect 9 (plan/served.md D-11, D-37): a new schedule spreads by the
/// day. Hand check: 1,200.00 of insurance from 2026-03-15 for 12 months
/// covers 2026-03-15 through 2027-03-14. March holds 17 of its 31 days:
/// 100.00 × 17/31 = 54.838 → 54.84. April through February are whole
/// months, 100.00 each (eleven). The last March holds 14 days and takes
/// what the others leave: 1,200.00 − 1,154.84 = 45.16 (= 100.00 × 14/31,
/// to the cent). Thirteen month ends.
#[test]
fn defect9_a_new_schedule_spreads_by_the_day() {
let mut b = books();
post(&mut b.l, d(3, 15), b.prepaid, b.cash, "1200");
let id =
b.l.register_schedule(new(
&b,
usd("1200.00"),
d(3, 15),
12,
ScheduleConvention::Daily,
))
.unwrap();
let s = b.l.get_schedule(id).unwrap();
assert_eq!(s.convention, ScheduleConvention::Daily);
let plan = s.plan().unwrap();
let mut want = vec![(d(3, 31), dec("54.84"))];
for m in 4..=12 {
want.push((ledger_core::schedules::month_end(d(m, 1)), dec("100.00")));
}
want.push((ymd(2027, 1, 31), dec("100.00")));
want.push((ymd(2027, 2, 28), dec("100.00")));
want.push((ymd(2027, 3, 31), dec("45.16")));
assert_eq!(plan, want);
assert_eq!(s.first_day(), d(3, 15));
assert_eq!(s.last_day(), Some(ymd(2027, 3, 14)));
assert_eq!(s.next().unwrap(), Some(d(3, 31)));
// Posted through June: the books carry the days used.
for (m, _) in s.due(d(6, 30)).unwrap() {
b.l.post_schedule_month(id, m).unwrap();
}
let pl = profit_and_loss_for_period(&b.l, d(3, 1), d(6, 30), None).unwrap();
assert_eq!(pl.total_expenses, dec("354.84"));
let s = b.l.get_schedule(id).unwrap();
assert_eq!(s.remaining().unwrap(), dec("845.16"));
assert_eq!(s.next().unwrap(), Some(d(7, 31)));
}
/// Defect 9 for deferred revenue: earned as the days pass, to the cent.
/// Hand check: 1,000.00 from 2026-01-05 for 3 months covers Jan 5 through
/// Apr 4. Weights: January 27/31, February and March whole, April the
/// rest. Cumulative at each month end: 1,000 × (27/31)/3 = 290.3226 →
/// 290.32; × (27/31 + 1)/3 = 623.6559 → 623.66; × (27/31 + 2)/3 = 956.9892
/// → 956.99; then 1,000.00. Months: 290.32, 333.34, 333.33, 43.01.
#[test]
fn defect9_deferred_revenue_daily_is_earned_to_the_cent() {
let mut b = books();
post(&mut b.l, d(1, 5), b.cash, b.deferred, "1000");
let id =
b.l.register_schedule(NewSchedule {
description: "Quarterly plan, a customer".into(),
kind: ScheduleKind::DeferredRevenue,
total: usd("1000"),
start: d(1, 5),
months: 3,
balance_account: b.deferred,
pl_account: b.revenue,
convention: ScheduleConvention::Daily,
})
.unwrap();
let plan: Vec<Decimal> =
b.l.get_schedule(id)
.unwrap()
.due(d(12, 31))
.unwrap()
.into_iter()
.map(|(_, a)| a)
.collect();
assert_eq!(
plan,
[dec("290.32"), dec("333.34"), dec("333.33"), dec("43.01")]
);
for (m, _) in b.l.get_schedule(id).unwrap().due(d(12, 31)).unwrap() {
b.l.post_schedule_month(id, m).unwrap();
}
// Earned through March: 956.99; still owed for April 1-4: 43.01.
let pl = profit_and_loss_for_period(&b.l, d(1, 1), d(3, 31), None).unwrap();
assert_eq!(pl.total_revenue, dec("956.99"));
let bs = balance_sheet_as_of(&b.l, d(3, 31)).unwrap();
assert_eq!(
bs.liabilities
.iter()
.find(|a| a.code == "2400")
.unwrap()
.amount,
dec("43.01")
);
let bs = balance_sheet_as_of(&b.l, d(4, 30)).unwrap();
assert!(bs.liabilities.is_empty(), "fully earned");
assert_eq!(b.l.get_schedule(id).unwrap().next().unwrap(), None);
}
/// Defect 9: schedules an earlier version registered (no convention in
/// the journal) stay equal months, mid-month start and all, exactly as
/// amortization_plan has always made them: replay re-derives every posted
/// month from it.
#[test]
fn defect9_legacy_schedules_stay_monthly_even() {
let mut b = books();
let id =
b.l.replay_register_schedule(new(
&b,
usd("1000"),
d(1, 5),
3,
ScheduleConvention::MonthlyEven,
))
.unwrap();
let s = b.l.get_schedule(id).unwrap();
assert_eq!(
s.plan().unwrap(),
ledger_core::schedules::amortization_plan(dec("1000"), d(1, 5), 3).unwrap()
);
assert_eq!(
s.plan().unwrap(),
[
(d(1, 31), dec("333.33")),
(d(2, 28), dec("333.33")),
(d(3, 31), dec("333.34"))
]
);
// It covers whole months: from the 1st of its first.
assert_eq!(s.first_day(), d(1, 1));
assert_eq!(s.last_day(), Some(d(3, 31)));
// The serialized ledger says nothing of a convention it never had.
let v = serde_json::to_value(s).unwrap();
assert!(v.get("convention").is_none());
assert!(v.get("postings").is_none());
}
/// D-37 (owner, 2026-10-20): a new equal-months schedule starts on the
/// 1st of a month, for prepaid expenses and deferred revenue alike, so it
/// never recognizes days it doesn't cover (revenue is never earned early,
/// ASC 606-10-25-23). Refused otherwise, naming the 1st and the daily
/// convention; replay keeps the ones an earlier version took.
#[test]
fn an_equal_months_schedule_starts_on_the_first() {
let mut b = books();
for kind in [ScheduleKind::PrepaidExpense, ScheduleKind::DeferredRevenue] {
let (balance, pl) = match kind {
ScheduleKind::PrepaidExpense => (b.prepaid, b.insurance),
ScheduleKind::DeferredRevenue => (b.deferred, b.revenue),
};
let mid = NewSchedule {
description: "x".into(),
kind,
total: usd("1200"),
start: d(3, 15),
months: 12,
balance_account: balance,
pl_account: pl,
convention: ScheduleConvention::MonthlyEven,
};
let before = b.l.clone();
match b.l.register_schedule(mid.clone()) {
Err(LedgerError::InvalidSchedule(m)) => {
assert!(m.contains("2026-03-01"), "{m}");
assert!(m.contains("daily"), "{m}");
assert!(m.contains("ASC 606-10-25-23"), "{m}");
}
other => panic!("{kind:?}: {other:?}"),
}
assert!(b.l.differs_from(&before).is_empty());
b.l.register_schedule(NewSchedule {
start: d(3, 1),
..mid.clone()
})
.unwrap();
b.l.replay_register_schedule(mid).unwrap();
}
}
/// R1 (P3 depreciation review): a daily schedule rounds to the currency's
/// minor unit, so in yen every month is whole yen. Hand check: ¥1,000 from
/// 2026-01-05 over 3 months: cumulative 290.32 → 290, 623.66 → 624, 956.99
/// → 957, then 1,000: ¥290, ¥334, ¥333, ¥43. An equal-months yen schedule
/// whose months aren't whole yen (¥333.33) is refused: it could never post.
#[test]
fn a_jpy_daily_schedule_posts_whole_yen() {
let mut l = Ledger::new(Currency::JPY);
let prepaid = l
.add_account("1300", "Prepaid", AccountType::Asset, None, false)
.unwrap();
let insurance = l
.add_account("6300", "Insurance", AccountType::Expense, None, false)
.unwrap();
let yen = |a: &str| Money::new(dec(a), Currency::JPY);
let schedule = |start, convention| NewSchedule {
description: "Insurance".into(),
kind: ScheduleKind::PrepaidExpense,
total: yen("1000"),
start,
months: 3,
balance_account: prepaid,
pl_account: insurance,
convention,
};
let id = l
.register_schedule(schedule(d(1, 5), ScheduleConvention::Daily))
.unwrap();
let plan: Vec<Decimal> = l
.get_schedule(id)
.unwrap()
.plan()
.unwrap()
.into_iter()
.map(|(_, a)| a)
.collect();
assert_eq!(plan, [dec("290"), dec("334"), dec("333"), dec("43")]);
for (m, _) in l.get_schedule(id).unwrap().due(d(12, 31)).unwrap() {
l.post_schedule_month(id, m).unwrap();
}
match l.register_schedule(schedule(d(1, 1), ScheduleConvention::MonthlyEven)) {
Err(LedgerError::InvalidSchedule(m)) => assert!(m.contains("333.33"), "{m}"),
other => panic!("{other:?}"),
}
}
/// R1 for a three-place currency: a daily schedule of 1,000.000 KWD from
/// 2026-01-05 over 3 months, in thousandths. Hand check: cumulative
/// 290.32258 → 290.323, 623.65591 → 623.656, 956.98925 → 956.989, then
/// 1,000.000: 290.323, 333.333, 333.333, 43.011.
#[test]
fn a_kwd_daily_schedule_rounds_to_the_fils() {
let kwd = |a: &str| Money::new(dec(a), Currency::new("KWD").unwrap());
let plan: Vec<Decimal> = ledger_core::schedules::daily_plan(kwd("1000.000"), d(1, 5), 3)
.unwrap()
.into_iter()
.map(|(_, a)| a)
.collect();
assert_eq!(
plan,
[
dec("290.323"),
dec("333.333"),
dec("333.333"),
dec("43.011")
]
);
assert!(plan.iter().all(|a| a.scale() == 3));
}
/// R3 (P3 depreciation review): a reversed schedule month is un-posted, so
/// it is due again; reversing the reversal posts it back; and once it has
/// posted again, re-instating the first posting is refused (one month is
/// never recognized twice).
#[test]
fn a_reversed_schedule_month_is_due_again() {
let mut b = books();
let id =
b.l.register_schedule(new(&b, usd("1200"), d(1, 1), 12, ScheduleConvention::Daily))
.unwrap();
let jan = b.l.post_schedule_month(id, d(1, 31)).unwrap();
let rev = b.l.reverse_entry(jan, d(1, 31), "wrong month").unwrap();
let s = b.l.get_schedule(id).unwrap();
assert!(!s.posted.contains(&d(1, 31)));
assert_eq!(s.next().unwrap(), Some(d(1, 31)));
assert_eq!(s.remaining().unwrap(), dec("1200"));
assert!(b.l.schedule_month_posted_before(id, d(1, 31)));
// Reversing the reversal re-instates January.
let back = b.l.reverse_entry(rev, d(1, 31), "it was right").unwrap();
assert!(b.l.get_schedule(id).unwrap().posted.contains(&d(1, 31)));
assert!(b.l.post_schedule_month(id, d(1, 31)).is_err());
// Reverse it again, post January again: then re-instating the first
// posting would recognize January twice.
b.l.reverse_entry(back, d(1, 31), "no, wrong").unwrap();
let again = b.l.post_schedule_month(id, d(1, 31)).unwrap();
assert!(b.l.in_effect(again));
let before = b.l.clone();
let rev_of_back =
b.l.posted_entries()
.find(|e| e.reversal_of() == Some(back))
.unwrap()
.id();
match b.l.reverse_entry(rev_of_back, d(1, 31), "re-instate") {
Err(LedgerError::InvalidSchedule(m)) => {
assert!(m.contains(&format!("entry {again}")), "{m}")
}
other => panic!("{other:?}"),
}
assert!(b.l.differs_from(&before).is_empty());
let pl = profit_and_loss_for_period(&b.l, d(1, 1), d(12, 31), None).unwrap();
assert_eq!(pl.total_expenses, dec("100"));
}
/// P3 review: a schedule month whose posting was reversed after the month
/// ended carried the month at its end; posting it again on its own date
/// would recognize it twice there (until the reversal), so it is left for
/// a human, said, never posted twice. Hand check: 1,200.00 daily from Jan
/// 1 over 12 months, January 100.00 (Jan 31), reversed Feb 3: posting
/// January again is refused, the hold names Feb 3; January's expense on
/// Jan 31 stays 100.00, and February posts as ever.
#[test]
fn a_schedule_month_reversed_after_it_ended_is_left_for_a_human() {
let mut b = books();
let id =
b.l.register_schedule(new(&b, usd("1200"), d(1, 1), 12, ScheduleConvention::Daily))
.unwrap();
let jan = b.l.post_schedule_month(id, d(1, 31)).unwrap();
b.l.reverse_entry(jan, d(2, 3), "wrong policy").unwrap();
let s = b.l.get_schedule(id).unwrap();
assert!(!s.posted.contains(&d(1, 31)));
assert_eq!(
b.l.schedule_month_reversed_after(id, d(1, 31)),
Some(d(2, 3))
);
let why = b.l.schedule_month_hold(id, d(1, 31)).unwrap();
assert!(why.contains("2026-02-03"), "{why}");
let before = b.l.clone();
match b.l.post_schedule_month(id, d(1, 31)) {
Err(LedgerError::InvalidSchedule(m)) => assert!(m.contains("twice"), "{m}"),
other => panic!("{other:?}"),
}
assert!(b.l.differs_from(&before).is_empty());
assert_eq!(b.l.schedule_month_hold(id, d(2, 28)), None);
b.l.post_schedule_month(id, d(2, 28)).unwrap();
let jan_pl = profit_and_loss_for_period(&b.l, d(1, 1), d(1, 31), None).unwrap();
assert_eq!(jan_pl.total_expenses, dec("100"));
}
/// P3 review: a month that rounds to nothing is never a posting (no entry
/// carries a zero line, P3 depreciation review R10). Hand check, daily:
/// 0.02 from Jan 1 over 3 months: the cumulatives are 0.0066 → 0.01,
/// 0.0133 → 0.01, then 0.02, so January 0.01, February nothing (left out
/// of the plan), March 0.01. Equal months: 0.01 over 3 months is 0.00,
/// 0.00 and 0.01; only March is ever due, and it is the next.
#[test]
fn a_month_that_rounds_to_nothing_is_never_a_posting() {
let mut b = books();
let id =
b.l.register_schedule(new(&b, usd("0.02"), d(1, 1), 3, ScheduleConvention::Daily))
.unwrap();
let s = b.l.get_schedule(id).unwrap();
assert_eq!(
s.plan().unwrap(),
[(d(1, 31), dec("0.01")), (d(3, 31), dec("0.01"))]
);
let id =
b.l.register_schedule(new(
&b,
usd("0.01"),
d(1, 1),
3,
ScheduleConvention::MonthlyEven,
))
.unwrap();
let s = b.l.get_schedule(id).unwrap();
assert_eq!(s.plan().unwrap().len(), 3);
assert_eq!(s.due(d(3, 31)).unwrap(), [(d(3, 31), dec("0.01"))]);
assert_eq!(s.next().unwrap(), Some(d(3, 31)));
assert_eq!(s.remaining().unwrap(), dec("0.01"));
}
+35 -3
View File
@@ -853,8 +853,8 @@ fn register_accumulated_mid_month_follows_the_schedule() {
let (l, _, laptop, server) = register_fixture();
let on = date(2026, 5, 17);
let r = fixed_asset_register(&l, on).unwrap();
let la = accumulated_depreciation(l.get_asset(laptop).unwrap(), on);
let sa = accumulated_depreciation(l.get_asset(server).unwrap(), on);
let la = accumulated_depreciation(l.get_asset(laptop).unwrap(), on).unwrap();
let sa = accumulated_depreciation(l.get_asset(server).unwrap(), on).unwrap();
assert_eq!(r.rows[0].accumulated, la);
assert_eq!(r.rows[1].accumulated, sa);
assert_eq!(r.rows[0].book_value, d("12000.00") - la);
@@ -898,7 +898,7 @@ fn register_leaves_out_assets_not_yet_in_service() {
assert_eq!(ids, vec![laptop, server, chair]);
assert_eq!(
r.rows[2].accumulated,
accumulated_depreciation(l.get_asset(chair).unwrap(), date(2026, 6, 1))
accumulated_depreciation(l.get_asset(chair).unwrap(), date(2026, 6, 1)).unwrap()
);
// Before anything: empty and zero.
let r = fixed_asset_register(&l, date(2025, 12, 31)).unwrap();
@@ -1029,3 +1029,35 @@ fn register_of_no_assets_is_empty() {
assert_eq!(r.unposted_depreciation(), Decimal::ZERO);
assert_eq!(r.unregistered_cost(), Decimal::ZERO);
}
/// Defect 22 in the register (INVARIANT 2): an asset disposed of later in
/// the same month as `as_of` is held on `as_of`, its accumulated
/// depreciation prorated by days through `as_of`, never plugged to the
/// whole. Hand check: the 12,000.00 laptop (12 months from 2026-01-01),
/// disposed of 2026-06-20; on 2026-06-10, five months of 1,000.00 and
/// June's 10/30: 5,333.33, book value 6,666.67. Before P3 the disposed
/// schedule ended in a plug, so the register showed 12,000.00 and 0.00.
/// The same in the life's final month: on 2026-12-15, 11,483.87.
#[test]
fn register_as_of_a_day_before_a_same_month_disposal_is_prorated() {
let (mut l, _, laptop, _) = register_fixture();
l.dispose_asset(laptop, date(2026, 6, 20), usd("5000.00"))
.unwrap();
let r = fixed_asset_register(&l, date(2026, 6, 10)).unwrap();
assert_eq!(r.rows[0].disposed, None);
assert_eq!(r.rows[0].accumulated, d("5333.33"));
assert_eq!(r.rows[0].book_value, d("6666.67"));
assert_totals_are_sums(&r);
// On the disposal day it is gone: no figures, in no total.
let r = fixed_asset_register(&l, date(2026, 6, 20)).unwrap();
assert_eq!(r.rows[0].accumulated, Decimal::ZERO);
assert!(r.rows[0].disposed.is_some());
let (l, _, laptop, _) = register_fixture();
let r = fixed_asset_register(&l, date(2026, 12, 15)).unwrap();
let row = r.rows.iter().find(|x| x.asset_id == laptop).unwrap();
assert_eq!(
(row.accumulated, row.book_value),
(d("11483.87"), d("516.13"))
);
}
+276 -24
View File
@@ -279,7 +279,7 @@ const PLAN_CASH: AccountId = 100;
#[test]
fn full_months_each_take_one_twelfth_b15() {
let a = asset("12000.00", "0.00", 12, date(2026, 1, 1));
let s = depreciation_schedule(&a, date(2026, 12, 31));
let s = depreciation_schedule(&a, date(2026, 12, 31)).unwrap();
assert_eq!(s.len(), 12);
for (i, line) in s.iter().enumerate() {
let n = i as i64 + 1;
@@ -296,7 +296,7 @@ fn full_months_each_take_one_twelfth_b15() {
#[test]
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));
let s = depreciation_schedule(&a, date(2026, 6, 15)).unwrap();
assert_eq!(s.len(), 6);
assert_eq!(s.last().unwrap().period, "2026-06");
assert_eq!(s.last().unwrap().amount, d("500.00"));
@@ -309,7 +309,7 @@ fn schedule_through_a_mid_month_date_prorates_that_month_by_days() {
#[test]
fn schedule_clamps_to_end_of_life() {
let a = asset("12000.00", "0.00", 12, date(2026, 1, 1));
let s = depreciation_schedule(&a, date(2030, 12, 31));
let s = depreciation_schedule(&a, date(2030, 12, 31)).unwrap();
let total: Decimal = s.iter().map(|l| l.amount).sum();
assert_eq!(total, d("12000.00"));
assert_eq!(s.iter().filter(|l| !l.amount.is_zero()).count(), 12);
@@ -327,7 +327,7 @@ fn schedule_clamps_to_end_of_life() {
#[test]
fn partial_first_and_last_months_make_one_full_month_b15() {
let a = asset("620.00", "0.00", 2, date(2026, 1, 15));
let s = depreciation_schedule(&a, date(2027, 12, 31));
let s = depreciation_schedule(&a, date(2027, 12, 31)).unwrap();
let got: Vec<(&str, Decimal, Decimal, Decimal)> = s
.iter()
.map(|l| (l.period.as_str(), l.amount, l.accumulated, l.book_value))
@@ -352,7 +352,7 @@ fn partial_first_and_last_months_make_one_full_month_b15() {
#[test]
fn last_month_trues_up_unequal_month_lengths_b15() {
let a = asset("1030.00", "100.00", 3, date(2026, 1, 15));
let s = depreciation_schedule(&a, date(2027, 12, 31));
let s = depreciation_schedule(&a, date(2027, 12, 31)).unwrap();
let got: Vec<(&str, Decimal, Decimal, Decimal)> = s
.iter()
.map(|l| (l.period.as_str(), l.amount, l.accumulated, l.book_value))
@@ -373,13 +373,13 @@ fn last_month_trues_up_unequal_month_lengths_b15() {
#[test]
fn salvage_value_is_never_depreciated_b15() {
let a = asset("5000.00", "1000.00", 4, date(2026, 3, 1));
let s = depreciation_schedule(&a, date(2026, 6, 30));
let s = depreciation_schedule(&a, date(2026, 6, 30)).unwrap();
let amounts: Vec<Decimal> = s.iter().map(|l| l.amount).collect();
assert_eq!(amounts, vec![d("1000.00"); 4]);
assert_eq!(s.last().unwrap().book_value, d("1000.00"));
assert_eq!(book_value(&a, date(2040, 1, 31)), d("1000.00"));
assert_eq!(book_value(&a, date(2040, 1, 31)).unwrap(), d("1000.00"));
assert_eq!(
accumulated_depreciation(&a, date(2040, 1, 31)),
accumulated_depreciation(&a, date(2040, 1, 31)).unwrap(),
d("4000.00")
);
}
@@ -390,7 +390,7 @@ fn salvage_value_is_never_depreciated_b15() {
#[test]
fn uneven_split_rounds_to_cents_and_sums_exactly() {
let a = asset("1000.00", "0.00", 3, date(2026, 1, 1));
let s = depreciation_schedule(&a, date(2026, 3, 31));
let s = depreciation_schedule(&a, date(2026, 3, 31)).unwrap();
assert_eq!(s.len(), 3);
for l in &s {
assert!(
@@ -412,9 +412,16 @@ fn uneven_split_rounds_to_cents_and_sums_exactly() {
#[test]
fn nothing_depreciates_before_purchase() {
let a = asset("12000.00", "0.00", 12, date(2026, 1, 1));
assert!(depreciation_schedule(&a, date(2025, 12, 31)).is_empty());
assert_eq!(accumulated_depreciation(&a, date(2025, 12, 31)), d("0"));
assert_eq!(book_value(&a, date(2025, 12, 31)), d("12000.00"));
assert!(
depreciation_schedule(&a, date(2025, 12, 31))
.unwrap()
.is_empty()
);
assert_eq!(
accumulated_depreciation(&a, date(2025, 12, 31)).unwrap(),
d("0")
);
assert_eq!(book_value(&a, date(2025, 12, 31)).unwrap(), d("12000.00"));
}
/// Oracle: interface docs "accumulated through as_of" and "book value =
@@ -423,10 +430,205 @@ fn nothing_depreciates_before_purchase() {
fn accumulated_and_book_value_mid_life() {
let a = asset("12000.00", "0.00", 12, date(2026, 1, 1));
assert_eq!(
accumulated_depreciation(&a, date(2026, 6, 30)),
accumulated_depreciation(&a, date(2026, 6, 30)).unwrap(),
d("6000.00")
);
assert_eq!(book_value(&a, date(2026, 6, 30)), d("6000.00"));
assert_eq!(book_value(&a, date(2026, 6, 30)).unwrap(), d("6000.00"));
}
// ------------------------------- defect 22: the final month, disposals
/// Defect 22, reproduction A (plan/served.md P3; P0 review round 4): a
/// schedule through a day of the life's final month is prorated by days,
/// never plugged to the whole. Hand check: 12,000.00 over 12 months from
/// 2026-01-01; the life ends 2027-01-01, so December is its final month.
/// Through Dec 15: the eleven months before, 11,000.00, plus December's
/// 1,000.00 × 15/31 = 483.870 → 483.87; accumulated 11,483.87, book value
/// 516.13. Before P3 December plugged to 1,000.00 (book value 0.00).
#[test]
fn defect22_depreciation_through_mid_final_month_is_prorated_not_plugged() {
let a = asset("12000.00", "0.00", 12, date(2026, 1, 1));
let s = depreciation_schedule(&a, date(2026, 12, 15)).unwrap();
let dec = s.last().unwrap();
assert_eq!(dec.period, "2026-12");
assert_eq!(dec.amount, d("483.87"));
assert_eq!(dec.accumulated, d("11483.87"));
assert_eq!(dec.book_value, d("516.13"));
assert_eq!(book_value(&a, date(2026, 12, 15)).unwrap(), d("516.13"));
// Through its last day, the whole of it.
let s = depreciation_schedule(&a, date(2026, 12, 31)).unwrap();
assert_eq!(
(s.last().unwrap().amount, s.last().unwrap().accumulated),
(d("1000.00"), d("12000.00"))
);
}
/// Defect 22, reproduction D: the month-length drift of a mid-month life
/// is spread over the final month's days, not charged on one. Hand check:
/// 1,030.00 − 100.00 = 930.00 over 3 months from 2026-01-15; the life ends
/// 2026-04-15, so April holds days 1-14. January 17/31 of 310.00 = 170.00,
/// February and March 310.00 each: 790.00 before April, 140.00 left for
/// its 14 days, 10.00 a day (January's daily rate, 310.00/31). Through
/// Apr 13: April 130.00, accumulated 920.00, book value 110.00. Through
/// Apr 14, the life's last day: April 140.00, accumulated 930.00.
#[test]
fn defect22_final_month_drift_is_spread_by_days() {
let a = asset("1030.00", "100.00", 3, date(2026, 1, 15));
let s = depreciation_schedule(&a, date(2026, 4, 13)).unwrap();
let apr = s.last().unwrap();
assert_eq!(
(
apr.period.as_str(),
apr.amount,
apr.accumulated,
apr.book_value
),
("2026-04", d("130.00"), d("920.00"), d("110.00"))
);
let s = depreciation_schedule(&a, date(2026, 4, 14)).unwrap();
let apr = s.last().unwrap();
assert_eq!(
(apr.amount, apr.accumulated, apr.book_value),
(d("140.00"), d("930.00"), d("100.00"))
);
// Past the life: nothing more.
assert_eq!(
depreciation_schedule(&a, date(2030, 1, 1)).unwrap().len(),
4
);
}
/// Defect 22, reproduction B: a disposed asset's schedule stops at its
/// disposal day (held, inclusive) and shows what the disposal took. Hand
/// check: 12,000.00 over 12 months from 2026-01-01, disposed of
/// 2026-06-15: five months of 1,000.00, then June's 15 of 30 days, 500.00;
/// accumulated 5,500.00, book value 6,500.00, the gain on 7,000.00 500.00.
/// Before P3 the disposed schedule plugged June to 7,000.00 (accumulated
/// 12,000.00, book value 0.00).
#[test]
fn defect22_a_disposed_assets_schedule_stops_at_its_disposal_day() {
let (mut l, accts) = fixture();
let id = l.register_asset(new_asset(&accts)).unwrap();
let before =
disposal_amounts(l.get_asset(id).unwrap(), date(2026, 6, 15), usd("7000.00")).unwrap();
assert_eq!(
(before.accumulated, before.book_value, before.gain_loss),
(d("5500.00"), d("6500.00"), d("500.00"))
);
l.dispose_asset(id, date(2026, 6, 15), usd("7000.00"))
.unwrap();
let a = l.get_asset(id).unwrap();
let s = depreciation_schedule(a, NaiveDate::MAX).unwrap();
let jun = s.last().unwrap();
assert_eq!(
(
jun.period.as_str(),
jun.amount,
jun.accumulated,
jun.book_value
),
("2026-06", d("500.00"), d("5500.00"), d("6500.00"))
);
assert_eq!(jun.accumulated, before.accumulated);
assert_eq!(
accumulated_depreciation(a, NaiveDate::MAX).unwrap(),
d("5500.00")
);
// A through-date before the disposal is unchanged by it.
assert_eq!(
accumulated_depreciation(a, date(2026, 5, 31)).unwrap(),
d("5000.00")
);
// Disposed of on its in-service day: one day held, 1,000.00 / 31 =
// 32.258 → 32.26.
let (mut l, accts) = fixture();
let id = l.register_asset(new_asset(&accts)).unwrap();
l.dispose_asset(id, date(2026, 1, 1), usd("0.00")).unwrap();
let s = depreciation_schedule(l.get_asset(id).unwrap(), NaiveDate::MAX).unwrap();
assert_eq!(s.len(), 1);
assert_eq!((s[0].amount, s[0].accumulated), (d("32.26"), d("32.26")));
}
/// R1 (P3 depreciation review): depreciation rounds to the currency's own
/// minor unit. ¥100,000 over 12 months from 2026-01-01: each month's
/// cumulative 100,000 × m / 12, rounded half up to the yen (8,333.33 →
/// 8,333; 16,666.67 → 16,667; 25,000; ...), so months alternate 8,333 and
/// 8,334 and sum to 100,000. Every one is whole yen, so it can post (an
/// entry refuses a fraction of the minor unit, B-3).
#[test]
fn jpy_depreciation_posts_whole_yen() {
let mut a = asset("100000", "0", 12, date(2026, 1, 1));
a.cost = Money::new(d("100000"), Currency::JPY);
a.salvage_value = Money::new(d("0"), Currency::JPY);
let s = depreciation_schedule(&a, date(2026, 12, 31)).unwrap();
let got: Vec<Decimal> = s.iter().map(|l| l.amount).collect();
let want: Vec<Decimal> = [
8333, 8334, 8333, 8333, 8334, 8333, 8333, 8334, 8333, 8333, 8334, 8333,
]
.into_iter()
.map(Decimal::from)
.collect();
assert_eq!(got, want);
assert!(s.iter().all(|l| l.amount.normalize().scale() == 0));
assert_eq!(s.last().unwrap().accumulated, d("100000"));
// And the ledger posts each month.
let mut l = Ledger::new(Currency::JPY);
let exp = l
.add_account("6200", "Depreciation", AccountType::Expense, None, false)
.unwrap();
let acc = l
.add_account("1510", "Accumulated", AccountType::Asset, None, false)
.unwrap();
for line in &s {
let e = l
.try_create_entry(date(2026, 12, 31), "Depreciation")
.unwrap();
let yen = Money::new(line.amount, Currency::JPY);
l.add_line(e, exp, Side::Debit, yen).unwrap();
l.add_line(e, acc, Side::Credit, yen).unwrap();
l.post_entry(e).unwrap();
}
}
/// The run's catch-up rule (plan/served.md P3, settled in design: a month
/// skipped is caught up in the month the run is made). Hand check, the
/// same 12,000.00 asset: January run (1,000.00), February skipped; a March
/// run posts February and March, 2,000.00; a February run after it posts
/// nothing; with January's run reversed later, a January run posts its
/// 1,000.00 back; a run after the life posts what is left.
#[test]
fn a_skipped_month_is_caught_up_in_the_month_run() {
use ledger_core::{PostedRun, run_amount};
let a = asset("12000.00", "0.00", 12, date(2026, 1, 1));
let run = |y, m, amount: &str| PostedRun {
year: y,
month: m,
amount: d(amount),
};
let jan = run(2026, 1, "1000.00");
let mar = run_amount(&a, 2026, 3, &[jan]).unwrap();
assert_eq!((mar.amount, mar.scheduled), (d("2000.00"), d("1000.00")));
let mar = run(2026, 3, "2000.00");
assert_eq!(run_amount(&a, 2026, 2, &[jan, mar]).unwrap().amount, d("0"));
// January reversed: posted is March's 2,000.00 alone.
assert_eq!(
run_amount(&a, 2026, 1, &[mar]).unwrap().amount,
d("1000.00")
);
// Nothing posted, a run in 2027: the whole life.
assert_eq!(run_amount(&a, 2027, 6, &[]).unwrap().amount, d("12000.00"));
assert_eq!(
run_amount(&a, 2027, 6, &[run(2026, 12, "12000.00")])
.unwrap()
.amount,
d("0")
);
// Before the asset was in service: refused.
assert!(matches!(
run_amount(&a, 2025, 12, &[]),
Err(LedgerError::InvalidDepreciationPeriod(_))
));
}
// ------------------------------------------------------------- disposal
@@ -1109,7 +1311,7 @@ proptest! {
fn prop_full_schedule_laws(a in arb_asset()) {
let cost = a.cost.amount;
let salvage = a.salvage_value.amount;
let s = depreciation_schedule(&a, date(2050, 12, 31));
let s = depreciation_schedule(&a, date(2050, 12, 31)).unwrap();
prop_assert!(!s.is_empty());
let total: Decimal = s.iter().map(|l| l.amount).sum();
prop_assert_eq!(total, cost - salvage);
@@ -1139,17 +1341,17 @@ proptest! {
/// one, and nothing is depreciated before purchase.
#[test]
fn prop_point_queries_agree_with_schedule(a in arb_asset()) {
let full = depreciation_schedule(&a, date(2050, 12, 31));
let full = depreciation_schedule(&a, date(2050, 12, 31)).unwrap();
for (i, l) in full.iter().enumerate() {
let end = period_end(&l.period);
prop_assert_eq!(accumulated_depreciation(&a, end), l.accumulated);
prop_assert_eq!(book_value(&a, end), l.book_value);
let prefix = depreciation_schedule(&a, end);
prop_assert_eq!(accumulated_depreciation(&a, end).unwrap(), l.accumulated);
prop_assert_eq!(book_value(&a, end).unwrap(), l.book_value);
let prefix = depreciation_schedule(&a, end).unwrap();
prop_assert_eq!(&prefix[..], &full[..=i]);
}
let before = a.purchase_date.pred_opt().unwrap();
prop_assert_eq!(accumulated_depreciation(&a, before), Decimal::ZERO);
prop_assert_eq!(book_value(&a, before), a.cost.amount);
prop_assert_eq!(accumulated_depreciation(&a, before).unwrap(), Decimal::ZERO);
prop_assert_eq!(book_value(&a, before).unwrap(), a.cost.amount);
}
/// Oracle: B-15 partial months by days + interface "the last month absorbs
@@ -1167,7 +1369,7 @@ proptest! {
let mut a = asset("1.00", "0.00", life, p);
a.salvage_value = Money::new(Decimal::new(salvage, 2), Currency::USD);
a.cost = Money::new(Decimal::new(salvage + m * i64::from(life), 2), Currency::USD);
let s = depreciation_schedule(&a, date(2050, 12, 31));
let s = depreciation_schedule(&a, date(2050, 12, 31)).unwrap();
prop_assert_eq!(s.len(), life as usize + 1);
for l in &s[1..s.len() - 1] {
prop_assert_eq!(l.amount, monthly);
@@ -1187,9 +1389,9 @@ proptest! {
let when = month_end(a.purchase_date.checked_add_months(Months::new(offset)).unwrap());
let pr = Decimal::new(proceeds, 2);
let got = disposal_amounts(&a, when, Money::new(pr, Currency::USD)).unwrap();
prop_assert_eq!(got.accumulated, accumulated_depreciation(&a, when));
prop_assert_eq!(got.accumulated, accumulated_depreciation(&a, when).unwrap());
prop_assert_eq!(got.book_value, a.cost.amount - got.accumulated);
prop_assert_eq!(got.book_value, book_value(&a, when));
prop_assert_eq!(got.book_value, book_value(&a, when).unwrap());
prop_assert_eq!(got.gain_loss, pr - got.book_value);
prop_assert!(got.book_value >= a.salvage_value.amount);
}
@@ -1305,3 +1507,53 @@ proptest! {
prop_assert_eq!(balance_of(&l, "1000"), d("10000.00") - cost);
}
}
/// P3 review: what the books carry is read entry by entry, each in its own
/// month (depreciation_effects): a run adds its amount in its month, its
/// reversal takes it away in the reversal's month, a reversal of that
/// reversal adds it back in its own. Hand check: January's run (1,000.00,
/// Jan 31) reversed on Feb 2: +1,000.00 in January, -1,000.00 in February,
/// so a January run posts nothing (Jan 31 carried January) and a February
/// one 2,000.00 (the schedule through Feb 28 less the 0.00 then carried).
/// Re-instated on Mar 3: +1,000.00 in March too.
#[test]
fn depreciation_effects_read_each_entry_in_its_month() {
use ledger_core::{PostedRun, depreciation_effects, run_amount};
let (mut l, a) = fixture();
let id = l.register_asset(new_asset(&a)).unwrap();
let e = l
.try_create_entry(date(2026, 1, 31), "Depreciation")
.unwrap();
l.add_line(e, a.depr_expense, Side::Debit, usd("1000.00"))
.unwrap();
l.add_line(e, a.accum, Side::Credit, usd("1000.00"))
.unwrap();
l.post_entry(e).unwrap();
let rev = l.reverse_entry(e, date(2026, 2, 2), "undo").unwrap();
let runs = [(e, d("1000.00"))];
let fx = |y, m, amount: &str| PostedRun {
year: y,
month: m,
amount: d(amount),
};
let effects = depreciation_effects(&l, &runs);
assert_eq!(effects, [fx(2026, 1, "1000.00"), fx(2026, 2, "-1000.00")]);
let asset = l.get_asset(id).unwrap().clone();
assert_eq!(
run_amount(&asset, 2026, 1, &effects).unwrap().amount,
d("0")
);
assert_eq!(
run_amount(&asset, 2026, 2, &effects).unwrap().amount,
d("2000.00")
);
l.reverse_entry(rev, date(2026, 3, 3), "redo").unwrap();
assert_eq!(
depreciation_effects(&l, &runs),
[
fx(2026, 1, "1000.00"),
fx(2026, 2, "-1000.00"),
fx(2026, 3, "1000.00")
]
);
}
+139
View File
@@ -100,6 +100,7 @@ fn dates_outside_the_calendar_are_refused_everywhere() {
months,
balance_account: b.prepaid,
pl_account: b.expense,
convention: ledger_core::ScheduleConvention::MonthlyEven,
};
for (start, months) in [
(d(9999, 12, 15), 2),
@@ -175,6 +176,7 @@ fn replayed_dates_outside_the_calendar_are_accepted() {
months,
balance_account: b.prepaid,
pl_account: b.expense,
convention: ledger_core::ScheduleConvention::MonthlyEven,
};
for (start, months) in [
(d(9999, 12, 15), 2),
@@ -789,3 +791,140 @@ fn the_bound_is_the_line_limit_itself() {
LedgerError::AmountOutOfRange
);
}
// ------------------------------------------------- P3: assets, schedules
/// The equipment accounts an asset needs, on these books.
fn asset_accounts(b: &mut Books) -> (AccountId, AccountId, AccountId) {
let mut add = |code: &str, ty| b.ledger.add_account(code, code, ty, None, false).unwrap();
(
add("1500", AccountType::Asset),
add("1501", AccountType::Asset),
add("6100", AccountType::Expense),
)
}
fn new_asset(
accounts: (AccountId, AccountId, AccountId),
cost: &str,
salvage: &str,
purchase_date: NaiveDate,
life: u32,
) -> ledger_core::NewAsset {
ledger_core::NewAsset {
description: "Machine".into(),
purchase_date,
cost: usd(cost.parse().unwrap()),
salvage_value: usd(salvage.parse().unwrap()),
useful_life_months: life,
cost_account: accounts.0,
accum_depr_account: accounts.1,
expense_account: accounts.2,
}
}
/// B-3 (P3): a new asset's cost and salvage are in the currency's minor
/// unit (every depreciation and disposal line derives from them, and a
/// line finer than a cent never posts). Its replay pair: an asset an
/// earlier version took in tenths of a cent opens, and its schedule is
/// computed, never a panic: whole cents until its last day, which is
/// exactly its cost (1,200.005 over 12 months from 2026-01-01: 100.00 a
/// month, the cumulatives 100.0004 × m rounding to whole cents, and
/// December the rest, 100.005).
#[test]
fn an_asset_cost_finer_than_a_cent_is_refused() {
let mut b = books();
let acc = asset_accounts(&mut b);
for (cost, salvage) in [("1200.005", "0"), ("1200.00", "0.001")] {
let before = b.ledger.clone();
assert!(matches!(
b.ledger
.register_asset(new_asset(acc, cost, salvage, d(2026, 1, 1), 12)),
Err(LedgerError::FinerThanMinorUnit { .. })
));
assert!(b.ledger.differs_from(&before).is_empty());
}
let id = b
.ledger
.replay_register_asset(new_asset(acc, "1200.005", "0", d(2026, 1, 1), 12))
.unwrap();
let s = ledger_core::depreciation_schedule(b.ledger.get_asset(id).unwrap(), NaiveDate::MAX)
.unwrap();
assert_eq!(s.len(), 12);
for line in &s[..11] {
assert_eq!(line.amount, Decimal::new(10000, 2), "{}", line.period);
}
assert_eq!(s[11].amount, "100.005".parse::<Decimal>().unwrap());
assert_eq!(s[11].accumulated, "1200.005".parse::<Decimal>().unwrap());
}
/// B-28 (P3: lives past 9999, plan/served.md defect 22): a new asset's
/// last day of depreciation is at most 9999-12-31, the calendar's last
/// day, so every month of its life is one the books can post. A life
/// ending on that day is taken (P0 bounded the life's exclusive end, one
/// day too strict); one a day later is refused. Replay takes either.
#[test]
fn a_life_ending_on_9999_12_31_is_accepted() {
let mut b = books();
let acc = asset_accounts(&mut b);
// 9999-01-01 + 12 months = 10000-01-01, exclusive: last day 9999-12-31.
b.ledger
.register_asset(new_asset(acc, "1200.00", "0", d(9999, 1, 1), 12))
.unwrap();
// 9999-12-01 + 1 month = 10000-01-01: last day 9999-12-31.
b.ledger
.register_asset(new_asset(acc, "1200.00", "0", d(9999, 12, 1), 1))
.unwrap();
}
/// The pair of a_life_ending_on_9999_12_31_is_accepted.
#[test]
fn a_life_ending_after_it_is_refused() {
let mut b = books();
let acc = asset_accounts(&mut b);
for (start, life) in [
(d(9999, 1, 2), 12),
(d(9999, 12, 2), 1),
(d(9999, 12, 31), 1),
] {
assert_eq!(
b.ledger
.register_asset(new_asset(acc, "1200.00", "0", start, life))
.unwrap_err(),
LedgerError::AssetDateOverflow,
"{start} + {life}"
);
b.ledger
.replay_register_asset(new_asset(acc, "1200.00", "0", start, life))
.unwrap();
}
}
/// B-28 (P3): a daily schedule's last day is in the calendar: one month
/// from 9999-12-15 covers through 10000-01-14, refused; one month from
/// 9999-12-01 ends on 9999-12-31, taken. Replay takes either.
#[test]
fn a_daily_schedule_whose_last_day_passes_9999_is_refused() {
let mut b = books();
let schedule = |start: NaiveDate| NewSchedule {
description: "Insurance".into(),
kind: ledger_core::schedules::ScheduleKind::PrepaidExpense,
total: usd(Decimal::new(30000, 2)),
start,
months: 1,
balance_account: b.prepaid,
pl_account: b.expense,
convention: ledger_core::ScheduleConvention::Daily,
};
let (late, ok) = (schedule(d(9999, 12, 15)), schedule(d(9999, 12, 1)));
assert!(matches!(
b.ledger.register_schedule(late.clone()),
Err(LedgerError::InvalidSchedule(m)) if m.contains("9999-12-31")
));
b.ledger.register_schedule(ok).unwrap();
let id = b.ledger.replay_register_schedule(late).unwrap();
// Its plan is computed: December 9999, then January 10000.
let plan = b.ledger.get_schedule(id).unwrap().plan().unwrap();
assert_eq!(plan.len(), 2);
assert_eq!(plan[1].0, d(10000, 1, 31));
}
@@ -56,7 +56,7 @@ fn depreciation_reaches_chronos_last_month() {
})
.unwrap();
let a = l.get_asset(id).unwrap();
let full = ledger_core::depreciation_schedule(a, NaiveDate::MAX);
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"));
@@ -66,17 +66,30 @@ fn depreciation_reaches_chronos_last_month() {
Decimal::new(120000, 2)
);
// Through November: eleven months, December not yet.
let nov = ledger_core::depreciation_schedule(a, d(y, 11, 30));
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.
let dec = ledger_core::depreciation_schedule(a, d(y, 12, 10));
// 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_depreciation(a, NaiveDate::MAX),
ledger_core::accumulated_depreciation(a, NaiveDate::MAX).unwrap(),
Decimal::new(120000, 2)
);
assert_eq!(ledger_core::book_value(a, NaiveDate::MAX), Decimal::ZERO);
assert_eq!(
ledger_core::book_value(a, NaiveDate::MAX).unwrap(),
Decimal::ZERO
);
}
/// An asset whose life runs past 9999 (or starts before year 1) trues up
@@ -108,7 +121,7 @@ fn a_life_past_9999_trues_up_to_the_cent() {
})
.unwrap();
let a = l.get_asset(id).unwrap();
let s = ledger_core::depreciation_schedule(a, NaiveDate::MAX);
let s = ledger_core::depreciation_schedule(a, NaiveDate::MAX).unwrap();
assert_eq!(
s.last().unwrap().accumulated,
Decimal::new(99_999, 2),
@@ -170,7 +183,7 @@ fn february_is_prorated_by_chronos_calendar_in_every_year() {
.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));
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);