//! Fixed-asset tests: the depreciation math must be exact to the penny. use chrono::NaiveDate; use ledger_core::{ AccountType, AssetStatus, Currency, DepreciationMethod, Ledger, Money, NewAsset, accumulated_depreciation, book_value, depreciation_schedule, disposal_amounts, }; use rust_decimal::Decimal; fn usd(amount: &str) -> Money { Money::new(amount.parse().unwrap(), Currency::USD) } fn date(y: i32, m: u32, d: u32) -> NaiveDate { NaiveDate::from_ymd_opt(y, m, d).unwrap() } fn test_ledger() -> (Ledger, u64, u64, u64) { let mut ledger = Ledger::new(Currency::USD); let cost = ledger .add_account("1500", "Equipment", AccountType::Asset, None, false) .unwrap(); let accum = ledger .add_account( "1501", "Accumulated Depreciation", AccountType::Asset, None, false, ) .unwrap(); let expense = ledger .add_account( "6100", "Depreciation Expense", AccountType::Expense, None, false, ) .unwrap(); (ledger, cost, accum, expense) } /// Register the standard test asset: bought 2026-01-15, 60-month life, /// zero salvage, straight-line. fn van_asset( ledger: &mut Ledger, dollars: u64, cost_acct: u64, accum_acct: u64, expense_acct: u64, ) -> u64 { ledger .register_asset(NewAsset { description: "Sprinter van".to_string(), purchase_date: date(2026, 1, 15), cost: usd(&dollars.to_string()), salvage_value: usd("0"), useful_life_months: 60, cost_account: cost_acct, accum_depr_account: accum_acct, expense_account: expense_acct, }) .unwrap() } #[test] fn schedule_sums_exactly_to_depreciable_amount() { let (mut ledger, cost, accum, expense) = test_ledger(); // 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)); // 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(); assert_eq!( total, Decimal::from(100_000), "lifetime total must be exact" ); assert_eq!(sched.last().unwrap().accumulated, Decimal::from(100_000)); assert_eq!(sched.last().unwrap().book_value, Decimal::ZERO); // First month prorated: 17 of 31 days → 100000/60 * 17/31 = 913.98. let first = &sched[0]; assert_eq!(first.period, "2026-01"); assert_eq!(first.amount, Decimal::new(91398, 2)); // Last month is the complement: 14 of 31 days. assert_eq!(sched[60].period, "2031-01"); } #[test] 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)); // 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 = monthly.round_dp_with_strategy(2, rust_decimal::RoundingStrategy::MidpointAwayFromZero); let pair = sched[0].amount + sched[60].amount; assert!( (pair - monthly_rounded).abs() <= Decimal::new(1, 2), "partial months should complement: {pair} vs {monthly_rounded}" ); } #[test] fn salvage_value_is_never_depreciated() { let (mut ledger, cost, accum, expense) = test_ledger(); let id = ledger .register_asset(NewAsset { description: "Machine".to_string(), purchase_date: date(2026, 3, 1), cost: usd("50000"), salvage_value: usd("5000"), useful_life_months: 36, cost_account: cost, accum_depr_account: accum, expense_account: expense, }) .unwrap(); let asset = ledger.get_asset(id).unwrap(); let sched = depreciation_schedule(asset, date(2030, 12, 31)); 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)); } #[test] 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)); // 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)), Decimal::from(60_000) - acc ); } #[test] fn disposal_math() { 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(); // Sell after one year for 55,000. Book value is 60000 − 11548.39 = // 48451.61, so this is a gain of 6548.39. let d = disposal_amounts(asset, date(2026, 12, 31), usd("55000")).unwrap(); assert_eq!(d.book_value, Decimal::new(4845161, 2)); assert_eq!(d.accumulated, Decimal::new(1154839, 2)); assert_eq!(d.gain_loss, Decimal::new(654839, 2)); // Disposing twice fails. ledger .dispose_asset(id, date(2026, 12, 31), usd("55000")) .unwrap(); assert!( ledger .dispose_asset(id, date(2027, 1, 1), usd("1")) .is_err() ); let asset = ledger.get_asset(id).unwrap(); assert!(matches!(asset.status, AssetStatus::Disposed { .. })); } #[test] fn registration_validation() { let (mut ledger, cost, accum, expense) = test_ledger(); let liability = ledger .add_account("2000", "Payable", AccountType::Liability, None, false) .unwrap(); let base = NewAsset { description: "X".to_string(), purchase_date: date(2026, 1, 1), cost: usd("1000"), salvage_value: usd("0"), useful_life_months: 12, cost_account: cost, accum_depr_account: accum, expense_account: expense, }; // Wrong account type for the expense link. let mut bad = base.clone(); bad.expense_account = liability; assert!(ledger.register_asset(bad).is_err()); // Salvage >= cost. let mut bad = base.clone(); bad.salvage_value = usd("1000"); assert!(ledger.register_asset(bad).is_err()); // Zero life. let mut bad = base.clone(); bad.useful_life_months = 0; assert!(ledger.register_asset(bad).is_err()); // Duplicate account links. let mut bad = base.clone(); bad.accum_depr_account = cost; assert!(ledger.register_asset(bad).is_err()); // Unknown account. let mut bad = base.clone(); bad.cost_account = 999; assert!(ledger.register_asset(bad).is_err()); // The good one works. assert!(ledger.register_asset(base).is_ok()); } #[test] fn method_is_straight_line() { let (mut ledger, cost, accum, expense) = test_ledger(); let id = van_asset(&mut ledger, 12_000, cost, accum, expense); let asset = ledger.get_asset(id).unwrap(); assert_eq!(asset.method, DepreciationMethod::StraightLine); } #[test] fn registration_rejects_date_overflow() { let (mut ledger, cost, accum, expense) = test_ledger(); // A purchase date near the end of the calendar with a long life would // overflow the schedule's month arithmetic; registration must refuse. let err = ledger .register_asset(NewAsset { description: "Time machine".to_string(), purchase_date: NaiveDate::MAX, cost: usd("1000"), salvage_value: usd("0"), useful_life_months: 12, cost_account: cost, accum_depr_account: accum, expense_account: expense, }) .unwrap_err(); assert!(matches!(err, ledger_core::LedgerError::AssetDateOverflow)); } #[test] fn schedule_through_purchase_date_covers_one_day() { let (mut ledger, cost, accum, expense) = test_ledger(); let id = ledger .register_asset(NewAsset { description: "X".to_string(), purchase_date: date(2026, 1, 15), cost: usd("62000"), salvage_value: usd("0"), useful_life_months: 62, cost_account: cost, accum_depr_account: accum, expense_account: expense, }) .unwrap(); let asset = ledger.get_asset(id).unwrap(); // Through the day before purchase: nothing. assert!(depreciation_schedule(asset, date(2026, 1, 14)).is_empty()); // Through the purchase date itself: one day of the first month. let one_day = depreciation_schedule(asset, date(2026, 1, 15)); assert_eq!(one_day.len(), 1); assert_eq!(one_day[0].period, "2026-01"); // 62000/62 = 1000/month; 1/31 of that = 32.26. assert_eq!(one_day[0].amount, Decimal::new(3226, 2)); } #[test] fn golden_van_depreciation_schedule() { // The canonical van's month-by-month schedule, pinned byte-for-value. // Hand-verified: 61 months, first 548.39 = 1000 x 17/31, last 451.61 = // 1000 x 14/31, lifetime total exactly 60000.00. 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 got = serde_json::to_value(&sched).unwrap(); let golden: serde_json::Value = serde_json::from_str(include_str!("golden/van_schedule.json")).unwrap(); assert_eq!(got, golden); } #[test] fn registration_rejects_wrong_typed_accounts() { let (mut ledger, cost, accum, expense) = test_ledger(); let base = || NewAsset { description: "X".to_string(), purchase_date: date(2026, 1, 15), cost: usd("1000"), salvage_value: usd("0"), useful_life_months: 12, cost_account: cost, accum_depr_account: accum, expense_account: expense, }; // An expense account cannot hold the cost. let err = ledger .register_asset(NewAsset { cost_account: expense, ..base() }) .unwrap_err(); assert!(matches!( err, ledger_core::LedgerError::AssetAccountWrongType { .. } )); // An asset account cannot be the depreciation expense. let err = ledger .register_asset(NewAsset { expense_account: cost, ..base() }) .unwrap_err(); assert!(matches!( err, ledger_core::LedgerError::AssetAccountWrongType { .. } )); // The three links must be distinct accounts. let err = ledger .register_asset(NewAsset { accum_depr_account: cost, ..base() }) .unwrap_err(); assert!(matches!( err, ledger_core::LedgerError::DuplicateAssetAccount(_) )); // Unknown accounts are rejected. let err = ledger .register_asset(NewAsset { cost_account: 999, ..base() }) .unwrap_err(); assert!(matches!(err, ledger_core::LedgerError::UnknownAccount(999))); } #[test] fn disposal_accepts_zero_proceeds() { // Scrapping an asset for nothing is legitimate: full loss. 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().clone(); let d = disposal_amounts(&asset, date(2026, 6, 30), usd("0")).unwrap(); assert!(d.gain_loss < Decimal::ZERO); assert_eq!(d.book_value + d.gain_loss, Decimal::ZERO); } #[test] fn two_assets_get_distinct_ids_and_list() { let (mut ledger, cost, accum, expense) = test_ledger(); let id1 = van_asset(&mut ledger, 60_000, cost, accum, expense); let id2 = van_asset(&mut ledger, 30_000, cost, accum, expense); assert_ne!(id1, id2); let listed: Vec<_> = ledger.registered_assets().collect(); assert_eq!(listed.len(), 2); assert!(listed.iter().any(|a| a.id == id1)); assert!(listed.iter().any(|a| a.id == id2)); } #[test] fn one_month_useful_life_is_valid() { let (mut ledger, cost, accum, expense) = test_ledger(); let id = ledger .register_asset(NewAsset { description: "Cheap tool".to_string(), purchase_date: date(2026, 3, 10), cost: usd("300"), salvage_value: usd("0"), useful_life_months: 1, cost_account: cost, accum_depr_account: accum, expense_account: expense, }) .unwrap(); let asset = ledger.get_asset(id).unwrap(); let sched = depreciation_schedule(asset, date(2026, 12, 31)); // A one-month life starting Mar 10 spans two calendar months. assert_eq!(sched.len(), 2); // March: 22/31 of 300 = 212.90. assert_eq!(sched[0].amount, Decimal::new(21290, 2)); // Lifetime total is exactly 300.00. let total: Decimal = sched.iter().map(|l| l.amount).sum(); assert_eq!(total, Decimal::new(30000, 2)); }