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

+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));
}