1use std::str::FromStr;
7
8use chrono::{Datelike, NaiveDate};
9use rust_decimal::Decimal;
10use rust_decimal_macros::dec;
11use serde::{Deserialize, Serialize};
12
13const MACRS_GDS_3_YEAR: &[&str] = &["33.33", "44.45", "14.81", "7.41"];
16const MACRS_GDS_5_YEAR: &[&str] = &["20.00", "32.00", "19.20", "11.52", "11.52", "5.76"];
17const MACRS_GDS_7_YEAR: &[&str] = &[
18 "14.29", "24.49", "17.49", "12.49", "8.93", "8.92", "8.93", "4.46",
19];
20const MACRS_GDS_10_YEAR: &[&str] = &[
21 "10.00", "18.00", "14.40", "11.52", "9.22", "7.37", "6.55", "6.55", "6.56", "6.55", "3.28",
22];
23const MACRS_GDS_15_YEAR: &[&str] = &[
24 "5.00", "9.50", "8.55", "7.70", "6.93", "6.23", "5.90", "5.90", "5.91", "5.90", "5.91", "5.90",
25 "5.91", "5.90", "5.91", "2.95",
26];
27const MACRS_GDS_20_YEAR: &[&str] = &[
28 "3.750", "7.219", "6.677", "6.177", "5.713", "5.285", "4.888", "4.522", "4.462", "4.461",
29 "4.462", "4.461", "4.462", "4.461", "4.462", "4.461", "4.462", "4.461", "4.462", "4.461",
30 "2.231",
31];
32
33fn macrs_table_for_life(useful_life_years: u32) -> Option<&'static [&'static str]> {
35 match useful_life_years {
36 1..=3 => Some(MACRS_GDS_3_YEAR),
37 4..=5 => Some(MACRS_GDS_5_YEAR),
38 6..=7 => Some(MACRS_GDS_7_YEAR),
39 8..=10 => Some(MACRS_GDS_10_YEAR),
40 11..=15 => Some(MACRS_GDS_15_YEAR),
41 16..=20 => Some(MACRS_GDS_20_YEAR),
42 _ => None,
43 }
44}
45
46fn macrs_pct(s: &str) -> Decimal {
48 Decimal::from_str(s).unwrap_or(Decimal::ZERO)
49}
50
51#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize, Default)]
53#[serde(rename_all = "snake_case")]
54pub enum AssetClass {
55 Buildings,
57 BuildingImprovements,
59 Land,
61 #[default]
63 MachineryEquipment,
64 Machinery,
66 ComputerHardware,
68 ItEquipment,
70 FurnitureFixtures,
72 Furniture,
74 Vehicles,
76 LeaseholdImprovements,
78 Intangibles,
80 Software,
82 ConstructionInProgress,
84 LowValueAssets,
86}
87
88impl AssetClass {
89 pub fn default_useful_life_months(&self) -> u32 {
91 match self {
92 Self::Buildings | Self::BuildingImprovements => 480, Self::Land => 0, Self::MachineryEquipment | Self::Machinery => 120, Self::ComputerHardware | Self::ItEquipment => 36, Self::FurnitureFixtures | Self::Furniture => 84, Self::Vehicles => 60, Self::LeaseholdImprovements => 120, Self::Intangibles | Self::Software => 60, Self::ConstructionInProgress => 0, Self::LowValueAssets => 12, }
103 }
104
105 pub fn is_depreciable(&self) -> bool {
107 !matches!(self, Self::Land | Self::ConstructionInProgress)
108 }
109
110 pub fn default_depreciation_method(&self) -> DepreciationMethod {
112 match self {
113 Self::Buildings | Self::BuildingImprovements | Self::LeaseholdImprovements => {
114 DepreciationMethod::StraightLine
115 }
116 Self::MachineryEquipment | Self::Machinery => DepreciationMethod::StraightLine,
117 Self::ComputerHardware | Self::ItEquipment => {
118 DepreciationMethod::DoubleDecliningBalance
119 }
120 Self::FurnitureFixtures | Self::Furniture => DepreciationMethod::StraightLine,
121 Self::Vehicles => DepreciationMethod::DoubleDecliningBalance,
122 Self::Intangibles | Self::Software => DepreciationMethod::StraightLine,
123 Self::LowValueAssets => DepreciationMethod::ImmediateExpense,
124 Self::Land | Self::ConstructionInProgress => DepreciationMethod::None,
125 }
126 }
127}
128
129#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize, Default)]
131#[serde(rename_all = "snake_case")]
132pub enum DepreciationMethod {
133 #[default]
135 StraightLine,
136 DoubleDecliningBalance,
138 SumOfYearsDigits,
140 UnitsOfProduction,
142 Macrs,
144 ImmediateExpense,
146 None,
148}
149
150impl DepreciationMethod {
151 pub fn calculate_monthly_depreciation(
153 &self,
154 acquisition_cost: Decimal,
155 salvage_value: Decimal,
156 useful_life_months: u32,
157 months_elapsed: u32,
158 accumulated_depreciation: Decimal,
159 ) -> Decimal {
160 if useful_life_months == 0 {
161 return Decimal::ZERO;
162 }
163
164 let depreciable_base = acquisition_cost - salvage_value;
165 let net_book_value = acquisition_cost - accumulated_depreciation;
166
167 if net_book_value <= salvage_value {
169 return Decimal::ZERO;
170 }
171
172 match self {
173 Self::StraightLine => {
174 let monthly_amount = depreciable_base / Decimal::from(useful_life_months);
175 monthly_amount.min(net_book_value - salvage_value)
177 }
178
179 Self::DoubleDecliningBalance => {
180 let annual_rate = Decimal::from(2) / Decimal::from(useful_life_months) * dec!(12);
182 let monthly_rate = annual_rate / dec!(12);
183 let depreciation = net_book_value * monthly_rate;
184 depreciation.min(net_book_value - salvage_value)
186 }
187
188 Self::SumOfYearsDigits => {
189 let years_total = useful_life_months / 12;
190 let sum_of_years: u32 = (1..=years_total).sum();
191 let current_year = (months_elapsed / 12) + 1;
192 let remaining_years = years_total.saturating_sub(current_year) + 1;
193
194 if sum_of_years == 0 || remaining_years == 0 {
195 return Decimal::ZERO;
196 }
197
198 let year_fraction = Decimal::from(remaining_years) / Decimal::from(sum_of_years);
199 let annual_depreciation = depreciable_base * year_fraction;
200 let monthly_amount = annual_depreciation / dec!(12);
201 monthly_amount.min(net_book_value - salvage_value)
202 }
203
204 Self::UnitsOfProduction => {
205 let monthly_amount = depreciable_base / Decimal::from(useful_life_months);
208 monthly_amount.min(net_book_value - salvage_value)
209 }
210
211 Self::Macrs => {
212 let useful_life_years = useful_life_months / 12;
215 let current_year = (months_elapsed / 12) as usize;
216
217 if let Some(table) = macrs_table_for_life(useful_life_years) {
218 if current_year < table.len() {
219 let pct = macrs_pct(table[current_year]);
220 let annual_depreciation = acquisition_cost * pct / dec!(100);
221 let monthly_amount = annual_depreciation / dec!(12);
222 monthly_amount.min(net_book_value)
224 } else {
225 Decimal::ZERO
226 }
227 } else {
228 let annual_rate =
230 Decimal::from(2) / Decimal::from(useful_life_months) * dec!(12);
231 let monthly_rate = annual_rate / dec!(12);
232 let depreciation = net_book_value * monthly_rate;
233 depreciation.min(net_book_value - salvage_value)
234 }
235 }
236
237 Self::ImmediateExpense => {
238 if months_elapsed == 0 {
240 depreciable_base
241 } else {
242 Decimal::ZERO
243 }
244 }
245
246 Self::None => Decimal::ZERO,
247 }
248 }
249}
250
251#[derive(Debug, Clone, Serialize, Deserialize)]
253pub struct AssetAccountDetermination {
254 pub asset_account: String,
256 pub accumulated_depreciation_account: String,
258 pub depreciation_expense_account: String,
260 pub gain_on_disposal_account: String,
262 pub loss_on_disposal_account: String,
264 pub acquisition_clearing_account: String,
266 pub gain_loss_account: String,
268}
269
270impl Default for AssetAccountDetermination {
271 fn default() -> Self {
272 Self {
273 asset_account: "160000".to_string(),
274 accumulated_depreciation_account: "169000".to_string(),
275 depreciation_expense_account: "640000".to_string(),
276 gain_on_disposal_account: "810000".to_string(),
277 loss_on_disposal_account: "840000".to_string(),
278 acquisition_clearing_account: "299000".to_string(),
279 gain_loss_account: "810000".to_string(),
280 }
281 }
282}
283
284#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize, Default)]
286#[serde(rename_all = "snake_case")]
287pub enum AcquisitionType {
288 #[default]
290 Purchase,
291 SelfConstructed,
293 Transfer,
295 BusinessCombination,
297 FinanceLease,
299 Donation,
301}
302
303#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize, Default)]
305#[serde(rename_all = "snake_case")]
306pub enum AssetStatus {
307 UnderConstruction,
309 #[default]
311 Active,
312 Inactive,
314 FullyDepreciated,
316 PendingDisposal,
318 Disposed,
320}
321
322#[derive(Debug, Clone, Serialize, Deserialize)]
324pub struct FixedAsset {
325 pub asset_id: String,
327
328 pub sub_number: u16,
330
331 pub description: String,
333
334 pub asset_class: AssetClass,
336
337 pub company_code: String,
339
340 pub cost_center: Option<String>,
342
343 pub location: Option<String>,
345
346 pub acquisition_date: NaiveDate,
348
349 pub acquisition_type: AcquisitionType,
351
352 pub acquisition_cost: Decimal,
354
355 pub capitalized_date: Option<NaiveDate>,
357
358 pub depreciation_method: DepreciationMethod,
360
361 pub useful_life_months: u32,
363
364 pub salvage_value: Decimal,
366
367 pub accumulated_depreciation: Decimal,
369
370 pub net_book_value: Decimal,
372
373 pub account_determination: AssetAccountDetermination,
375
376 pub status: AssetStatus,
378
379 pub disposal_date: Option<NaiveDate>,
381
382 pub disposal_proceeds: Option<Decimal>,
384
385 pub serial_number: Option<String>,
387
388 pub manufacturer: Option<String>,
390
391 pub model: Option<String>,
393
394 pub warranty_expiration: Option<NaiveDate>,
396
397 pub insurance_policy: Option<String>,
399
400 pub purchase_order: Option<String>,
402
403 pub vendor_id: Option<String>,
405
406 pub invoice_reference: Option<String>,
408}
409
410impl FixedAsset {
411 pub fn new(
413 asset_id: impl Into<String>,
414 description: impl Into<String>,
415 asset_class: AssetClass,
416 company_code: impl Into<String>,
417 acquisition_date: NaiveDate,
418 acquisition_cost: Decimal,
419 ) -> Self {
420 let useful_life_months = asset_class.default_useful_life_months();
421 let depreciation_method = asset_class.default_depreciation_method();
422
423 Self {
424 asset_id: asset_id.into(),
425 sub_number: 0,
426 description: description.into(),
427 asset_class,
428 company_code: company_code.into(),
429 cost_center: None,
430 location: None,
431 acquisition_date,
432 acquisition_type: AcquisitionType::Purchase,
433 acquisition_cost,
434 capitalized_date: Some(acquisition_date),
435 depreciation_method,
436 useful_life_months,
437 salvage_value: Decimal::ZERO,
438 accumulated_depreciation: Decimal::ZERO,
439 net_book_value: acquisition_cost,
440 account_determination: AssetAccountDetermination::default(),
441 status: AssetStatus::Active,
442 disposal_date: None,
443 disposal_proceeds: None,
444 serial_number: None,
445 manufacturer: None,
446 model: None,
447 warranty_expiration: None,
448 insurance_policy: None,
449 purchase_order: None,
450 vendor_id: None,
451 invoice_reference: None,
452 }
453 }
454
455 pub fn with_cost_center(mut self, cost_center: impl Into<String>) -> Self {
457 self.cost_center = Some(cost_center.into());
458 self
459 }
460
461 pub fn with_location(mut self, location: impl Into<String>) -> Self {
463 self.location = Some(location.into());
464 self
465 }
466
467 pub fn with_salvage_value(mut self, salvage_value: Decimal) -> Self {
469 self.salvage_value = salvage_value;
470 self
471 }
472
473 pub fn with_depreciation_method(mut self, method: DepreciationMethod) -> Self {
475 self.depreciation_method = method;
476 self
477 }
478
479 pub fn with_useful_life_months(mut self, months: u32) -> Self {
481 self.useful_life_months = months;
482 self
483 }
484
485 pub fn with_vendor(mut self, vendor_id: impl Into<String>) -> Self {
487 self.vendor_id = Some(vendor_id.into());
488 self
489 }
490
491 pub fn months_since_capitalization(&self, as_of_date: NaiveDate) -> u32 {
493 let cap_date = self.capitalized_date.unwrap_or(self.acquisition_date);
494 if as_of_date < cap_date {
495 return 0;
496 }
497
498 let years = as_of_date.year() - cap_date.year();
499 let months = as_of_date.month() as i32 - cap_date.month() as i32;
500 ((years * 12) + months).max(0) as u32
501 }
502
503 pub fn calculate_monthly_depreciation(&self, as_of_date: NaiveDate) -> Decimal {
505 if !self.asset_class.is_depreciable() {
506 return Decimal::ZERO;
507 }
508
509 if self.status == AssetStatus::Disposed {
510 return Decimal::ZERO;
511 }
512
513 let months_elapsed = self.months_since_capitalization(as_of_date);
514
515 self.depreciation_method.calculate_monthly_depreciation(
516 self.acquisition_cost,
517 self.salvage_value,
518 self.useful_life_months,
519 months_elapsed,
520 self.accumulated_depreciation,
521 )
522 }
523
524 pub fn apply_depreciation(&mut self, depreciation_amount: Decimal) {
526 self.accumulated_depreciation += depreciation_amount;
527 self.net_book_value = self.acquisition_cost - self.accumulated_depreciation;
528
529 if self.net_book_value <= self.salvage_value && self.status == AssetStatus::Active {
531 self.status = AssetStatus::FullyDepreciated;
532 }
533 }
534
535 pub fn calculate_disposal_gain_loss(&self, proceeds: Decimal) -> Decimal {
537 proceeds - self.net_book_value
538 }
539
540 pub fn dispose(&mut self, disposal_date: NaiveDate, proceeds: Decimal) {
542 self.disposal_date = Some(disposal_date);
543 self.disposal_proceeds = Some(proceeds);
544 self.status = AssetStatus::Disposed;
545 }
546
547 pub fn is_fully_depreciated(&self) -> bool {
549 self.net_book_value <= self.salvage_value
550 }
551
552 pub fn remaining_useful_life_months(&self, as_of_date: NaiveDate) -> u32 {
554 let months_elapsed = self.months_since_capitalization(as_of_date);
555 self.useful_life_months.saturating_sub(months_elapsed)
556 }
557
558 pub fn annual_depreciation_rate(&self) -> Decimal {
560 if self.useful_life_months == 0 {
561 return Decimal::ZERO;
562 }
563
564 match self.depreciation_method {
565 DepreciationMethod::StraightLine => {
566 Decimal::from(12) / Decimal::from(self.useful_life_months) * dec!(100)
567 }
568 DepreciationMethod::DoubleDecliningBalance => {
569 Decimal::from(24) / Decimal::from(self.useful_life_months) * dec!(100)
570 }
571 _ => Decimal::from(12) / Decimal::from(self.useful_life_months) * dec!(100),
572 }
573 }
574
575 pub fn macrs_depreciation(&self, year: u32) -> Decimal {
581 if year == 0 {
582 return Decimal::ZERO;
583 }
584
585 let useful_life_years = self.useful_life_months / 12;
586 let table_index = (year - 1) as usize;
587
588 match macrs_table_for_life(useful_life_years) {
589 Some(table) if table_index < table.len() => {
590 let pct = macrs_pct(table[table_index]);
591 self.acquisition_cost * pct / dec!(100)
592 }
593 _ => Decimal::ZERO,
594 }
595 }
596
597 pub fn ddb_depreciation(&self) -> Decimal {
603 if self.useful_life_months == 0 {
604 return Decimal::ZERO;
605 }
606
607 let net_book_value = self.acquisition_cost - self.accumulated_depreciation;
608 if net_book_value <= self.salvage_value {
609 return Decimal::ZERO;
610 }
611
612 let annual_rate = Decimal::from(2) / Decimal::from(self.useful_life_months) * dec!(12);
613 let monthly_rate = annual_rate / dec!(12);
614 let depreciation = (net_book_value * monthly_rate).round_dp(2);
615 depreciation.min(net_book_value - self.salvage_value)
616 }
617}
618
619#[derive(Debug, Clone, Default, Serialize, Deserialize)]
621pub struct FixedAssetPool {
622 pub assets: Vec<FixedAsset>,
624 #[serde(skip)]
626 class_index: std::collections::HashMap<AssetClass, Vec<usize>>,
627 #[serde(skip)]
629 company_index: std::collections::HashMap<String, Vec<usize>>,
630}
631
632impl FixedAssetPool {
633 pub fn new() -> Self {
635 Self::default()
636 }
637
638 pub fn add_asset(&mut self, asset: FixedAsset) {
640 let idx = self.assets.len();
641 let asset_class = asset.asset_class;
642 let company_code = asset.company_code.clone();
643
644 self.assets.push(asset);
645
646 self.class_index.entry(asset_class).or_default().push(idx);
647 self.company_index
648 .entry(company_code)
649 .or_default()
650 .push(idx);
651 }
652
653 pub fn get_depreciable_assets(&self) -> Vec<&FixedAsset> {
655 self.assets
656 .iter()
657 .filter(|a| {
658 a.asset_class.is_depreciable()
659 && a.status == AssetStatus::Active
660 && !a.is_fully_depreciated()
661 })
662 .collect()
663 }
664
665 pub fn get_depreciable_assets_mut(&mut self) -> Vec<&mut FixedAsset> {
667 self.assets
668 .iter_mut()
669 .filter(|a| {
670 a.asset_class.is_depreciable()
671 && a.status == AssetStatus::Active
672 && !a.is_fully_depreciated()
673 })
674 .collect()
675 }
676
677 pub fn get_by_company(&self, company_code: &str) -> Vec<&FixedAsset> {
679 self.company_index
680 .get(company_code)
681 .map(|indices| indices.iter().map(|&i| &self.assets[i]).collect())
682 .unwrap_or_default()
683 }
684
685 pub fn get_by_class(&self, asset_class: AssetClass) -> Vec<&FixedAsset> {
687 self.class_index
688 .get(&asset_class)
689 .map(|indices| indices.iter().map(|&i| &self.assets[i]).collect())
690 .unwrap_or_default()
691 }
692
693 pub fn get_by_id(&self, asset_id: &str) -> Option<&FixedAsset> {
695 self.assets.iter().find(|a| a.asset_id == asset_id)
696 }
697
698 pub fn get_by_id_mut(&mut self, asset_id: &str) -> Option<&mut FixedAsset> {
700 self.assets.iter_mut().find(|a| a.asset_id == asset_id)
701 }
702
703 pub fn calculate_period_depreciation(&self, as_of_date: NaiveDate) -> Decimal {
705 self.get_depreciable_assets()
706 .iter()
707 .map(|a| a.calculate_monthly_depreciation(as_of_date))
708 .sum()
709 }
710
711 pub fn total_net_book_value(&self) -> Decimal {
713 self.assets
714 .iter()
715 .filter(|a| a.status != AssetStatus::Disposed)
716 .map(|a| a.net_book_value)
717 .sum()
718 }
719
720 pub fn len(&self) -> usize {
722 self.assets.len()
723 }
724
725 pub fn is_empty(&self) -> bool {
727 self.assets.is_empty()
728 }
729
730 pub fn rebuild_indices(&mut self) {
732 self.class_index.clear();
733 self.company_index.clear();
734
735 for (idx, asset) in self.assets.iter().enumerate() {
736 self.class_index
737 .entry(asset.asset_class)
738 .or_default()
739 .push(idx);
740 self.company_index
741 .entry(asset.company_code.clone())
742 .or_default()
743 .push(idx);
744 }
745 }
746}
747
748#[cfg(test)]
749#[allow(clippy::unwrap_used)]
750mod tests {
751 use super::*;
752
753 fn test_date(year: i32, month: u32, day: u32) -> NaiveDate {
754 NaiveDate::from_ymd_opt(year, month, day).unwrap()
755 }
756
757 #[test]
758 fn test_asset_creation() {
759 let asset = FixedAsset::new(
760 "FA-001",
761 "Office Computer",
762 AssetClass::ComputerHardware,
763 "1000",
764 test_date(2024, 1, 1),
765 Decimal::from(2000),
766 );
767
768 assert_eq!(asset.asset_id, "FA-001");
769 assert_eq!(asset.acquisition_cost, Decimal::from(2000));
770 assert_eq!(asset.useful_life_months, 36); }
772
773 #[test]
774 fn test_straight_line_depreciation() {
775 let asset = FixedAsset::new(
776 "FA-001",
777 "Office Equipment",
778 AssetClass::FurnitureFixtures,
779 "1000",
780 test_date(2024, 1, 1),
781 Decimal::from(8400),
782 )
783 .with_useful_life_months(84) .with_depreciation_method(DepreciationMethod::StraightLine);
785
786 let monthly_dep = asset.calculate_monthly_depreciation(test_date(2024, 2, 1));
787 assert_eq!(monthly_dep, Decimal::from(100)); }
789
790 #[test]
791 fn test_salvage_value_limit() {
792 let mut asset = FixedAsset::new(
793 "FA-001",
794 "Test Asset",
795 AssetClass::MachineryEquipment,
796 "1000",
797 test_date(2024, 1, 1),
798 Decimal::from(1200),
799 )
800 .with_useful_life_months(12)
801 .with_salvage_value(Decimal::from(200));
802
803 for _ in 0..11 {
805 let dep = Decimal::from(83);
806 asset.apply_depreciation(dep);
807 }
808
809 let final_dep = asset.calculate_monthly_depreciation(test_date(2024, 12, 1));
812
813 asset.apply_depreciation(final_dep);
815 assert!(asset.net_book_value >= asset.salvage_value);
816 }
817
818 #[test]
819 fn test_disposal() {
820 let mut asset = FixedAsset::new(
821 "FA-001",
822 "Old Equipment",
823 AssetClass::MachineryEquipment,
824 "1000",
825 test_date(2020, 1, 1),
826 Decimal::from(10000),
827 );
828
829 asset.apply_depreciation(Decimal::from(5000));
831
832 let gain_loss = asset.calculate_disposal_gain_loss(Decimal::from(6000));
834 assert_eq!(gain_loss, Decimal::from(1000)); asset.dispose(test_date(2024, 1, 1), Decimal::from(6000));
838 assert_eq!(asset.status, AssetStatus::Disposed);
839 }
840
841 #[test]
842 fn test_land_not_depreciable() {
843 let asset = FixedAsset::new(
844 "FA-001",
845 "Land Parcel",
846 AssetClass::Land,
847 "1000",
848 test_date(2024, 1, 1),
849 Decimal::from(500000),
850 );
851
852 let dep = asset.calculate_monthly_depreciation(test_date(2024, 6, 1));
853 assert_eq!(dep, Decimal::ZERO);
854 }
855
856 #[test]
857 fn test_asset_pool() {
858 let mut pool = FixedAssetPool::new();
859
860 pool.add_asset(FixedAsset::new(
861 "FA-001",
862 "Computer 1",
863 AssetClass::ComputerHardware,
864 "1000",
865 test_date(2024, 1, 1),
866 Decimal::from(2000),
867 ));
868
869 pool.add_asset(FixedAsset::new(
870 "FA-002",
871 "Desk",
872 AssetClass::FurnitureFixtures,
873 "1000",
874 test_date(2024, 1, 1),
875 Decimal::from(500),
876 ));
877
878 assert_eq!(pool.len(), 2);
879 assert_eq!(pool.get_by_class(AssetClass::ComputerHardware).len(), 1);
880 assert_eq!(pool.get_by_company("1000").len(), 2);
881 }
882
883 #[test]
884 fn test_months_since_capitalization() {
885 let asset = FixedAsset::new(
886 "FA-001",
887 "Test",
888 AssetClass::MachineryEquipment,
889 "1000",
890 test_date(2024, 3, 15),
891 Decimal::from(10000),
892 );
893
894 assert_eq!(asset.months_since_capitalization(test_date(2024, 3, 1)), 0);
895 assert_eq!(asset.months_since_capitalization(test_date(2024, 6, 1)), 3);
896 assert_eq!(asset.months_since_capitalization(test_date(2025, 3, 1)), 12);
897 }
898
899 #[test]
902 fn test_macrs_tables_sum_to_100() {
903 let tables: &[(&str, &[&str])] = &[
904 ("3-year", MACRS_GDS_3_YEAR),
905 ("5-year", MACRS_GDS_5_YEAR),
906 ("7-year", MACRS_GDS_7_YEAR),
907 ("10-year", MACRS_GDS_10_YEAR),
908 ("15-year", MACRS_GDS_15_YEAR),
909 ("20-year", MACRS_GDS_20_YEAR),
910 ];
911
912 let tolerance = dec!(0.02);
913 let hundred = dec!(100);
914
915 for (label, table) in tables {
916 let sum: Decimal = table.iter().map(|s| macrs_pct(s)).sum();
917 let diff = (sum - hundred).abs();
918 assert!(
919 diff < tolerance,
920 "MACRS GDS {label} table sums to {sum}, expected ~100.0"
921 );
922 }
923 }
924
925 #[test]
926 fn test_macrs_table_for_life_mapping() {
927 assert_eq!(macrs_table_for_life(1).unwrap().len(), 4);
929 assert_eq!(macrs_table_for_life(3).unwrap().len(), 4);
930
931 assert_eq!(macrs_table_for_life(4).unwrap().len(), 6);
933 assert_eq!(macrs_table_for_life(5).unwrap().len(), 6);
934
935 assert_eq!(macrs_table_for_life(6).unwrap().len(), 8);
937 assert_eq!(macrs_table_for_life(7).unwrap().len(), 8);
938
939 assert_eq!(macrs_table_for_life(8).unwrap().len(), 11);
941 assert_eq!(macrs_table_for_life(10).unwrap().len(), 11);
942
943 assert_eq!(macrs_table_for_life(11).unwrap().len(), 16);
945 assert_eq!(macrs_table_for_life(15).unwrap().len(), 16);
946
947 assert_eq!(macrs_table_for_life(16).unwrap().len(), 21);
949 assert_eq!(macrs_table_for_life(20).unwrap().len(), 21);
950
951 assert!(macrs_table_for_life(0).is_none());
953 assert!(macrs_table_for_life(21).is_none());
954 assert!(macrs_table_for_life(100).is_none());
955 }
956
957 #[test]
958 fn test_macrs_depreciation_5_year_asset() {
959 let asset = FixedAsset::new(
960 "FA-MACRS",
961 "Vehicle",
962 AssetClass::Vehicles,
963 "1000",
964 test_date(2024, 1, 1),
965 Decimal::from(10000),
966 )
967 .with_useful_life_months(60) .with_depreciation_method(DepreciationMethod::Macrs);
969
970 assert_eq!(asset.macrs_depreciation(1), Decimal::from(2000));
972 assert_eq!(asset.macrs_depreciation(2), Decimal::from(3200));
974 assert_eq!(asset.macrs_depreciation(3), Decimal::from(1920));
976 assert_eq!(asset.macrs_depreciation(4), Decimal::from(1152));
978 assert_eq!(asset.macrs_depreciation(5), Decimal::from(1152));
980 assert_eq!(asset.macrs_depreciation(6), Decimal::from(576));
982 assert_eq!(asset.macrs_depreciation(7), Decimal::ZERO);
984 assert_eq!(asset.macrs_depreciation(0), Decimal::ZERO);
986 }
987
988 #[test]
989 fn test_macrs_calculate_monthly_depreciation_uses_tables() {
990 let asset = FixedAsset::new(
991 "FA-MACRS-M",
992 "Vehicle",
993 AssetClass::Vehicles,
994 "1000",
995 test_date(2024, 1, 1),
996 Decimal::from(12000),
997 )
998 .with_useful_life_months(60) .with_depreciation_method(DepreciationMethod::Macrs);
1000
1001 let monthly_year1 = asset.calculate_monthly_depreciation(test_date(2024, 2, 1));
1003 assert_eq!(monthly_year1, Decimal::from(200));
1004
1005 let monthly_year2 = asset.calculate_monthly_depreciation(test_date(2025, 2, 1));
1007 assert_eq!(monthly_year2, Decimal::from(320));
1008 }
1009
1010 #[test]
1011 fn test_ddb_depreciation() {
1012 let asset = FixedAsset::new(
1013 "FA-DDB",
1014 "Server",
1015 AssetClass::ComputerHardware,
1016 "1000",
1017 test_date(2024, 1, 1),
1018 Decimal::from(3600),
1019 )
1020 .with_useful_life_months(36) .with_depreciation_method(DepreciationMethod::DoubleDecliningBalance);
1022
1023 let monthly = asset.ddb_depreciation();
1027 assert_eq!(monthly, Decimal::from(200));
1028 }
1029
1030 #[test]
1031 fn test_ddb_depreciation_with_accumulated() {
1032 let mut asset = FixedAsset::new(
1033 "FA-DDB2",
1034 "Laptop",
1035 AssetClass::ComputerHardware,
1036 "1000",
1037 test_date(2024, 1, 1),
1038 Decimal::from(1800),
1039 )
1040 .with_useful_life_months(36);
1041
1042 asset.apply_depreciation(Decimal::from(900));
1044
1045 let monthly = asset.ddb_depreciation();
1047 assert_eq!(monthly, Decimal::from(50));
1048 }
1049
1050 #[test]
1051 fn test_ddb_depreciation_respects_salvage() {
1052 let mut asset = FixedAsset::new(
1053 "FA-DDB3",
1054 "Printer",
1055 AssetClass::ComputerHardware,
1056 "1000",
1057 test_date(2024, 1, 1),
1058 Decimal::from(1800),
1059 )
1060 .with_useful_life_months(36)
1061 .with_salvage_value(Decimal::from(200));
1062
1063 asset.apply_depreciation(Decimal::from(1590));
1066
1067 let monthly = asset.ddb_depreciation();
1069 assert_eq!(monthly, Decimal::from(10));
1070 }
1071}