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)]
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); // 3 years for computers
770    }
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) // 7 years
783        .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)); // 8400 / 84 months
787    }
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        // Apply 11 months of depreciation (1000/12 ~= 83.33 each)
803        for _ in 0..11 {
804            let dep = Decimal::from(83);
805            asset.apply_depreciation(dep);
806        }
807
808        // At this point, NBV should be around 287, which is above salvage (200)
809        // Next depreciation should be limited to not go below salvage
810        let final_dep = asset.calculate_monthly_depreciation(test_date(2024, 12, 1));
811
812        // Verify we don't depreciate below salvage
813        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        // Simulate some depreciation
829        asset.apply_depreciation(Decimal::from(5000));
830
831        // Calculate gain/loss
832        let gain_loss = asset.calculate_disposal_gain_loss(Decimal::from(6000));
833        assert_eq!(gain_loss, Decimal::from(1000)); // Gain of 1000
834
835        // Record disposal
836        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    // ---- MACRS GDS table tests ----
899
900    #[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        // 1-3 years -> 3-year table (4 entries)
927        assert_eq!(macrs_table_for_life(1).unwrap().len(), 4);
928        assert_eq!(macrs_table_for_life(3).unwrap().len(), 4);
929
930        // 4-5 years -> 5-year table (6 entries)
931        assert_eq!(macrs_table_for_life(4).unwrap().len(), 6);
932        assert_eq!(macrs_table_for_life(5).unwrap().len(), 6);
933
934        // 6-7 years -> 7-year table (8 entries)
935        assert_eq!(macrs_table_for_life(6).unwrap().len(), 8);
936        assert_eq!(macrs_table_for_life(7).unwrap().len(), 8);
937
938        // 8-10 years -> 10-year table (11 entries)
939        assert_eq!(macrs_table_for_life(8).unwrap().len(), 11);
940        assert_eq!(macrs_table_for_life(10).unwrap().len(), 11);
941
942        // 11-15 years -> 15-year table (16 entries)
943        assert_eq!(macrs_table_for_life(11).unwrap().len(), 16);
944        assert_eq!(macrs_table_for_life(15).unwrap().len(), 16);
945
946        // 16-20 years -> 20-year table (21 entries)
947        assert_eq!(macrs_table_for_life(16).unwrap().len(), 21);
948        assert_eq!(macrs_table_for_life(20).unwrap().len(), 21);
949
950        // Out of range -> None
951        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) // 5 years
967        .with_depreciation_method(DepreciationMethod::Macrs);
968
969        // Year 1: 20.00% of 10,000 = 2,000
970        assert_eq!(asset.macrs_depreciation(1), Decimal::from(2000));
971        // Year 2: 32.00% of 10,000 = 3,200
972        assert_eq!(asset.macrs_depreciation(2), Decimal::from(3200));
973        // Year 3: 19.20% of 10,000 = 1,920
974        assert_eq!(asset.macrs_depreciation(3), Decimal::from(1920));
975        // Year 4: 11.52% of 10,000 = 1,152
976        assert_eq!(asset.macrs_depreciation(4), Decimal::from(1152));
977        // Year 5: 11.52% of 10,000 = 1,152
978        assert_eq!(asset.macrs_depreciation(5), Decimal::from(1152));
979        // Year 6: 5.76% of 10,000 = 576
980        assert_eq!(asset.macrs_depreciation(6), Decimal::from(576));
981        // Year 7: beyond table -> 0
982        assert_eq!(asset.macrs_depreciation(7), Decimal::ZERO);
983        // Year 0: invalid -> 0
984        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) // 5 years
998        .with_depreciation_method(DepreciationMethod::Macrs);
999
1000        // Year 1 (months_elapsed 0..11): 20.00% of 12,000 = 2,400 annual -> 200/month
1001        let monthly_year1 = asset.calculate_monthly_depreciation(test_date(2024, 2, 1));
1002        assert_eq!(monthly_year1, Decimal::from(200));
1003
1004        // Year 2 (months_elapsed 12..23): 32.00% of 12,000 = 3,840 annual -> 320/month
1005        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) // 3 years
1020        .with_depreciation_method(DepreciationMethod::DoubleDecliningBalance);
1021
1022        // DDB annual rate = 2 / 36 * 12 = 2/3
1023        // Monthly rate = (2/3) / 12 = 1/18
1024        // First month: 3600 * (1/18) = 200
1025        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        // After accumulating 900 of depreciation, NBV = 900
1042        asset.apply_depreciation(Decimal::from(900));
1043
1044        // Monthly rate = 1/18, on NBV 900 -> 50
1045        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        // Accumulate until NBV is barely above salvage
1063        // NBV = 1800 - 1590 = 210, salvage = 200
1064        asset.apply_depreciation(Decimal::from(1590));
1065
1066        // DDB would compute 210 * (1/18) = 11.666..., but cap at NBV - salvage = 10
1067        let monthly = asset.ddb_depreciation();
1068        assert_eq!(monthly, Decimal::from(10));
1069    }
1070}