1use chrono::{Datelike, NaiveDate};
7use rust_decimal::Decimal;
8use rust_decimal_macros::dec;
9use serde::{Deserialize, Serialize};
10
11#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize, Default)]
13#[serde(rename_all = "snake_case")]
14pub enum AssetClass {
15 Buildings,
17 BuildingImprovements,
19 Land,
21 #[default]
23 MachineryEquipment,
24 Machinery,
26 ComputerHardware,
28 ItEquipment,
30 FurnitureFixtures,
32 Furniture,
34 Vehicles,
36 LeaseholdImprovements,
38 Intangibles,
40 Software,
42 ConstructionInProgress,
44 LowValueAssets,
46}
47
48impl AssetClass {
49 pub fn default_useful_life_months(&self) -> u32 {
51 match self {
52 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, }
63 }
64
65 pub fn is_depreciable(&self) -> bool {
67 !matches!(self, Self::Land | Self::ConstructionInProgress)
68 }
69
70 pub fn default_depreciation_method(&self) -> DepreciationMethod {
72 match self {
73 Self::Buildings | Self::BuildingImprovements | Self::LeaseholdImprovements => {
74 DepreciationMethod::StraightLine
75 }
76 Self::MachineryEquipment | Self::Machinery => DepreciationMethod::StraightLine,
77 Self::ComputerHardware | Self::ItEquipment => {
78 DepreciationMethod::DoubleDecliningBalance
79 }
80 Self::FurnitureFixtures | Self::Furniture => DepreciationMethod::StraightLine,
81 Self::Vehicles => DepreciationMethod::DoubleDecliningBalance,
82 Self::Intangibles | Self::Software => DepreciationMethod::StraightLine,
83 Self::LowValueAssets => DepreciationMethod::ImmediateExpense,
84 Self::Land | Self::ConstructionInProgress => DepreciationMethod::None,
85 }
86 }
87}
88
89#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize, Default)]
91#[serde(rename_all = "snake_case")]
92pub enum DepreciationMethod {
93 #[default]
95 StraightLine,
96 DoubleDecliningBalance,
98 SumOfYearsDigits,
100 UnitsOfProduction,
102 Macrs,
104 ImmediateExpense,
106 None,
108}
109
110impl DepreciationMethod {
111 pub fn calculate_monthly_depreciation(
113 &self,
114 acquisition_cost: Decimal,
115 salvage_value: Decimal,
116 useful_life_months: u32,
117 months_elapsed: u32,
118 accumulated_depreciation: Decimal,
119 ) -> Decimal {
120 if useful_life_months == 0 {
121 return Decimal::ZERO;
122 }
123
124 let depreciable_base = acquisition_cost - salvage_value;
125 let net_book_value = acquisition_cost - accumulated_depreciation;
126
127 if net_book_value <= salvage_value {
129 return Decimal::ZERO;
130 }
131
132 match self {
133 Self::StraightLine => {
134 let monthly_amount = depreciable_base / Decimal::from(useful_life_months);
135 monthly_amount.min(net_book_value - salvage_value)
137 }
138
139 Self::DoubleDecliningBalance => {
140 let annual_rate = Decimal::from(2) / Decimal::from(useful_life_months) * dec!(12);
142 let monthly_rate = annual_rate / dec!(12);
143 let depreciation = net_book_value * monthly_rate;
144 depreciation.min(net_book_value - salvage_value)
146 }
147
148 Self::SumOfYearsDigits => {
149 let years_total = useful_life_months / 12;
150 let sum_of_years: u32 = (1..=years_total).sum();
151 let current_year = (months_elapsed / 12) + 1;
152 let remaining_years = years_total.saturating_sub(current_year) + 1;
153
154 if sum_of_years == 0 || remaining_years == 0 {
155 return Decimal::ZERO;
156 }
157
158 let year_fraction = Decimal::from(remaining_years) / Decimal::from(sum_of_years);
159 let annual_depreciation = depreciable_base * year_fraction;
160 let monthly_amount = annual_depreciation / dec!(12);
161 monthly_amount.min(net_book_value - salvage_value)
162 }
163
164 Self::UnitsOfProduction => {
165 let monthly_amount = depreciable_base / Decimal::from(useful_life_months);
168 monthly_amount.min(net_book_value - salvage_value)
169 }
170
171 Self::Macrs => {
172 let annual_rate = Decimal::from(2) / Decimal::from(useful_life_months) * dec!(12);
175 let monthly_rate = annual_rate / dec!(12);
176 let depreciation = net_book_value * monthly_rate;
177 depreciation.min(net_book_value - salvage_value)
178 }
179
180 Self::ImmediateExpense => {
181 if months_elapsed == 0 {
183 depreciable_base
184 } else {
185 Decimal::ZERO
186 }
187 }
188
189 Self::None => Decimal::ZERO,
190 }
191 }
192}
193
194#[derive(Debug, Clone, Serialize, Deserialize)]
196pub struct AssetAccountDetermination {
197 pub asset_account: String,
199 pub accumulated_depreciation_account: String,
201 pub depreciation_expense_account: String,
203 pub gain_on_disposal_account: String,
205 pub loss_on_disposal_account: String,
207 pub acquisition_clearing_account: String,
209 pub gain_loss_account: String,
211}
212
213impl Default for AssetAccountDetermination {
214 fn default() -> Self {
215 Self {
216 asset_account: "160000".to_string(),
217 accumulated_depreciation_account: "169000".to_string(),
218 depreciation_expense_account: "640000".to_string(),
219 gain_on_disposal_account: "810000".to_string(),
220 loss_on_disposal_account: "840000".to_string(),
221 acquisition_clearing_account: "299000".to_string(),
222 gain_loss_account: "810000".to_string(),
223 }
224 }
225}
226
227#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize, Default)]
229#[serde(rename_all = "snake_case")]
230pub enum AcquisitionType {
231 #[default]
233 Purchase,
234 SelfConstructed,
236 Transfer,
238 BusinessCombination,
240 FinanceLease,
242 Donation,
244}
245
246#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize, Default)]
248#[serde(rename_all = "snake_case")]
249pub enum AssetStatus {
250 UnderConstruction,
252 #[default]
254 Active,
255 Inactive,
257 FullyDepreciated,
259 PendingDisposal,
261 Disposed,
263}
264
265#[derive(Debug, Clone, Serialize, Deserialize)]
267pub struct FixedAsset {
268 pub asset_id: String,
270
271 pub sub_number: u16,
273
274 pub description: String,
276
277 pub asset_class: AssetClass,
279
280 pub company_code: String,
282
283 pub cost_center: Option<String>,
285
286 pub location: Option<String>,
288
289 pub acquisition_date: NaiveDate,
291
292 pub acquisition_type: AcquisitionType,
294
295 pub acquisition_cost: Decimal,
297
298 pub capitalized_date: Option<NaiveDate>,
300
301 pub depreciation_method: DepreciationMethod,
303
304 pub useful_life_months: u32,
306
307 pub salvage_value: Decimal,
309
310 pub accumulated_depreciation: Decimal,
312
313 pub net_book_value: Decimal,
315
316 pub account_determination: AssetAccountDetermination,
318
319 pub status: AssetStatus,
321
322 pub disposal_date: Option<NaiveDate>,
324
325 pub disposal_proceeds: Option<Decimal>,
327
328 pub serial_number: Option<String>,
330
331 pub manufacturer: Option<String>,
333
334 pub model: Option<String>,
336
337 pub warranty_expiration: Option<NaiveDate>,
339
340 pub insurance_policy: Option<String>,
342
343 pub purchase_order: Option<String>,
345
346 pub vendor_id: Option<String>,
348
349 pub invoice_reference: Option<String>,
351}
352
353impl FixedAsset {
354 pub fn new(
356 asset_id: impl Into<String>,
357 description: impl Into<String>,
358 asset_class: AssetClass,
359 company_code: impl Into<String>,
360 acquisition_date: NaiveDate,
361 acquisition_cost: Decimal,
362 ) -> Self {
363 let useful_life_months = asset_class.default_useful_life_months();
364 let depreciation_method = asset_class.default_depreciation_method();
365
366 Self {
367 asset_id: asset_id.into(),
368 sub_number: 0,
369 description: description.into(),
370 asset_class,
371 company_code: company_code.into(),
372 cost_center: None,
373 location: None,
374 acquisition_date,
375 acquisition_type: AcquisitionType::Purchase,
376 acquisition_cost,
377 capitalized_date: Some(acquisition_date),
378 depreciation_method,
379 useful_life_months,
380 salvage_value: Decimal::ZERO,
381 accumulated_depreciation: Decimal::ZERO,
382 net_book_value: acquisition_cost,
383 account_determination: AssetAccountDetermination::default(),
384 status: AssetStatus::Active,
385 disposal_date: None,
386 disposal_proceeds: None,
387 serial_number: None,
388 manufacturer: None,
389 model: None,
390 warranty_expiration: None,
391 insurance_policy: None,
392 purchase_order: None,
393 vendor_id: None,
394 invoice_reference: None,
395 }
396 }
397
398 pub fn with_cost_center(mut self, cost_center: impl Into<String>) -> Self {
400 self.cost_center = Some(cost_center.into());
401 self
402 }
403
404 pub fn with_location(mut self, location: impl Into<String>) -> Self {
406 self.location = Some(location.into());
407 self
408 }
409
410 pub fn with_salvage_value(mut self, salvage_value: Decimal) -> Self {
412 self.salvage_value = salvage_value;
413 self
414 }
415
416 pub fn with_depreciation_method(mut self, method: DepreciationMethod) -> Self {
418 self.depreciation_method = method;
419 self
420 }
421
422 pub fn with_useful_life_months(mut self, months: u32) -> Self {
424 self.useful_life_months = months;
425 self
426 }
427
428 pub fn with_vendor(mut self, vendor_id: impl Into<String>) -> Self {
430 self.vendor_id = Some(vendor_id.into());
431 self
432 }
433
434 pub fn months_since_capitalization(&self, as_of_date: NaiveDate) -> u32 {
436 let cap_date = self.capitalized_date.unwrap_or(self.acquisition_date);
437 if as_of_date < cap_date {
438 return 0;
439 }
440
441 let years = as_of_date.year() - cap_date.year();
442 let months = as_of_date.month() as i32 - cap_date.month() as i32;
443 ((years * 12) + months).max(0) as u32
444 }
445
446 pub fn calculate_monthly_depreciation(&self, as_of_date: NaiveDate) -> Decimal {
448 if !self.asset_class.is_depreciable() {
449 return Decimal::ZERO;
450 }
451
452 if self.status == AssetStatus::Disposed {
453 return Decimal::ZERO;
454 }
455
456 let months_elapsed = self.months_since_capitalization(as_of_date);
457
458 self.depreciation_method.calculate_monthly_depreciation(
459 self.acquisition_cost,
460 self.salvage_value,
461 self.useful_life_months,
462 months_elapsed,
463 self.accumulated_depreciation,
464 )
465 }
466
467 pub fn apply_depreciation(&mut self, depreciation_amount: Decimal) {
469 self.accumulated_depreciation += depreciation_amount;
470 self.net_book_value = self.acquisition_cost - self.accumulated_depreciation;
471
472 if self.net_book_value <= self.salvage_value && self.status == AssetStatus::Active {
474 self.status = AssetStatus::FullyDepreciated;
475 }
476 }
477
478 pub fn calculate_disposal_gain_loss(&self, proceeds: Decimal) -> Decimal {
480 proceeds - self.net_book_value
481 }
482
483 pub fn dispose(&mut self, disposal_date: NaiveDate, proceeds: Decimal) {
485 self.disposal_date = Some(disposal_date);
486 self.disposal_proceeds = Some(proceeds);
487 self.status = AssetStatus::Disposed;
488 }
489
490 pub fn is_fully_depreciated(&self) -> bool {
492 self.net_book_value <= self.salvage_value
493 }
494
495 pub fn remaining_useful_life_months(&self, as_of_date: NaiveDate) -> u32 {
497 let months_elapsed = self.months_since_capitalization(as_of_date);
498 self.useful_life_months.saturating_sub(months_elapsed)
499 }
500
501 pub fn annual_depreciation_rate(&self) -> Decimal {
503 if self.useful_life_months == 0 {
504 return Decimal::ZERO;
505 }
506
507 match self.depreciation_method {
508 DepreciationMethod::StraightLine => {
509 Decimal::from(12) / Decimal::from(self.useful_life_months) * dec!(100)
510 }
511 DepreciationMethod::DoubleDecliningBalance => {
512 Decimal::from(24) / Decimal::from(self.useful_life_months) * dec!(100)
513 }
514 _ => Decimal::from(12) / Decimal::from(self.useful_life_months) * dec!(100),
515 }
516 }
517}
518
519#[derive(Debug, Clone, Default, Serialize, Deserialize)]
521pub struct FixedAssetPool {
522 pub assets: Vec<FixedAsset>,
524 #[serde(skip)]
526 class_index: std::collections::HashMap<AssetClass, Vec<usize>>,
527 #[serde(skip)]
529 company_index: std::collections::HashMap<String, Vec<usize>>,
530}
531
532impl FixedAssetPool {
533 pub fn new() -> Self {
535 Self::default()
536 }
537
538 pub fn add_asset(&mut self, asset: FixedAsset) {
540 let idx = self.assets.len();
541 let asset_class = asset.asset_class;
542 let company_code = asset.company_code.clone();
543
544 self.assets.push(asset);
545
546 self.class_index.entry(asset_class).or_default().push(idx);
547 self.company_index
548 .entry(company_code)
549 .or_default()
550 .push(idx);
551 }
552
553 pub fn get_depreciable_assets(&self) -> Vec<&FixedAsset> {
555 self.assets
556 .iter()
557 .filter(|a| {
558 a.asset_class.is_depreciable()
559 && a.status == AssetStatus::Active
560 && !a.is_fully_depreciated()
561 })
562 .collect()
563 }
564
565 pub fn get_depreciable_assets_mut(&mut self) -> Vec<&mut FixedAsset> {
567 self.assets
568 .iter_mut()
569 .filter(|a| {
570 a.asset_class.is_depreciable()
571 && a.status == AssetStatus::Active
572 && !a.is_fully_depreciated()
573 })
574 .collect()
575 }
576
577 pub fn get_by_company(&self, company_code: &str) -> Vec<&FixedAsset> {
579 self.company_index
580 .get(company_code)
581 .map(|indices| indices.iter().map(|&i| &self.assets[i]).collect())
582 .unwrap_or_default()
583 }
584
585 pub fn get_by_class(&self, asset_class: AssetClass) -> Vec<&FixedAsset> {
587 self.class_index
588 .get(&asset_class)
589 .map(|indices| indices.iter().map(|&i| &self.assets[i]).collect())
590 .unwrap_or_default()
591 }
592
593 pub fn get_by_id(&self, asset_id: &str) -> Option<&FixedAsset> {
595 self.assets.iter().find(|a| a.asset_id == asset_id)
596 }
597
598 pub fn get_by_id_mut(&mut self, asset_id: &str) -> Option<&mut FixedAsset> {
600 self.assets.iter_mut().find(|a| a.asset_id == asset_id)
601 }
602
603 pub fn calculate_period_depreciation(&self, as_of_date: NaiveDate) -> Decimal {
605 self.get_depreciable_assets()
606 .iter()
607 .map(|a| a.calculate_monthly_depreciation(as_of_date))
608 .sum()
609 }
610
611 pub fn total_net_book_value(&self) -> Decimal {
613 self.assets
614 .iter()
615 .filter(|a| a.status != AssetStatus::Disposed)
616 .map(|a| a.net_book_value)
617 .sum()
618 }
619
620 pub fn len(&self) -> usize {
622 self.assets.len()
623 }
624
625 pub fn is_empty(&self) -> bool {
627 self.assets.is_empty()
628 }
629
630 pub fn rebuild_indices(&mut self) {
632 self.class_index.clear();
633 self.company_index.clear();
634
635 for (idx, asset) in self.assets.iter().enumerate() {
636 self.class_index
637 .entry(asset.asset_class)
638 .or_default()
639 .push(idx);
640 self.company_index
641 .entry(asset.company_code.clone())
642 .or_default()
643 .push(idx);
644 }
645 }
646}
647
648#[cfg(test)]
649mod tests {
650 use super::*;
651
652 fn test_date(year: i32, month: u32, day: u32) -> NaiveDate {
653 NaiveDate::from_ymd_opt(year, month, day).unwrap()
654 }
655
656 #[test]
657 fn test_asset_creation() {
658 let asset = FixedAsset::new(
659 "FA-001",
660 "Office Computer",
661 AssetClass::ComputerHardware,
662 "1000",
663 test_date(2024, 1, 1),
664 Decimal::from(2000),
665 );
666
667 assert_eq!(asset.asset_id, "FA-001");
668 assert_eq!(asset.acquisition_cost, Decimal::from(2000));
669 assert_eq!(asset.useful_life_months, 36); }
671
672 #[test]
673 fn test_straight_line_depreciation() {
674 let asset = FixedAsset::new(
675 "FA-001",
676 "Office Equipment",
677 AssetClass::FurnitureFixtures,
678 "1000",
679 test_date(2024, 1, 1),
680 Decimal::from(8400),
681 )
682 .with_useful_life_months(84) .with_depreciation_method(DepreciationMethod::StraightLine);
684
685 let monthly_dep = asset.calculate_monthly_depreciation(test_date(2024, 2, 1));
686 assert_eq!(monthly_dep, Decimal::from(100)); }
688
689 #[test]
690 fn test_salvage_value_limit() {
691 let mut asset = FixedAsset::new(
692 "FA-001",
693 "Test Asset",
694 AssetClass::MachineryEquipment,
695 "1000",
696 test_date(2024, 1, 1),
697 Decimal::from(1200),
698 )
699 .with_useful_life_months(12)
700 .with_salvage_value(Decimal::from(200));
701
702 for _ in 0..11 {
704 let dep = Decimal::from(83);
705 asset.apply_depreciation(dep);
706 }
707
708 let final_dep = asset.calculate_monthly_depreciation(test_date(2024, 12, 1));
711
712 asset.apply_depreciation(final_dep);
714 assert!(asset.net_book_value >= asset.salvage_value);
715 }
716
717 #[test]
718 fn test_disposal() {
719 let mut asset = FixedAsset::new(
720 "FA-001",
721 "Old Equipment",
722 AssetClass::MachineryEquipment,
723 "1000",
724 test_date(2020, 1, 1),
725 Decimal::from(10000),
726 );
727
728 asset.apply_depreciation(Decimal::from(5000));
730
731 let gain_loss = asset.calculate_disposal_gain_loss(Decimal::from(6000));
733 assert_eq!(gain_loss, Decimal::from(1000)); asset.dispose(test_date(2024, 1, 1), Decimal::from(6000));
737 assert_eq!(asset.status, AssetStatus::Disposed);
738 }
739
740 #[test]
741 fn test_land_not_depreciable() {
742 let asset = FixedAsset::new(
743 "FA-001",
744 "Land Parcel",
745 AssetClass::Land,
746 "1000",
747 test_date(2024, 1, 1),
748 Decimal::from(500000),
749 );
750
751 let dep = asset.calculate_monthly_depreciation(test_date(2024, 6, 1));
752 assert_eq!(dep, Decimal::ZERO);
753 }
754
755 #[test]
756 fn test_asset_pool() {
757 let mut pool = FixedAssetPool::new();
758
759 pool.add_asset(FixedAsset::new(
760 "FA-001",
761 "Computer 1",
762 AssetClass::ComputerHardware,
763 "1000",
764 test_date(2024, 1, 1),
765 Decimal::from(2000),
766 ));
767
768 pool.add_asset(FixedAsset::new(
769 "FA-002",
770 "Desk",
771 AssetClass::FurnitureFixtures,
772 "1000",
773 test_date(2024, 1, 1),
774 Decimal::from(500),
775 ));
776
777 assert_eq!(pool.len(), 2);
778 assert_eq!(pool.get_by_class(AssetClass::ComputerHardware).len(), 1);
779 assert_eq!(pool.get_by_company("1000").len(), 2);
780 }
781
782 #[test]
783 fn test_months_since_capitalization() {
784 let asset = FixedAsset::new(
785 "FA-001",
786 "Test",
787 AssetClass::MachineryEquipment,
788 "1000",
789 test_date(2024, 3, 15),
790 Decimal::from(10000),
791 );
792
793 assert_eq!(asset.months_since_capitalization(test_date(2024, 3, 1)), 0);
794 assert_eq!(asset.months_since_capitalization(test_date(2024, 6, 1)), 3);
795 assert_eq!(asset.months_since_capitalization(test_date(2025, 3, 1)), 12);
796 }
797}