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)]
749mod tests {
750 use super::*;
751
752 fn test_date(year: i32, month: u32, day: u32) -> NaiveDate {
753 NaiveDate::from_ymd_opt(year, month, day).unwrap()
754 }
755
756 #[test]
757 fn test_asset_creation() {
758 let asset = FixedAsset::new(
759 "FA-001",
760 "Office Computer",
761 AssetClass::ComputerHardware,
762 "1000",
763 test_date(2024, 1, 1),
764 Decimal::from(2000),
765 );
766
767 assert_eq!(asset.asset_id, "FA-001");
768 assert_eq!(asset.acquisition_cost, Decimal::from(2000));
769 assert_eq!(asset.useful_life_months, 36); }
771
772 #[test]
773 fn test_straight_line_depreciation() {
774 let asset = FixedAsset::new(
775 "FA-001",
776 "Office Equipment",
777 AssetClass::FurnitureFixtures,
778 "1000",
779 test_date(2024, 1, 1),
780 Decimal::from(8400),
781 )
782 .with_useful_life_months(84) .with_depreciation_method(DepreciationMethod::StraightLine);
784
785 let monthly_dep = asset.calculate_monthly_depreciation(test_date(2024, 2, 1));
786 assert_eq!(monthly_dep, Decimal::from(100)); }
788
789 #[test]
790 fn test_salvage_value_limit() {
791 let mut asset = FixedAsset::new(
792 "FA-001",
793 "Test Asset",
794 AssetClass::MachineryEquipment,
795 "1000",
796 test_date(2024, 1, 1),
797 Decimal::from(1200),
798 )
799 .with_useful_life_months(12)
800 .with_salvage_value(Decimal::from(200));
801
802 for _ in 0..11 {
804 let dep = Decimal::from(83);
805 asset.apply_depreciation(dep);
806 }
807
808 let final_dep = asset.calculate_monthly_depreciation(test_date(2024, 12, 1));
811
812 asset.apply_depreciation(final_dep);
814 assert!(asset.net_book_value >= asset.salvage_value);
815 }
816
817 #[test]
818 fn test_disposal() {
819 let mut asset = FixedAsset::new(
820 "FA-001",
821 "Old Equipment",
822 AssetClass::MachineryEquipment,
823 "1000",
824 test_date(2020, 1, 1),
825 Decimal::from(10000),
826 );
827
828 asset.apply_depreciation(Decimal::from(5000));
830
831 let gain_loss = asset.calculate_disposal_gain_loss(Decimal::from(6000));
833 assert_eq!(gain_loss, Decimal::from(1000)); asset.dispose(test_date(2024, 1, 1), Decimal::from(6000));
837 assert_eq!(asset.status, AssetStatus::Disposed);
838 }
839
840 #[test]
841 fn test_land_not_depreciable() {
842 let asset = FixedAsset::new(
843 "FA-001",
844 "Land Parcel",
845 AssetClass::Land,
846 "1000",
847 test_date(2024, 1, 1),
848 Decimal::from(500000),
849 );
850
851 let dep = asset.calculate_monthly_depreciation(test_date(2024, 6, 1));
852 assert_eq!(dep, Decimal::ZERO);
853 }
854
855 #[test]
856 fn test_asset_pool() {
857 let mut pool = FixedAssetPool::new();
858
859 pool.add_asset(FixedAsset::new(
860 "FA-001",
861 "Computer 1",
862 AssetClass::ComputerHardware,
863 "1000",
864 test_date(2024, 1, 1),
865 Decimal::from(2000),
866 ));
867
868 pool.add_asset(FixedAsset::new(
869 "FA-002",
870 "Desk",
871 AssetClass::FurnitureFixtures,
872 "1000",
873 test_date(2024, 1, 1),
874 Decimal::from(500),
875 ));
876
877 assert_eq!(pool.len(), 2);
878 assert_eq!(pool.get_by_class(AssetClass::ComputerHardware).len(), 1);
879 assert_eq!(pool.get_by_company("1000").len(), 2);
880 }
881
882 #[test]
883 fn test_months_since_capitalization() {
884 let asset = FixedAsset::new(
885 "FA-001",
886 "Test",
887 AssetClass::MachineryEquipment,
888 "1000",
889 test_date(2024, 3, 15),
890 Decimal::from(10000),
891 );
892
893 assert_eq!(asset.months_since_capitalization(test_date(2024, 3, 1)), 0);
894 assert_eq!(asset.months_since_capitalization(test_date(2024, 6, 1)), 3);
895 assert_eq!(asset.months_since_capitalization(test_date(2025, 3, 1)), 12);
896 }
897
898 #[test]
901 fn test_macrs_tables_sum_to_100() {
902 let tables: &[(&str, &[&str])] = &[
903 ("3-year", MACRS_GDS_3_YEAR),
904 ("5-year", MACRS_GDS_5_YEAR),
905 ("7-year", MACRS_GDS_7_YEAR),
906 ("10-year", MACRS_GDS_10_YEAR),
907 ("15-year", MACRS_GDS_15_YEAR),
908 ("20-year", MACRS_GDS_20_YEAR),
909 ];
910
911 let tolerance = dec!(0.02);
912 let hundred = dec!(100);
913
914 for (label, table) in tables {
915 let sum: Decimal = table.iter().map(|s| macrs_pct(s)).sum();
916 let diff = (sum - hundred).abs();
917 assert!(
918 diff < tolerance,
919 "MACRS GDS {label} table sums to {sum}, expected ~100.0"
920 );
921 }
922 }
923
924 #[test]
925 fn test_macrs_table_for_life_mapping() {
926 assert_eq!(macrs_table_for_life(1).unwrap().len(), 4);
928 assert_eq!(macrs_table_for_life(3).unwrap().len(), 4);
929
930 assert_eq!(macrs_table_for_life(4).unwrap().len(), 6);
932 assert_eq!(macrs_table_for_life(5).unwrap().len(), 6);
933
934 assert_eq!(macrs_table_for_life(6).unwrap().len(), 8);
936 assert_eq!(macrs_table_for_life(7).unwrap().len(), 8);
937
938 assert_eq!(macrs_table_for_life(8).unwrap().len(), 11);
940 assert_eq!(macrs_table_for_life(10).unwrap().len(), 11);
941
942 assert_eq!(macrs_table_for_life(11).unwrap().len(), 16);
944 assert_eq!(macrs_table_for_life(15).unwrap().len(), 16);
945
946 assert_eq!(macrs_table_for_life(16).unwrap().len(), 21);
948 assert_eq!(macrs_table_for_life(20).unwrap().len(), 21);
949
950 assert!(macrs_table_for_life(0).is_none());
952 assert!(macrs_table_for_life(21).is_none());
953 assert!(macrs_table_for_life(100).is_none());
954 }
955
956 #[test]
957 fn test_macrs_depreciation_5_year_asset() {
958 let asset = FixedAsset::new(
959 "FA-MACRS",
960 "Vehicle",
961 AssetClass::Vehicles,
962 "1000",
963 test_date(2024, 1, 1),
964 Decimal::from(10000),
965 )
966 .with_useful_life_months(60) .with_depreciation_method(DepreciationMethod::Macrs);
968
969 assert_eq!(asset.macrs_depreciation(1), Decimal::from(2000));
971 assert_eq!(asset.macrs_depreciation(2), Decimal::from(3200));
973 assert_eq!(asset.macrs_depreciation(3), Decimal::from(1920));
975 assert_eq!(asset.macrs_depreciation(4), Decimal::from(1152));
977 assert_eq!(asset.macrs_depreciation(5), Decimal::from(1152));
979 assert_eq!(asset.macrs_depreciation(6), Decimal::from(576));
981 assert_eq!(asset.macrs_depreciation(7), Decimal::ZERO);
983 assert_eq!(asset.macrs_depreciation(0), Decimal::ZERO);
985 }
986
987 #[test]
988 fn test_macrs_calculate_monthly_depreciation_uses_tables() {
989 let asset = FixedAsset::new(
990 "FA-MACRS-M",
991 "Vehicle",
992 AssetClass::Vehicles,
993 "1000",
994 test_date(2024, 1, 1),
995 Decimal::from(12000),
996 )
997 .with_useful_life_months(60) .with_depreciation_method(DepreciationMethod::Macrs);
999
1000 let monthly_year1 = asset.calculate_monthly_depreciation(test_date(2024, 2, 1));
1002 assert_eq!(monthly_year1, Decimal::from(200));
1003
1004 let monthly_year2 = asset.calculate_monthly_depreciation(test_date(2025, 2, 1));
1006 assert_eq!(monthly_year2, Decimal::from(320));
1007 }
1008
1009 #[test]
1010 fn test_ddb_depreciation() {
1011 let asset = FixedAsset::new(
1012 "FA-DDB",
1013 "Server",
1014 AssetClass::ComputerHardware,
1015 "1000",
1016 test_date(2024, 1, 1),
1017 Decimal::from(3600),
1018 )
1019 .with_useful_life_months(36) .with_depreciation_method(DepreciationMethod::DoubleDecliningBalance);
1021
1022 let monthly = asset.ddb_depreciation();
1026 assert_eq!(monthly, Decimal::from(200));
1027 }
1028
1029 #[test]
1030 fn test_ddb_depreciation_with_accumulated() {
1031 let mut asset = FixedAsset::new(
1032 "FA-DDB2",
1033 "Laptop",
1034 AssetClass::ComputerHardware,
1035 "1000",
1036 test_date(2024, 1, 1),
1037 Decimal::from(1800),
1038 )
1039 .with_useful_life_months(36);
1040
1041 asset.apply_depreciation(Decimal::from(900));
1043
1044 let monthly = asset.ddb_depreciation();
1046 assert_eq!(monthly, Decimal::from(50));
1047 }
1048
1049 #[test]
1050 fn test_ddb_depreciation_respects_salvage() {
1051 let mut asset = FixedAsset::new(
1052 "FA-DDB3",
1053 "Printer",
1054 AssetClass::ComputerHardware,
1055 "1000",
1056 test_date(2024, 1, 1),
1057 Decimal::from(1800),
1058 )
1059 .with_useful_life_months(36)
1060 .with_salvage_value(Decimal::from(200));
1061
1062 asset.apply_depreciation(Decimal::from(1590));
1065
1066 let monthly = asset.ddb_depreciation();
1068 assert_eq!(monthly, Decimal::from(10));
1069 }
1070}