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    /// German declining balance (Degressiv): min(3× straight-line rate, 30%) on NBV,
147    /// with automatic switch to straight-line when SL exceeds Degressiv.
148    /// Per EStG §7(2) / Wachstumschancengesetz (Jul 2025 – Dec 2027).
149    Degressiv,
150    /// No depreciation (land, CIP)
151    None,
152}
153
154impl DepreciationMethod {
155    /// Calculate monthly depreciation amount.
156    pub fn calculate_monthly_depreciation(
157        &self,
158        acquisition_cost: Decimal,
159        salvage_value: Decimal,
160        useful_life_months: u32,
161        months_elapsed: u32,
162        accumulated_depreciation: Decimal,
163    ) -> Decimal {
164        if useful_life_months == 0 {
165            return Decimal::ZERO;
166        }
167
168        let depreciable_base = acquisition_cost - salvage_value;
169        let net_book_value = acquisition_cost - accumulated_depreciation;
170
171        // Don't depreciate below salvage value
172        if net_book_value <= salvage_value {
173            return Decimal::ZERO;
174        }
175
176        match self {
177            Self::StraightLine => {
178                let monthly_amount = depreciable_base / Decimal::from(useful_life_months);
179                // Cap at remaining book value above salvage
180                monthly_amount.min(net_book_value - salvage_value)
181            }
182
183            Self::DoubleDecliningBalance => {
184                // Double the straight-line rate applied to NBV
185                let annual_rate = Decimal::from(2) / Decimal::from(useful_life_months) * dec!(12);
186                let monthly_rate = annual_rate / dec!(12);
187                let depreciation = net_book_value * monthly_rate;
188                // Cap at remaining book value above salvage
189                depreciation.min(net_book_value - salvage_value)
190            }
191
192            Self::SumOfYearsDigits => {
193                let years_total = useful_life_months / 12;
194                let sum_of_years: u32 = (1..=years_total).sum();
195                let current_year = (months_elapsed / 12) + 1;
196                let remaining_years = years_total.saturating_sub(current_year) + 1;
197
198                if sum_of_years == 0 || remaining_years == 0 {
199                    return Decimal::ZERO;
200                }
201
202                let year_fraction = Decimal::from(remaining_years) / Decimal::from(sum_of_years);
203                let annual_depreciation = depreciable_base * year_fraction;
204                let monthly_amount = annual_depreciation / dec!(12);
205                monthly_amount.min(net_book_value - salvage_value)
206            }
207
208            Self::UnitsOfProduction => {
209                // For units of production, caller should use specific production-based calculation
210                // This is a fallback that uses straight-line
211                let monthly_amount = depreciable_base / Decimal::from(useful_life_months);
212                monthly_amount.min(net_book_value - salvage_value)
213            }
214
215            Self::Macrs => {
216                // MACRS GDS half-year convention using IRS Publication 946 tables.
217                // MACRS ignores salvage value — the full acquisition cost is the depreciable base.
218                let useful_life_years = useful_life_months / 12;
219                let current_year = (months_elapsed / 12) as usize;
220
221                if let Some(table) = macrs_table_for_life(useful_life_years) {
222                    if current_year < table.len() {
223                        let pct = macrs_pct(table[current_year]);
224                        let annual_depreciation = acquisition_cost * pct / dec!(100);
225                        let monthly_amount = annual_depreciation / dec!(12);
226                        // Cap so we don't go below zero NBV
227                        monthly_amount.min(net_book_value)
228                    } else {
229                        Decimal::ZERO
230                    }
231                } else {
232                    // Useful life outside MACRS table range — fall back to DDB
233                    let annual_rate =
234                        Decimal::from(2) / Decimal::from(useful_life_months) * dec!(12);
235                    let monthly_rate = annual_rate / dec!(12);
236                    let depreciation = net_book_value * monthly_rate;
237                    depreciation.min(net_book_value - salvage_value)
238                }
239            }
240
241            Self::ImmediateExpense => {
242                // Full expense in first month
243                if months_elapsed == 0 {
244                    depreciable_base
245                } else {
246                    Decimal::ZERO
247                }
248            }
249
250            Self::Degressiv => {
251                // German declining balance: min(3 × SL rate, 30%) on NBV,
252                // with automatic switch to straight-line when SL > Degressiv.
253                let useful_life_years = useful_life_months / 12;
254                if useful_life_years == 0 {
255                    return Decimal::ZERO;
256                }
257
258                let sl_annual_rate = Decimal::ONE / Decimal::from(useful_life_years);
259                // Degressiv rate: 3× straight-line, capped at 30%
260                let degressiv_rate = (sl_annual_rate * Decimal::from(3)).min(dec!(0.30));
261                let degressiv_monthly = net_book_value * degressiv_rate / dec!(12);
262
263                // Straight-line on remaining: depreciable base spread over remaining months
264                let remaining_months = useful_life_months.saturating_sub(months_elapsed);
265                let sl_monthly = if remaining_months > 0 {
266                    (net_book_value - salvage_value) / Decimal::from(remaining_months)
267                } else {
268                    Decimal::ZERO
269                };
270
271                // Switch to SL when it exceeds Degressiv
272                let monthly_amount = degressiv_monthly.max(sl_monthly);
273                monthly_amount
274                    .min(net_book_value - salvage_value)
275                    .max(Decimal::ZERO)
276            }
277
278            Self::None => Decimal::ZERO,
279        }
280    }
281}
282
283/// Account determination rules for fixed asset transactions.
284#[derive(Debug, Clone, Serialize, Deserialize)]
285pub struct AssetAccountDetermination {
286    /// Asset balance sheet account
287    pub asset_account: String,
288    /// Accumulated depreciation account
289    pub accumulated_depreciation_account: String,
290    /// Depreciation expense account
291    pub depreciation_expense_account: String,
292    /// Gain on disposal account
293    pub gain_on_disposal_account: String,
294    /// Loss on disposal account
295    pub loss_on_disposal_account: String,
296    /// Clearing account for acquisitions
297    pub acquisition_clearing_account: String,
298    /// Gain/loss account (combined, for backward compatibility).
299    pub gain_loss_account: String,
300}
301
302impl Default for AssetAccountDetermination {
303    fn default() -> Self {
304        Self {
305            asset_account: "160000".to_string(),
306            accumulated_depreciation_account: "169000".to_string(),
307            depreciation_expense_account: "640000".to_string(),
308            gain_on_disposal_account: "810000".to_string(),
309            loss_on_disposal_account: "840000".to_string(),
310            acquisition_clearing_account: "299000".to_string(),
311            gain_loss_account: "810000".to_string(),
312        }
313    }
314}
315
316/// Asset acquisition type.
317#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize, Default)]
318#[serde(rename_all = "snake_case")]
319pub enum AcquisitionType {
320    /// External purchase
321    #[default]
322    Purchase,
323    /// Self-constructed
324    SelfConstructed,
325    /// Transfer from another entity
326    Transfer,
327    /// Acquired in business combination
328    BusinessCombination,
329    /// Leased asset (finance lease)
330    FinanceLease,
331    /// Donation received
332    Donation,
333}
334
335/// Status of a fixed asset.
336#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize, Default)]
337#[serde(rename_all = "snake_case")]
338pub enum AssetStatus {
339    /// Under construction (CIP)
340    UnderConstruction,
341    /// Active and in use
342    #[default]
343    Active,
344    /// Temporarily not in use
345    Inactive,
346    /// Fully depreciated but still in use
347    FullyDepreciated,
348    /// Scheduled for disposal
349    PendingDisposal,
350    /// Disposed/retired
351    Disposed,
352}
353
354/// Fixed asset master data.
355#[derive(Debug, Clone, Serialize, Deserialize)]
356pub struct FixedAsset {
357    /// Asset ID (e.g., "FA-001234")
358    pub asset_id: String,
359
360    /// Asset sub-number (for component accounting)
361    pub sub_number: u16,
362
363    /// Asset description
364    pub description: String,
365
366    /// Asset class
367    pub asset_class: AssetClass,
368
369    /// Company code
370    pub company_code: String,
371
372    /// Cost center responsible for the asset
373    pub cost_center: Option<String>,
374
375    /// Location/plant
376    pub location: Option<String>,
377
378    /// Acquisition date
379    pub acquisition_date: NaiveDate,
380
381    /// Acquisition type
382    pub acquisition_type: AcquisitionType,
383
384    /// Original acquisition cost
385    pub acquisition_cost: Decimal,
386
387    /// Capitalized date (when depreciation starts)
388    pub capitalized_date: Option<NaiveDate>,
389
390    /// Depreciation method
391    pub depreciation_method: DepreciationMethod,
392
393    /// Useful life in months
394    pub useful_life_months: u32,
395
396    /// Salvage/residual value
397    pub salvage_value: Decimal,
398
399    /// Accumulated depreciation as of current period
400    pub accumulated_depreciation: Decimal,
401
402    /// Net book value (acquisition_cost - accumulated_depreciation)
403    pub net_book_value: Decimal,
404
405    /// Account determination rules
406    pub account_determination: AssetAccountDetermination,
407
408    /// Current status
409    pub status: AssetStatus,
410
411    /// Disposal date (if disposed)
412    pub disposal_date: Option<NaiveDate>,
413
414    /// Disposal proceeds (if disposed)
415    pub disposal_proceeds: Option<Decimal>,
416
417    /// Serial number (for tracking)
418    pub serial_number: Option<String>,
419
420    /// Manufacturer
421    pub manufacturer: Option<String>,
422
423    /// Model
424    pub model: Option<String>,
425
426    /// Warranty expiration date
427    pub warranty_expiration: Option<NaiveDate>,
428
429    /// Insurance policy number
430    pub insurance_policy: Option<String>,
431
432    /// Original PO number
433    pub purchase_order: Option<String>,
434
435    /// Vendor ID (who supplied the asset)
436    pub vendor_id: Option<String>,
437
438    /// Invoice reference
439    pub invoice_reference: Option<String>,
440
441    /// Whether this asset is a GWG (geringwertiges Wirtschaftsgut) under German tax law.
442    /// Assets with acquisition cost ≤ 800 EUR are eligible for immediate full expensing
443    /// per EStG §6(2).
444    #[serde(default, skip_serializing_if = "Option::is_none")]
445    pub is_gwg: Option<bool>,
446}
447
448impl FixedAsset {
449    /// Create a new fixed asset.
450    pub fn new(
451        asset_id: impl Into<String>,
452        description: impl Into<String>,
453        asset_class: AssetClass,
454        company_code: impl Into<String>,
455        acquisition_date: NaiveDate,
456        acquisition_cost: Decimal,
457    ) -> Self {
458        let useful_life_months = asset_class.default_useful_life_months();
459        let depreciation_method = asset_class.default_depreciation_method();
460
461        Self {
462            asset_id: asset_id.into(),
463            sub_number: 0,
464            description: description.into(),
465            asset_class,
466            company_code: company_code.into(),
467            cost_center: None,
468            location: None,
469            acquisition_date,
470            acquisition_type: AcquisitionType::Purchase,
471            acquisition_cost,
472            capitalized_date: Some(acquisition_date),
473            depreciation_method,
474            useful_life_months,
475            salvage_value: Decimal::ZERO,
476            accumulated_depreciation: Decimal::ZERO,
477            net_book_value: acquisition_cost,
478            account_determination: AssetAccountDetermination::default(),
479            status: AssetStatus::Active,
480            disposal_date: None,
481            disposal_proceeds: None,
482            serial_number: None,
483            manufacturer: None,
484            model: None,
485            warranty_expiration: None,
486            insurance_policy: None,
487            purchase_order: None,
488            vendor_id: None,
489            invoice_reference: None,
490            is_gwg: None,
491        }
492    }
493
494    /// Set cost center.
495    pub fn with_cost_center(mut self, cost_center: impl Into<String>) -> Self {
496        self.cost_center = Some(cost_center.into());
497        self
498    }
499
500    /// Set location.
501    pub fn with_location(mut self, location: impl Into<String>) -> Self {
502        self.location = Some(location.into());
503        self
504    }
505
506    /// Set salvage value.
507    pub fn with_salvage_value(mut self, salvage_value: Decimal) -> Self {
508        self.salvage_value = salvage_value;
509        self
510    }
511
512    /// Set depreciation method.
513    pub fn with_depreciation_method(mut self, method: DepreciationMethod) -> Self {
514        self.depreciation_method = method;
515        self
516    }
517
518    /// Set useful life.
519    pub fn with_useful_life_months(mut self, months: u32) -> Self {
520        self.useful_life_months = months;
521        self
522    }
523
524    /// Set vendor ID.
525    pub fn with_vendor(mut self, vendor_id: impl Into<String>) -> Self {
526        self.vendor_id = Some(vendor_id.into());
527        self
528    }
529
530    /// Calculate months since capitalization.
531    pub fn months_since_capitalization(&self, as_of_date: NaiveDate) -> u32 {
532        let cap_date = self.capitalized_date.unwrap_or(self.acquisition_date);
533        if as_of_date < cap_date {
534            return 0;
535        }
536
537        let years = as_of_date.year() - cap_date.year();
538        let months = as_of_date.month() as i32 - cap_date.month() as i32;
539        ((years * 12) + months).max(0) as u32
540    }
541
542    /// Calculate depreciation for a specific month.
543    pub fn calculate_monthly_depreciation(&self, as_of_date: NaiveDate) -> Decimal {
544        if !self.asset_class.is_depreciable() {
545            return Decimal::ZERO;
546        }
547
548        if self.status == AssetStatus::Disposed {
549            return Decimal::ZERO;
550        }
551
552        let months_elapsed = self.months_since_capitalization(as_of_date);
553
554        self.depreciation_method.calculate_monthly_depreciation(
555            self.acquisition_cost,
556            self.salvage_value,
557            self.useful_life_months,
558            months_elapsed,
559            self.accumulated_depreciation,
560        )
561    }
562
563    /// Apply depreciation and update balances.
564    pub fn apply_depreciation(&mut self, depreciation_amount: Decimal) {
565        self.accumulated_depreciation += depreciation_amount;
566        self.net_book_value = self.acquisition_cost - self.accumulated_depreciation;
567
568        // Update status if fully depreciated
569        if self.net_book_value <= self.salvage_value && self.status == AssetStatus::Active {
570            self.status = AssetStatus::FullyDepreciated;
571        }
572    }
573
574    /// Calculate gain/loss on disposal.
575    pub fn calculate_disposal_gain_loss(&self, proceeds: Decimal) -> Decimal {
576        proceeds - self.net_book_value
577    }
578
579    /// Record disposal.
580    pub fn dispose(&mut self, disposal_date: NaiveDate, proceeds: Decimal) {
581        self.disposal_date = Some(disposal_date);
582        self.disposal_proceeds = Some(proceeds);
583        self.status = AssetStatus::Disposed;
584    }
585
586    /// Check if asset is fully depreciated.
587    pub fn is_fully_depreciated(&self) -> bool {
588        self.net_book_value <= self.salvage_value
589    }
590
591    /// Calculate remaining useful life in months.
592    pub fn remaining_useful_life_months(&self, as_of_date: NaiveDate) -> u32 {
593        let months_elapsed = self.months_since_capitalization(as_of_date);
594        self.useful_life_months.saturating_sub(months_elapsed)
595    }
596
597    /// Calculate depreciation rate (annual percentage).
598    pub fn annual_depreciation_rate(&self) -> Decimal {
599        if self.useful_life_months == 0 {
600            return Decimal::ZERO;
601        }
602
603        match self.depreciation_method {
604            DepreciationMethod::StraightLine => {
605                Decimal::from(12) / Decimal::from(self.useful_life_months) * dec!(100)
606            }
607            DepreciationMethod::DoubleDecliningBalance => {
608                Decimal::from(24) / Decimal::from(self.useful_life_months) * dec!(100)
609            }
610            _ => Decimal::from(12) / Decimal::from(self.useful_life_months) * dec!(100),
611        }
612    }
613
614    /// Return the annual MACRS depreciation for a given recovery year (1-indexed).
615    ///
616    /// Uses the IRS Publication 946 GDS half-year convention tables.
617    /// MACRS depreciation is based on the full acquisition cost (salvage value is ignored).
618    /// Returns `Decimal::ZERO` if the year is out of range or no table matches the useful life.
619    pub fn macrs_depreciation(&self, year: u32) -> Decimal {
620        if year == 0 {
621            return Decimal::ZERO;
622        }
623
624        let useful_life_years = self.useful_life_months / 12;
625        let table_index = (year - 1) as usize;
626
627        match macrs_table_for_life(useful_life_years) {
628            Some(table) if table_index < table.len() => {
629                let pct = macrs_pct(table[table_index]);
630                self.acquisition_cost * pct / dec!(100)
631            }
632            _ => Decimal::ZERO,
633        }
634    }
635
636    /// Return the monthly double-declining balance depreciation amount.
637    ///
638    /// The DDB rate is `2 / useful_life_months * 12` applied monthly against the current
639    /// net book value. The result is rounded to 2 decimal places and capped so the asset
640    /// does not depreciate below the salvage value.
641    pub fn ddb_depreciation(&self) -> Decimal {
642        if self.useful_life_months == 0 {
643            return Decimal::ZERO;
644        }
645
646        let net_book_value = self.acquisition_cost - self.accumulated_depreciation;
647        if net_book_value <= self.salvage_value {
648            return Decimal::ZERO;
649        }
650
651        let annual_rate = Decimal::from(2) / Decimal::from(self.useful_life_months) * dec!(12);
652        let monthly_rate = annual_rate / dec!(12);
653        let depreciation = (net_book_value * monthly_rate).round_dp(2);
654        depreciation.min(net_book_value - self.salvage_value)
655    }
656}
657
658/// Pool of fixed assets for transaction generation.
659#[derive(Debug, Clone, Default, Serialize, Deserialize)]
660pub struct FixedAssetPool {
661    /// All fixed assets
662    pub assets: Vec<FixedAsset>,
663    /// Index by asset class
664    #[serde(skip)]
665    class_index: std::collections::HashMap<AssetClass, Vec<usize>>,
666    /// Index by company code
667    #[serde(skip)]
668    company_index: std::collections::HashMap<String, Vec<usize>>,
669}
670
671impl FixedAssetPool {
672    /// Create a new empty asset pool.
673    pub fn new() -> Self {
674        Self::default()
675    }
676
677    /// Add an asset to the pool.
678    pub fn add_asset(&mut self, asset: FixedAsset) {
679        let idx = self.assets.len();
680        let asset_class = asset.asset_class;
681        let company_code = asset.company_code.clone();
682
683        self.assets.push(asset);
684
685        self.class_index.entry(asset_class).or_default().push(idx);
686        self.company_index
687            .entry(company_code)
688            .or_default()
689            .push(idx);
690    }
691
692    /// Get all assets requiring depreciation for a given month.
693    pub fn get_depreciable_assets(&self) -> Vec<&FixedAsset> {
694        self.assets
695            .iter()
696            .filter(|a| {
697                a.asset_class.is_depreciable()
698                    && a.status == AssetStatus::Active
699                    && !a.is_fully_depreciated()
700            })
701            .collect()
702    }
703
704    /// Get mutable references to depreciable assets.
705    pub fn get_depreciable_assets_mut(&mut self) -> Vec<&mut FixedAsset> {
706        self.assets
707            .iter_mut()
708            .filter(|a| {
709                a.asset_class.is_depreciable()
710                    && a.status == AssetStatus::Active
711                    && !a.is_fully_depreciated()
712            })
713            .collect()
714    }
715
716    /// Get assets by company code.
717    pub fn get_by_company(&self, company_code: &str) -> Vec<&FixedAsset> {
718        self.company_index
719            .get(company_code)
720            .map(|indices| indices.iter().map(|&i| &self.assets[i]).collect())
721            .unwrap_or_default()
722    }
723
724    /// Get assets by class.
725    pub fn get_by_class(&self, asset_class: AssetClass) -> Vec<&FixedAsset> {
726        self.class_index
727            .get(&asset_class)
728            .map(|indices| indices.iter().map(|&i| &self.assets[i]).collect())
729            .unwrap_or_default()
730    }
731
732    /// Get asset by ID.
733    pub fn get_by_id(&self, asset_id: &str) -> Option<&FixedAsset> {
734        self.assets.iter().find(|a| a.asset_id == asset_id)
735    }
736
737    /// Get mutable asset by ID.
738    pub fn get_by_id_mut(&mut self, asset_id: &str) -> Option<&mut FixedAsset> {
739        self.assets.iter_mut().find(|a| a.asset_id == asset_id)
740    }
741
742    /// Calculate total depreciation for all assets in a period.
743    pub fn calculate_period_depreciation(&self, as_of_date: NaiveDate) -> Decimal {
744        self.get_depreciable_assets()
745            .iter()
746            .map(|a| a.calculate_monthly_depreciation(as_of_date))
747            .sum()
748    }
749
750    /// Get total net book value.
751    pub fn total_net_book_value(&self) -> Decimal {
752        self.assets
753            .iter()
754            .filter(|a| a.status != AssetStatus::Disposed)
755            .map(|a| a.net_book_value)
756            .sum()
757    }
758
759    /// Get count of assets.
760    pub fn len(&self) -> usize {
761        self.assets.len()
762    }
763
764    /// Check if pool is empty.
765    pub fn is_empty(&self) -> bool {
766        self.assets.is_empty()
767    }
768
769    /// Rebuild indices after deserialization.
770    pub fn rebuild_indices(&mut self) {
771        self.class_index.clear();
772        self.company_index.clear();
773
774        for (idx, asset) in self.assets.iter().enumerate() {
775            self.class_index
776                .entry(asset.asset_class)
777                .or_default()
778                .push(idx);
779            self.company_index
780                .entry(asset.company_code.clone())
781                .or_default()
782                .push(idx);
783        }
784    }
785}
786
787#[cfg(test)]
788#[allow(clippy::unwrap_used)]
789mod tests {
790    use super::*;
791
792    fn test_date(year: i32, month: u32, day: u32) -> NaiveDate {
793        NaiveDate::from_ymd_opt(year, month, day).unwrap()
794    }
795
796    #[test]
797    fn test_asset_creation() {
798        let asset = FixedAsset::new(
799            "FA-001",
800            "Office Computer",
801            AssetClass::ComputerHardware,
802            "1000",
803            test_date(2024, 1, 1),
804            Decimal::from(2000),
805        );
806
807        assert_eq!(asset.asset_id, "FA-001");
808        assert_eq!(asset.acquisition_cost, Decimal::from(2000));
809        assert_eq!(asset.useful_life_months, 36); // 3 years for computers
810    }
811
812    #[test]
813    fn test_straight_line_depreciation() {
814        let asset = FixedAsset::new(
815            "FA-001",
816            "Office Equipment",
817            AssetClass::FurnitureFixtures,
818            "1000",
819            test_date(2024, 1, 1),
820            Decimal::from(8400),
821        )
822        .with_useful_life_months(84) // 7 years
823        .with_depreciation_method(DepreciationMethod::StraightLine);
824
825        let monthly_dep = asset.calculate_monthly_depreciation(test_date(2024, 2, 1));
826        assert_eq!(monthly_dep, Decimal::from(100)); // 8400 / 84 months
827    }
828
829    #[test]
830    fn test_salvage_value_limit() {
831        let mut asset = FixedAsset::new(
832            "FA-001",
833            "Test Asset",
834            AssetClass::MachineryEquipment,
835            "1000",
836            test_date(2024, 1, 1),
837            Decimal::from(1200),
838        )
839        .with_useful_life_months(12)
840        .with_salvage_value(Decimal::from(200));
841
842        // Apply 11 months of depreciation (1000/12 ~= 83.33 each)
843        for _ in 0..11 {
844            let dep = Decimal::from(83);
845            asset.apply_depreciation(dep);
846        }
847
848        // At this point, NBV should be around 287, which is above salvage (200)
849        // Next depreciation should be limited to not go below salvage
850        let final_dep = asset.calculate_monthly_depreciation(test_date(2024, 12, 1));
851
852        // Verify we don't depreciate below salvage
853        asset.apply_depreciation(final_dep);
854        assert!(asset.net_book_value >= asset.salvage_value);
855    }
856
857    #[test]
858    fn test_disposal() {
859        let mut asset = FixedAsset::new(
860            "FA-001",
861            "Old Equipment",
862            AssetClass::MachineryEquipment,
863            "1000",
864            test_date(2020, 1, 1),
865            Decimal::from(10000),
866        );
867
868        // Simulate some depreciation
869        asset.apply_depreciation(Decimal::from(5000));
870
871        // Calculate gain/loss
872        let gain_loss = asset.calculate_disposal_gain_loss(Decimal::from(6000));
873        assert_eq!(gain_loss, Decimal::from(1000)); // Gain of 1000
874
875        // Record disposal
876        asset.dispose(test_date(2024, 1, 1), Decimal::from(6000));
877        assert_eq!(asset.status, AssetStatus::Disposed);
878    }
879
880    #[test]
881    fn test_land_not_depreciable() {
882        let asset = FixedAsset::new(
883            "FA-001",
884            "Land Parcel",
885            AssetClass::Land,
886            "1000",
887            test_date(2024, 1, 1),
888            Decimal::from(500000),
889        );
890
891        let dep = asset.calculate_monthly_depreciation(test_date(2024, 6, 1));
892        assert_eq!(dep, Decimal::ZERO);
893    }
894
895    #[test]
896    fn test_asset_pool() {
897        let mut pool = FixedAssetPool::new();
898
899        pool.add_asset(FixedAsset::new(
900            "FA-001",
901            "Computer 1",
902            AssetClass::ComputerHardware,
903            "1000",
904            test_date(2024, 1, 1),
905            Decimal::from(2000),
906        ));
907
908        pool.add_asset(FixedAsset::new(
909            "FA-002",
910            "Desk",
911            AssetClass::FurnitureFixtures,
912            "1000",
913            test_date(2024, 1, 1),
914            Decimal::from(500),
915        ));
916
917        assert_eq!(pool.len(), 2);
918        assert_eq!(pool.get_by_class(AssetClass::ComputerHardware).len(), 1);
919        assert_eq!(pool.get_by_company("1000").len(), 2);
920    }
921
922    #[test]
923    fn test_months_since_capitalization() {
924        let asset = FixedAsset::new(
925            "FA-001",
926            "Test",
927            AssetClass::MachineryEquipment,
928            "1000",
929            test_date(2024, 3, 15),
930            Decimal::from(10000),
931        );
932
933        assert_eq!(asset.months_since_capitalization(test_date(2024, 3, 1)), 0);
934        assert_eq!(asset.months_since_capitalization(test_date(2024, 6, 1)), 3);
935        assert_eq!(asset.months_since_capitalization(test_date(2025, 3, 1)), 12);
936    }
937
938    // ---- MACRS GDS table tests ----
939
940    #[test]
941    fn test_macrs_tables_sum_to_100() {
942        let tables: &[(&str, &[&str])] = &[
943            ("3-year", MACRS_GDS_3_YEAR),
944            ("5-year", MACRS_GDS_5_YEAR),
945            ("7-year", MACRS_GDS_7_YEAR),
946            ("10-year", MACRS_GDS_10_YEAR),
947            ("15-year", MACRS_GDS_15_YEAR),
948            ("20-year", MACRS_GDS_20_YEAR),
949        ];
950
951        let tolerance = dec!(0.02);
952        let hundred = dec!(100);
953
954        for (label, table) in tables {
955            let sum: Decimal = table.iter().map(|s| macrs_pct(s)).sum();
956            let diff = (sum - hundred).abs();
957            assert!(
958                diff < tolerance,
959                "MACRS GDS {label} table sums to {sum}, expected ~100.0"
960            );
961        }
962    }
963
964    #[test]
965    fn test_macrs_table_for_life_mapping() {
966        // 1-3 years -> 3-year table (4 entries)
967        assert_eq!(macrs_table_for_life(1).unwrap().len(), 4);
968        assert_eq!(macrs_table_for_life(3).unwrap().len(), 4);
969
970        // 4-5 years -> 5-year table (6 entries)
971        assert_eq!(macrs_table_for_life(4).unwrap().len(), 6);
972        assert_eq!(macrs_table_for_life(5).unwrap().len(), 6);
973
974        // 6-7 years -> 7-year table (8 entries)
975        assert_eq!(macrs_table_for_life(6).unwrap().len(), 8);
976        assert_eq!(macrs_table_for_life(7).unwrap().len(), 8);
977
978        // 8-10 years -> 10-year table (11 entries)
979        assert_eq!(macrs_table_for_life(8).unwrap().len(), 11);
980        assert_eq!(macrs_table_for_life(10).unwrap().len(), 11);
981
982        // 11-15 years -> 15-year table (16 entries)
983        assert_eq!(macrs_table_for_life(11).unwrap().len(), 16);
984        assert_eq!(macrs_table_for_life(15).unwrap().len(), 16);
985
986        // 16-20 years -> 20-year table (21 entries)
987        assert_eq!(macrs_table_for_life(16).unwrap().len(), 21);
988        assert_eq!(macrs_table_for_life(20).unwrap().len(), 21);
989
990        // Out of range -> None
991        assert!(macrs_table_for_life(0).is_none());
992        assert!(macrs_table_for_life(21).is_none());
993        assert!(macrs_table_for_life(100).is_none());
994    }
995
996    #[test]
997    fn test_macrs_depreciation_5_year_asset() {
998        let asset = FixedAsset::new(
999            "FA-MACRS",
1000            "Vehicle",
1001            AssetClass::Vehicles,
1002            "1000",
1003            test_date(2024, 1, 1),
1004            Decimal::from(10000),
1005        )
1006        .with_useful_life_months(60) // 5 years
1007        .with_depreciation_method(DepreciationMethod::Macrs);
1008
1009        // Year 1: 20.00% of 10,000 = 2,000
1010        assert_eq!(asset.macrs_depreciation(1), Decimal::from(2000));
1011        // Year 2: 32.00% of 10,000 = 3,200
1012        assert_eq!(asset.macrs_depreciation(2), Decimal::from(3200));
1013        // Year 3: 19.20% of 10,000 = 1,920
1014        assert_eq!(asset.macrs_depreciation(3), Decimal::from(1920));
1015        // Year 4: 11.52% of 10,000 = 1,152
1016        assert_eq!(asset.macrs_depreciation(4), Decimal::from(1152));
1017        // Year 5: 11.52% of 10,000 = 1,152
1018        assert_eq!(asset.macrs_depreciation(5), Decimal::from(1152));
1019        // Year 6: 5.76% of 10,000 = 576
1020        assert_eq!(asset.macrs_depreciation(6), Decimal::from(576));
1021        // Year 7: beyond table -> 0
1022        assert_eq!(asset.macrs_depreciation(7), Decimal::ZERO);
1023        // Year 0: invalid -> 0
1024        assert_eq!(asset.macrs_depreciation(0), Decimal::ZERO);
1025    }
1026
1027    #[test]
1028    fn test_macrs_calculate_monthly_depreciation_uses_tables() {
1029        let asset = FixedAsset::new(
1030            "FA-MACRS-M",
1031            "Vehicle",
1032            AssetClass::Vehicles,
1033            "1000",
1034            test_date(2024, 1, 1),
1035            Decimal::from(12000),
1036        )
1037        .with_useful_life_months(60) // 5 years
1038        .with_depreciation_method(DepreciationMethod::Macrs);
1039
1040        // Year 1 (months_elapsed 0..11): 20.00% of 12,000 = 2,400 annual -> 200/month
1041        let monthly_year1 = asset.calculate_monthly_depreciation(test_date(2024, 2, 1));
1042        assert_eq!(monthly_year1, Decimal::from(200));
1043
1044        // Year 2 (months_elapsed 12..23): 32.00% of 12,000 = 3,840 annual -> 320/month
1045        let monthly_year2 = asset.calculate_monthly_depreciation(test_date(2025, 2, 1));
1046        assert_eq!(monthly_year2, Decimal::from(320));
1047    }
1048
1049    #[test]
1050    fn test_ddb_depreciation() {
1051        let asset = FixedAsset::new(
1052            "FA-DDB",
1053            "Server",
1054            AssetClass::ComputerHardware,
1055            "1000",
1056            test_date(2024, 1, 1),
1057            Decimal::from(3600),
1058        )
1059        .with_useful_life_months(36) // 3 years
1060        .with_depreciation_method(DepreciationMethod::DoubleDecliningBalance);
1061
1062        // DDB annual rate = 2 / 36 * 12 = 2/3
1063        // Monthly rate = (2/3) / 12 = 1/18
1064        // First month: 3600 * (1/18) = 200
1065        let monthly = asset.ddb_depreciation();
1066        assert_eq!(monthly, Decimal::from(200));
1067    }
1068
1069    #[test]
1070    fn test_ddb_depreciation_with_accumulated() {
1071        let mut asset = FixedAsset::new(
1072            "FA-DDB2",
1073            "Laptop",
1074            AssetClass::ComputerHardware,
1075            "1000",
1076            test_date(2024, 1, 1),
1077            Decimal::from(1800),
1078        )
1079        .with_useful_life_months(36);
1080
1081        // After accumulating 900 of depreciation, NBV = 900
1082        asset.apply_depreciation(Decimal::from(900));
1083
1084        // Monthly rate = 1/18, on NBV 900 -> 50
1085        let monthly = asset.ddb_depreciation();
1086        assert_eq!(monthly, Decimal::from(50));
1087    }
1088
1089    #[test]
1090    fn test_ddb_depreciation_respects_salvage() {
1091        let mut asset = FixedAsset::new(
1092            "FA-DDB3",
1093            "Printer",
1094            AssetClass::ComputerHardware,
1095            "1000",
1096            test_date(2024, 1, 1),
1097            Decimal::from(1800),
1098        )
1099        .with_useful_life_months(36)
1100        .with_salvage_value(Decimal::from(200));
1101
1102        // Accumulate until NBV is barely above salvage
1103        // NBV = 1800 - 1590 = 210, salvage = 200
1104        asset.apply_depreciation(Decimal::from(1590));
1105
1106        // DDB would compute 210 * (1/18) = 11.666..., but cap at NBV - salvage = 10
1107        let monthly = asset.ddb_depreciation();
1108        assert_eq!(monthly, Decimal::from(10));
1109    }
1110
1111    // ---- Degressiv (German declining balance) tests ----
1112
1113    #[test]
1114    fn test_degressiv_depreciation_initial() {
1115        // 10-year asset, 120000 EUR, no salvage
1116        // SL rate = 1/10 = 10%; Degressiv = min(3 * 10%, 30%) = 30%
1117        // Monthly: 120000 * 0.30 / 12 = 3000
1118        let asset = FixedAsset::new(
1119            "FA-DEG",
1120            "Maschine",
1121            AssetClass::MachineryEquipment,
1122            "DE01",
1123            test_date(2024, 1, 1),
1124            Decimal::from(120000),
1125        )
1126        .with_useful_life_months(120)
1127        .with_depreciation_method(DepreciationMethod::Degressiv);
1128
1129        let monthly = asset.calculate_monthly_depreciation(test_date(2024, 2, 1));
1130        assert_eq!(monthly, Decimal::from(3000));
1131    }
1132
1133    #[test]
1134    fn test_degressiv_rate_capped_at_30_percent() {
1135        // 5-year asset: SL rate = 1/5 = 20%; 3x = 60%. Capped at 30%.
1136        // Degressiv monthly: 6000 * 0.30 / 12 = 150
1137        // SL monthly on remaining: 6000 / 59 ≈ 101.69 (< 150)
1138        // → Degressiv wins at start
1139        let asset = FixedAsset::new(
1140            "FA-DEG2",
1141            "Fahrzeug",
1142            AssetClass::Vehicles,
1143            "DE01",
1144            test_date(2024, 1, 1),
1145            Decimal::from(6000),
1146        )
1147        .with_useful_life_months(60)
1148        .with_depreciation_method(DepreciationMethod::Degressiv);
1149
1150        let monthly = asset.calculate_monthly_depreciation(test_date(2024, 2, 1));
1151        assert_eq!(monthly, Decimal::from(150));
1152
1153        // For a 3-year asset, SL rate (33.3%) > Degressiv cap (30%),
1154        // so the switch to SL happens immediately.
1155        let short_asset = FixedAsset::new(
1156            "FA-DEG2S",
1157            "Server",
1158            AssetClass::ComputerHardware,
1159            "DE01",
1160            test_date(2024, 1, 1),
1161            Decimal::from(3600),
1162        )
1163        .with_useful_life_months(36)
1164        .with_depreciation_method(DepreciationMethod::Degressiv);
1165
1166        // months_elapsed=1, remaining=35, SL = 3600/35 ≈ 102.86 > Degressiv 90
1167        let monthly_short = short_asset.calculate_monthly_depreciation(test_date(2024, 2, 1));
1168        assert!(
1169            monthly_short > Decimal::from(100),
1170            "SL should win for 3-year asset"
1171        );
1172    }
1173
1174    #[test]
1175    fn test_degressiv_switches_to_straight_line() {
1176        // Degressiv should switch to SL when SL produces higher depreciation.
1177        // 10-year, 10000 EUR asset.
1178        // Degressiv rate = 30%, monthly = NBV * 0.30 / 12
1179        // SL on remaining = (NBV - salvage) / remaining_months
1180        let mut asset = FixedAsset::new(
1181            "FA-DEG3",
1182            "Fahrzeug",
1183            AssetClass::Vehicles,
1184            "DE01",
1185            test_date(2024, 1, 1),
1186            Decimal::from(10000),
1187        )
1188        .with_useful_life_months(120)
1189        .with_depreciation_method(DepreciationMethod::Degressiv);
1190
1191        // Simulate significant depreciation: NBV = 1000, 24 months remaining
1192        asset.apply_depreciation(Decimal::from(9000));
1193        // NBV = 1000
1194        // Degressiv: 1000 * 0.30 / 12 = 25
1195        // SL on remaining: 1000 / 24 ≈ 41.67
1196        // Should use SL (41.67) since it's higher
1197        let dep = asset.calculate_monthly_depreciation(test_date(2032, 1, 1));
1198        // months_elapsed = 96, remaining = 120 - 96 = 24
1199        // SL = 1000/24 = 41.666...
1200        // Degressiv = 1000 * 0.30 / 12 = 25
1201        // max(25, 41.666) = 41.666...
1202        assert!(
1203            dep > Decimal::from(25),
1204            "Should switch to SL when it exceeds Degressiv"
1205        );
1206        assert!(dep < Decimal::from(42), "SL should be ~41.67");
1207    }
1208
1209    #[test]
1210    fn test_gwg_field_default() {
1211        let asset = FixedAsset::new(
1212            "FA-GWG",
1213            "Keyboard",
1214            AssetClass::ComputerHardware,
1215            "DE01",
1216            test_date(2024, 1, 1),
1217            Decimal::from(200),
1218        );
1219        assert_eq!(asset.is_gwg, None, "is_gwg should default to None");
1220    }
1221}