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
@@ -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
@@ -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"] {
|
||||
|
||||
@@ -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))
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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"));
|
||||
}
|
||||
@@ -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"))
|
||||
);
|
||||
}
|
||||
@@ -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")
|
||||
]
|
||||
);
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
Reference in new issue
Block a user