Skip to main content

datasynth_core/models/
fixed_asset.rs

1//! Fixed asset master data model.
2//!
3//! Provides fixed asset master data including depreciation schedules
4//! for realistic fixed asset accounting simulation.
5
6use std::str::FromStr;
7
8use chrono::{Datelike, NaiveDate};
9use rust_decimal::Decimal;
10use rust_decimal_macros::dec;
11use serde::{Deserialize, Serialize};
12
13/// MACRS GDS half-year convention percentages (IRS Publication 946).
14/// Stored as string slices so they convert to `Decimal` without floating-point artefacts.
15const 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
33/// Map useful life in years to the appropriate MACRS GDS depreciation table.
34fn 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
46/// Parse a MACRS percentage string into a `Decimal`.
47fn macrs_pct(s: &str) -> Decimal {
48    Decimal::from_str(s).unwrap_or(Decimal::ZERO)
49}
50
51/// Asset class for categorization and default depreciation rules.
52#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize, Default)]
53#[serde(rename_all = "snake_case")]
54pub enum AssetClass {
55    /// Buildings and structures
56    Buildings,
57    /// Building improvements
58    BuildingImprovements,
59    /// Land (typically non-depreciable)
60    Land,
61    /// Machinery and equipment
62    #[default]
63    MachineryEquipment,
64    /// Machinery (alias for MachineryEquipment)
65    Machinery,
66    /// Computer hardware
67    ComputerHardware,
68    /// IT Equipment (alias for ComputerHardware)
69    ItEquipment,
70    /// Office furniture and fixtures
71    FurnitureFixtures,
72    /// Furniture (alias for FurnitureFixtures)
73    Furniture,
74    /// Vehicles
75    Vehicles,
76    /// Leasehold improvements
77    LeaseholdImprovements,
78    /// Intangible assets (software, patents)
79    Intangibles,
80    /// Software
81    Software,
82    /// Construction in progress (not yet depreciating)
83    ConstructionInProgress,
84    /// Low-value assets
85    LowValueAssets,
86}
87
88impl AssetClass {
89    /// Get default useful life in months for this asset class.
90    pub fn default_useful_life_months(&self) -> u32 {
91        match self {
92            Self::Buildings | Self::BuildingImprovements => 480, // 40 years
93            Self::Land => 0,                                     // Not depreciated
94            Self::MachineryEquipment | Self::Machinery => 120,   // 10 years
95            Self::ComputerHardware | Self::ItEquipment => 36,    // 3 years
96            Self::FurnitureFixtures | Self::Furniture => 84,     // 7 years
97            Self::Vehicles => 60,                                // 5 years
98            Self::LeaseholdImprovements => 120,                  // 10 years (or lease term)
99            Self::Intangibles | Self::Software => 60,            // 5 years
100            Self::ConstructionInProgress => 0,                   // Not depreciated until complete
101            Self::LowValueAssets => 12,                          // 1 year
102        }
103    }
104
105    /// Check if this asset class is depreciable.
106    pub fn is_depreciable(&self) -> bool {
107        !matches!(self, Self::Land | Self::ConstructionInProgress)
108    }
109
110    /// Get default depreciation method for this asset class.
111    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/// Depreciation calculation method.
130#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize, Default)]
131#[serde(rename_all = "snake_case")]
132pub enum DepreciationMethod {
133    /// Straight-line depreciation
134    #[default]
135    StraightLine,
136    /// Double declining balance
137    DoubleDecliningBalance,
138    /// Sum of years' digits
139    SumOfYearsDigits,
140    /// Units of production
141    UnitsOfProduction,
142    /// MACRS (Modified Accelerated Cost Recovery System)
143    Macrs,
144    /// Immediate expense (low-value assets)
145    ImmediateExpense,
146    /// No depreciation (land, CIP)
147    None,
148}
149
150impl DepreciationMethod {
151    /// Calculate monthly depreciation amount.
152    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        // Don't depreciate below salvage value
168        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                // Cap at remaining book value above salvage
176                monthly_amount.min(net_book_value - salvage_value)
177            }
178
179            Self::DoubleDecliningBalance => {
180                // Double the straight-line rate applied to NBV
181                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                // Cap at remaining book value above salvage
185                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                // For units of production, caller should use specific production-based calculation
206                // This is a fallback that uses straight-line
207                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                // MACRS GDS half-year convention using IRS Publication 946 tables.
213                // MACRS ignores salvage value — the full acquisition cost is the depreciable base.
214                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                        // Cap so we don't go below zero NBV
223                        monthly_amount.min(net_book_value)
224                    } else {
225                        Decimal::ZERO
226                    }
227                } else {
228                    // Useful life outside MACRS table range — fall back to DDB
229                    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                // Full expense in first month
239                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/// Account determination rules for fixed asset transactions.
252#[derive(Debug, Clone, Serialize, Deserialize)]
253pub struct AssetAccountDetermination {
254    /// Asset balance sheet account
255    pub asset_account: String,
256    /// Accumulated depreciation account
257    pub accumulated_depreciation_account: String,
258    /// Depreciation expense account
259    pub depreciation_expense_account: String,
260    /// Gain on disposal account
261    pub gain_on_disposal_account: String,
262    /// Loss on disposal account
263    pub loss_on_disposal_account: String,
264    /// Clearing account for acquisitions
265    pub acquisition_clearing_account: String,
266    /// Gain/loss account (combined, for backward compatibility).
267    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/// Asset acquisition type.
285#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize, Default)]
286#[serde(rename_all = "snake_case")]
287pub enum AcquisitionType {
288    /// External purchase
289    #[default]
290    Purchase,
291    /// Self-constructed
292    SelfConstructed,
293    /// Transfer from another entity
294    Transfer,
295    /// Acquired in business combination
296    BusinessCombination,
297    /// Leased asset (finance lease)
298    FinanceLease,
299    /// Donation received
300    Donation,
301}
302
303/// Status of a fixed asset.
304#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize, Default)]
305#[serde(rename_all = "snake_case")]
306pub enum AssetStatus {
307    /// Under construction (CIP)
308    UnderConstruction,
309    /// Active and in use
310    #[default]
311    Active,
312    /// Temporarily not in use
313    Inactive,
314    /// Fully depreciated but still in use
315    FullyDepreciated,
316    /// Scheduled for disposal
317    PendingDisposal,
318    /// Disposed/retired
319    Disposed,
320}
321
322/// Fixed asset master data.
323#[derive(Debug, Clone, Serialize, Deserialize)]
324pub struct FixedAsset {
325    /// Asset ID (e.g., "FA-001234")
326    pub asset_id: String,
327
328    /// Asset sub-number (for component accounting)
329    pub sub_number: u16,
330
331    /// Asset description
332    pub description: String,
333
334    /// Asset class
335    pub asset_class: AssetClass,
336
337    /// Company code
338    pub company_code: String,
339
340    /// Cost center responsible for the asset
341    pub cost_center: Option<String>,
342
343    /// Location/plant
344    pub location: Option<String>,
345
346    /// Acquisition date
347    pub acquisition_date: NaiveDate,
348
349    /// Acquisition type
350    pub acquisition_type: AcquisitionType,
351
352    /// Original acquisition cost
353    pub acquisition_cost: Decimal,
354
355    /// Capitalized date (when depreciation starts)
356    pub capitalized_date: Option<NaiveDate>,
357
358    /// Depreciation method
359    pub depreciation_method: DepreciationMethod,
360
361    /// Useful life in months
362    pub useful_life_months: u32,
363
364    /// Salvage/residual value
365    pub salvage_value: Decimal,
366
367    /// Accumulated depreciation as of current period
368    pub accumulated_depreciation: Decimal,
369
370    /// Net book value (acquisition_cost - accumulated_depreciation)
371    pub net_book_value: Decimal,
372
373    /// Account determination rules
374    pub account_determination: AssetAccountDetermination,
375
376    /// Current status
377    pub status: AssetStatus,
378
379    /// Disposal date (if disposed)
380    pub disposal_date: Option<NaiveDate>,
381
382    /// Disposal proceeds (if disposed)
383    pub disposal_proceeds: Option<Decimal>,
384
385    /// Serial number (for tracking)
386    pub serial_number: Option<String>,
387
388    /// Manufacturer
389    pub manufacturer: Option<String>,
390
391    /// Model
392    pub model: Option<String>,
393
394    /// Warranty expiration date
395    pub warranty_expiration: Option<NaiveDate>,
396
397    /// Insurance policy number
398    pub insurance_policy: Option<String>,
399
400    /// Original PO number
401    pub purchase_order: Option<String>,
402
403    /// Vendor ID (who supplied the asset)
404    pub vendor_id: Option<String>,
405
406    /// Invoice reference
407    pub invoice_reference: Option<String>,
408}
409
410impl FixedAsset {
411    /// Create a new fixed asset.
412    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    /// Set cost center.
456    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    /// Set location.
462    pub fn with_location(mut self, location: impl Into<String>) -> Self {
463        self.location = Some(location.into());
464        self
465    }
466
467    /// Set salvage value.
468    pub fn with_salvage_value(mut self, salvage_value: Decimal) -> Self {
469        self.salvage_value = salvage_value;
470        self
471    }
472
473    /// Set depreciation method.
474    pub fn with_depreciation_method(mut self, method: DepreciationMethod) -> Self {
475        self.depreciation_method = method;
476        self
477    }
478
479    /// Set useful life.
480    pub fn with_useful_life_months(mut self, months: u32) -> Self {
481        self.useful_life_months = months;
482        self
483    }
484
485    /// Set vendor ID.
486    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    /// Calculate months since capitalization.
492    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    /// Calculate depreciation for a specific month.
504    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    /// Apply depreciation and update balances.
525    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        // Update status if fully depreciated
530        if self.net_book_value <= self.salvage_value && self.status == AssetStatus::Active {
531            self.status = AssetStatus::FullyDepreciated;
532        }
533    }
534
535    /// Calculate gain/loss on disposal.
536    pub fn calculate_disposal_gain_loss(&self, proceeds: Decimal) -> Decimal {
537        proceeds - self.net_book_value
538    }
539
540    /// Record disposal.
541    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    /// Check if asset is fully depreciated.
548    pub fn is_fully_depreciated(&self) -> bool {
549        self.net_book_value <= self.salvage_value
550    }
551
552    /// Calculate remaining useful life in months.
553    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    /// Calculate depreciation rate (annual percentage).
559    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    /// Return the annual MACRS depreciation for a given recovery year (1-indexed).
576    ///
577    /// Uses the IRS Publication 946 GDS half-year convention tables.
578    /// MACRS depreciation is based on the full acquisition cost (salvage value is ignored).
579    /// Returns `Decimal::ZERO` if the year is out of range or no table matches the useful life.
580    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    /// Return the monthly double-declining balance depreciation amount.
598    ///
599    /// The DDB rate is `2 / useful_life_months * 12` applied monthly against the current
600    /// net book value. The result is rounded to 2 decimal places and capped so the asset
601    /// does not depreciate below the salvage value.
602    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/// Pool of fixed assets for transaction generation.
620#[derive(Debug, Clone, Default, Serialize, Deserialize)]
621pub struct FixedAssetPool {
622    /// All fixed assets
623    pub assets: Vec<FixedAsset>,
624    /// Index by asset class
625    #[serde(skip)]
626    class_index: std::collections::HashMap<AssetClass, Vec<usize>>,
627    /// Index by company code
628    #[serde(skip)]
629    company_index: std::collections::HashMap<String, Vec<usize>>,
630}
631
632impl FixedAssetPool {
633    /// Create a new empty asset pool.
634    pub fn new() -> Self {
635        Self::default()
636    }
637
638    /// Add an asset to the pool.
639    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    /// Get all assets requiring depreciation for a given month.
654    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    /// Get mutable references to depreciable assets.
666    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    /// Get assets by company code.
678    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    /// Get assets by class.
686    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    /// Get asset by ID.
694    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    /// Get mutable asset by ID.
699    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    /// Calculate total depreciation for all assets in a period.
704    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    /// Get total net book value.
712    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    /// Get count of assets.
721    pub fn len(&self) -> usize {
722        self.assets.len()
723    }
724
725    /// Check if pool is empty.
726    pub fn is_empty(&self) -> bool {
727        self.assets.is_empty()
728    }
729
730    /// Rebuild indices after deserialization.
731    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); // 3 years for computers
771    }
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) // 7 years
784        .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)); // 8400 / 84 months
788    }
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        // Apply 11 months of depreciation (1000/12 ~= 83.33 each)
804        for _ in 0..11 {
805            let dep = Decimal::from(83);
806            asset.apply_depreciation(dep);
807        }
808
809        // At this point, NBV should be around 287, which is above salvage (200)
810        // Next depreciation should be limited to not go below salvage
811        let final_dep = asset.calculate_monthly_depreciation(test_date(2024, 12, 1));
812
813        // Verify we don't depreciate below salvage
814        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        // Simulate some depreciation
830        asset.apply_depreciation(Decimal::from(5000));
831
832        // Calculate gain/loss
833        let gain_loss = asset.calculate_disposal_gain_loss(Decimal::from(6000));
834        assert_eq!(gain_loss, Decimal::from(1000)); // Gain of 1000
835
836        // Record disposal
837        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    // ---- MACRS GDS table tests ----
900
901    #[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        // 1-3 years -> 3-year table (4 entries)
928        assert_eq!(macrs_table_for_life(1).unwrap().len(), 4);
929        assert_eq!(macrs_table_for_life(3).unwrap().len(), 4);
930
931        // 4-5 years -> 5-year table (6 entries)
932        assert_eq!(macrs_table_for_life(4).unwrap().len(), 6);
933        assert_eq!(macrs_table_for_life(5).unwrap().len(), 6);
934
935        // 6-7 years -> 7-year table (8 entries)
936        assert_eq!(macrs_table_for_life(6).unwrap().len(), 8);
937        assert_eq!(macrs_table_for_life(7).unwrap().len(), 8);
938
939        // 8-10 years -> 10-year table (11 entries)
940        assert_eq!(macrs_table_for_life(8).unwrap().len(), 11);
941        assert_eq!(macrs_table_for_life(10).unwrap().len(), 11);
942
943        // 11-15 years -> 15-year table (16 entries)
944        assert_eq!(macrs_table_for_life(11).unwrap().len(), 16);
945        assert_eq!(macrs_table_for_life(15).unwrap().len(), 16);
946
947        // 16-20 years -> 20-year table (21 entries)
948        assert_eq!(macrs_table_for_life(16).unwrap().len(), 21);
949        assert_eq!(macrs_table_for_life(20).unwrap().len(), 21);
950
951        // Out of range -> None
952        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) // 5 years
968        .with_depreciation_method(DepreciationMethod::Macrs);
969
970        // Year 1: 20.00% of 10,000 = 2,000
971        assert_eq!(asset.macrs_depreciation(1), Decimal::from(2000));
972        // Year 2: 32.00% of 10,000 = 3,200
973        assert_eq!(asset.macrs_depreciation(2), Decimal::from(3200));
974        // Year 3: 19.20% of 10,000 = 1,920
975        assert_eq!(asset.macrs_depreciation(3), Decimal::from(1920));
976        // Year 4: 11.52% of 10,000 = 1,152
977        assert_eq!(asset.macrs_depreciation(4), Decimal::from(1152));
978        // Year 5: 11.52% of 10,000 = 1,152
979        assert_eq!(asset.macrs_depreciation(5), Decimal::from(1152));
980        // Year 6: 5.76% of 10,000 = 576
981        assert_eq!(asset.macrs_depreciation(6), Decimal::from(576));
982        // Year 7: beyond table -> 0
983        assert_eq!(asset.macrs_depreciation(7), Decimal::ZERO);
984        // Year 0: invalid -> 0
985        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) // 5 years
999        .with_depreciation_method(DepreciationMethod::Macrs);
1000
1001        // Year 1 (months_elapsed 0..11): 20.00% of 12,000 = 2,400 annual -> 200/month
1002        let monthly_year1 = asset.calculate_monthly_depreciation(test_date(2024, 2, 1));
1003        assert_eq!(monthly_year1, Decimal::from(200));
1004
1005        // Year 2 (months_elapsed 12..23): 32.00% of 12,000 = 3,840 annual -> 320/month
1006        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) // 3 years
1021        .with_depreciation_method(DepreciationMethod::DoubleDecliningBalance);
1022
1023        // DDB annual rate = 2 / 36 * 12 = 2/3
1024        // Monthly rate = (2/3) / 12 = 1/18
1025        // First month: 3600 * (1/18) = 200
1026        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        // After accumulating 900 of depreciation, NBV = 900
1043        asset.apply_depreciation(Decimal::from(900));
1044
1045        // Monthly rate = 1/18, on NBV 900 -> 50
1046        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        // Accumulate until NBV is barely above salvage
1064        // NBV = 1800 - 1590 = 210, salvage = 200
1065        asset.apply_depreciation(Decimal::from(1590));
1066
1067        // DDB would compute 210 * (1/18) = 11.666..., but cap at NBV - salvage = 10
1068        let monthly = asset.ddb_depreciation();
1069        assert_eq!(monthly, Decimal::from(10));
1070    }
1071}