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)]
788mod tests {
789    use super::*;
790
791    fn test_date(year: i32, month: u32, day: u32) -> NaiveDate {
792        NaiveDate::from_ymd_opt(year, month, day).unwrap()
793    }
794
795    #[test]
796    fn test_asset_creation() {
797        let asset = FixedAsset::new(
798            "FA-001",
799            "Office Computer",
800            AssetClass::ComputerHardware,
801            "1000",
802            test_date(2024, 1, 1),
803            Decimal::from(2000),
804        );
805
806        assert_eq!(asset.asset_id, "FA-001");
807        assert_eq!(asset.acquisition_cost, Decimal::from(2000));
808        assert_eq!(asset.useful_life_months, 36); // 3 years for computers
809    }
810
811    #[test]
812    fn test_straight_line_depreciation() {
813        let asset = FixedAsset::new(
814            "FA-001",
815            "Office Equipment",
816            AssetClass::FurnitureFixtures,
817            "1000",
818            test_date(2024, 1, 1),
819            Decimal::from(8400),
820        )
821        .with_useful_life_months(84) // 7 years
822        .with_depreciation_method(DepreciationMethod::StraightLine);
823
824        let monthly_dep = asset.calculate_monthly_depreciation(test_date(2024, 2, 1));
825        assert_eq!(monthly_dep, Decimal::from(100)); // 8400 / 84 months
826    }
827
828    #[test]
829    fn test_salvage_value_limit() {
830        let mut asset = FixedAsset::new(
831            "FA-001",
832            "Test Asset",
833            AssetClass::MachineryEquipment,
834            "1000",
835            test_date(2024, 1, 1),
836            Decimal::from(1200),
837        )
838        .with_useful_life_months(12)
839        .with_salvage_value(Decimal::from(200));
840
841        // Apply 11 months of depreciation (1000/12 ~= 83.33 each)
842        for _ in 0..11 {
843            let dep = Decimal::from(83);
844            asset.apply_depreciation(dep);
845        }
846
847        // At this point, NBV should be around 287, which is above salvage (200)
848        // Next depreciation should be limited to not go below salvage
849        let final_dep = asset.calculate_monthly_depreciation(test_date(2024, 12, 1));
850
851        // Verify we don't depreciate below salvage
852        asset.apply_depreciation(final_dep);
853        assert!(asset.net_book_value >= asset.salvage_value);
854    }
855
856    #[test]
857    fn test_disposal() {
858        let mut asset = FixedAsset::new(
859            "FA-001",
860            "Old Equipment",
861            AssetClass::MachineryEquipment,
862            "1000",
863            test_date(2020, 1, 1),
864            Decimal::from(10000),
865        );
866
867        // Simulate some depreciation
868        asset.apply_depreciation(Decimal::from(5000));
869
870        // Calculate gain/loss
871        let gain_loss = asset.calculate_disposal_gain_loss(Decimal::from(6000));
872        assert_eq!(gain_loss, Decimal::from(1000)); // Gain of 1000
873
874        // Record disposal
875        asset.dispose(test_date(2024, 1, 1), Decimal::from(6000));
876        assert_eq!(asset.status, AssetStatus::Disposed);
877    }
878
879    #[test]
880    fn test_land_not_depreciable() {
881        let asset = FixedAsset::new(
882            "FA-001",
883            "Land Parcel",
884            AssetClass::Land,
885            "1000",
886            test_date(2024, 1, 1),
887            Decimal::from(500000),
888        );
889
890        let dep = asset.calculate_monthly_depreciation(test_date(2024, 6, 1));
891        assert_eq!(dep, Decimal::ZERO);
892    }
893
894    #[test]
895    fn test_asset_pool() {
896        let mut pool = FixedAssetPool::new();
897
898        pool.add_asset(FixedAsset::new(
899            "FA-001",
900            "Computer 1",
901            AssetClass::ComputerHardware,
902            "1000",
903            test_date(2024, 1, 1),
904            Decimal::from(2000),
905        ));
906
907        pool.add_asset(FixedAsset::new(
908            "FA-002",
909            "Desk",
910            AssetClass::FurnitureFixtures,
911            "1000",
912            test_date(2024, 1, 1),
913            Decimal::from(500),
914        ));
915
916        assert_eq!(pool.len(), 2);
917        assert_eq!(pool.get_by_class(AssetClass::ComputerHardware).len(), 1);
918        assert_eq!(pool.get_by_company("1000").len(), 2);
919    }
920
921    #[test]
922    fn test_months_since_capitalization() {
923        let asset = FixedAsset::new(
924            "FA-001",
925            "Test",
926            AssetClass::MachineryEquipment,
927            "1000",
928            test_date(2024, 3, 15),
929            Decimal::from(10000),
930        );
931
932        assert_eq!(asset.months_since_capitalization(test_date(2024, 3, 1)), 0);
933        assert_eq!(asset.months_since_capitalization(test_date(2024, 6, 1)), 3);
934        assert_eq!(asset.months_since_capitalization(test_date(2025, 3, 1)), 12);
935    }
936
937    // ---- MACRS GDS table tests ----
938
939    #[test]
940    fn test_macrs_tables_sum_to_100() {
941        let tables: &[(&str, &[&str])] = &[
942            ("3-year", MACRS_GDS_3_YEAR),
943            ("5-year", MACRS_GDS_5_YEAR),
944            ("7-year", MACRS_GDS_7_YEAR),
945            ("10-year", MACRS_GDS_10_YEAR),
946            ("15-year", MACRS_GDS_15_YEAR),
947            ("20-year", MACRS_GDS_20_YEAR),
948        ];
949
950        let tolerance = dec!(0.02);
951        let hundred = dec!(100);
952
953        for (label, table) in tables {
954            let sum: Decimal = table.iter().map(|s| macrs_pct(s)).sum();
955            let diff = (sum - hundred).abs();
956            assert!(
957                diff < tolerance,
958                "MACRS GDS {label} table sums to {sum}, expected ~100.0"
959            );
960        }
961    }
962
963    #[test]
964    fn test_macrs_table_for_life_mapping() {
965        // 1-3 years -> 3-year table (4 entries)
966        assert_eq!(macrs_table_for_life(1).unwrap().len(), 4);
967        assert_eq!(macrs_table_for_life(3).unwrap().len(), 4);
968
969        // 4-5 years -> 5-year table (6 entries)
970        assert_eq!(macrs_table_for_life(4).unwrap().len(), 6);
971        assert_eq!(macrs_table_for_life(5).unwrap().len(), 6);
972
973        // 6-7 years -> 7-year table (8 entries)
974        assert_eq!(macrs_table_for_life(6).unwrap().len(), 8);
975        assert_eq!(macrs_table_for_life(7).unwrap().len(), 8);
976
977        // 8-10 years -> 10-year table (11 entries)
978        assert_eq!(macrs_table_for_life(8).unwrap().len(), 11);
979        assert_eq!(macrs_table_for_life(10).unwrap().len(), 11);
980
981        // 11-15 years -> 15-year table (16 entries)
982        assert_eq!(macrs_table_for_life(11).unwrap().len(), 16);
983        assert_eq!(macrs_table_for_life(15).unwrap().len(), 16);
984
985        // 16-20 years -> 20-year table (21 entries)
986        assert_eq!(macrs_table_for_life(16).unwrap().len(), 21);
987        assert_eq!(macrs_table_for_life(20).unwrap().len(), 21);
988
989        // Out of range -> None
990        assert!(macrs_table_for_life(0).is_none());
991        assert!(macrs_table_for_life(21).is_none());
992        assert!(macrs_table_for_life(100).is_none());
993    }
994
995    #[test]
996    fn test_macrs_depreciation_5_year_asset() {
997        let asset = FixedAsset::new(
998            "FA-MACRS",
999            "Vehicle",
1000            AssetClass::Vehicles,
1001            "1000",
1002            test_date(2024, 1, 1),
1003            Decimal::from(10000),
1004        )
1005        .with_useful_life_months(60) // 5 years
1006        .with_depreciation_method(DepreciationMethod::Macrs);
1007
1008        // Year 1: 20.00% of 10,000 = 2,000
1009        assert_eq!(asset.macrs_depreciation(1), Decimal::from(2000));
1010        // Year 2: 32.00% of 10,000 = 3,200
1011        assert_eq!(asset.macrs_depreciation(2), Decimal::from(3200));
1012        // Year 3: 19.20% of 10,000 = 1,920
1013        assert_eq!(asset.macrs_depreciation(3), Decimal::from(1920));
1014        // Year 4: 11.52% of 10,000 = 1,152
1015        assert_eq!(asset.macrs_depreciation(4), Decimal::from(1152));
1016        // Year 5: 11.52% of 10,000 = 1,152
1017        assert_eq!(asset.macrs_depreciation(5), Decimal::from(1152));
1018        // Year 6: 5.76% of 10,000 = 576
1019        assert_eq!(asset.macrs_depreciation(6), Decimal::from(576));
1020        // Year 7: beyond table -> 0
1021        assert_eq!(asset.macrs_depreciation(7), Decimal::ZERO);
1022        // Year 0: invalid -> 0
1023        assert_eq!(asset.macrs_depreciation(0), Decimal::ZERO);
1024    }
1025
1026    #[test]
1027    fn test_macrs_calculate_monthly_depreciation_uses_tables() {
1028        let asset = FixedAsset::new(
1029            "FA-MACRS-M",
1030            "Vehicle",
1031            AssetClass::Vehicles,
1032            "1000",
1033            test_date(2024, 1, 1),
1034            Decimal::from(12000),
1035        )
1036        .with_useful_life_months(60) // 5 years
1037        .with_depreciation_method(DepreciationMethod::Macrs);
1038
1039        // Year 1 (months_elapsed 0..11): 20.00% of 12,000 = 2,400 annual -> 200/month
1040        let monthly_year1 = asset.calculate_monthly_depreciation(test_date(2024, 2, 1));
1041        assert_eq!(monthly_year1, Decimal::from(200));
1042
1043        // Year 2 (months_elapsed 12..23): 32.00% of 12,000 = 3,840 annual -> 320/month
1044        let monthly_year2 = asset.calculate_monthly_depreciation(test_date(2025, 2, 1));
1045        assert_eq!(monthly_year2, Decimal::from(320));
1046    }
1047
1048    #[test]
1049    fn test_ddb_depreciation() {
1050        let asset = FixedAsset::new(
1051            "FA-DDB",
1052            "Server",
1053            AssetClass::ComputerHardware,
1054            "1000",
1055            test_date(2024, 1, 1),
1056            Decimal::from(3600),
1057        )
1058        .with_useful_life_months(36) // 3 years
1059        .with_depreciation_method(DepreciationMethod::DoubleDecliningBalance);
1060
1061        // DDB annual rate = 2 / 36 * 12 = 2/3
1062        // Monthly rate = (2/3) / 12 = 1/18
1063        // First month: 3600 * (1/18) = 200
1064        let monthly = asset.ddb_depreciation();
1065        assert_eq!(monthly, Decimal::from(200));
1066    }
1067
1068    #[test]
1069    fn test_ddb_depreciation_with_accumulated() {
1070        let mut asset = FixedAsset::new(
1071            "FA-DDB2",
1072            "Laptop",
1073            AssetClass::ComputerHardware,
1074            "1000",
1075            test_date(2024, 1, 1),
1076            Decimal::from(1800),
1077        )
1078        .with_useful_life_months(36);
1079
1080        // After accumulating 900 of depreciation, NBV = 900
1081        asset.apply_depreciation(Decimal::from(900));
1082
1083        // Monthly rate = 1/18, on NBV 900 -> 50
1084        let monthly = asset.ddb_depreciation();
1085        assert_eq!(monthly, Decimal::from(50));
1086    }
1087
1088    #[test]
1089    fn test_ddb_depreciation_respects_salvage() {
1090        let mut asset = FixedAsset::new(
1091            "FA-DDB3",
1092            "Printer",
1093            AssetClass::ComputerHardware,
1094            "1000",
1095            test_date(2024, 1, 1),
1096            Decimal::from(1800),
1097        )
1098        .with_useful_life_months(36)
1099        .with_salvage_value(Decimal::from(200));
1100
1101        // Accumulate until NBV is barely above salvage
1102        // NBV = 1800 - 1590 = 210, salvage = 200
1103        asset.apply_depreciation(Decimal::from(1590));
1104
1105        // DDB would compute 210 * (1/18) = 11.666..., but cap at NBV - salvage = 10
1106        let monthly = asset.ddb_depreciation();
1107        assert_eq!(monthly, Decimal::from(10));
1108    }
1109
1110    // ---- Degressiv (German declining balance) tests ----
1111
1112    #[test]
1113    fn test_degressiv_depreciation_initial() {
1114        // 10-year asset, 120000 EUR, no salvage
1115        // SL rate = 1/10 = 10%; Degressiv = min(3 * 10%, 30%) = 30%
1116        // Monthly: 120000 * 0.30 / 12 = 3000
1117        let asset = FixedAsset::new(
1118            "FA-DEG",
1119            "Maschine",
1120            AssetClass::MachineryEquipment,
1121            "DE01",
1122            test_date(2024, 1, 1),
1123            Decimal::from(120000),
1124        )
1125        .with_useful_life_months(120)
1126        .with_depreciation_method(DepreciationMethod::Degressiv);
1127
1128        let monthly = asset.calculate_monthly_depreciation(test_date(2024, 2, 1));
1129        assert_eq!(monthly, Decimal::from(3000));
1130    }
1131
1132    #[test]
1133    fn test_degressiv_rate_capped_at_30_percent() {
1134        // 5-year asset: SL rate = 1/5 = 20%; 3x = 60%. Capped at 30%.
1135        // Degressiv monthly: 6000 * 0.30 / 12 = 150
1136        // SL monthly on remaining: 6000 / 59 ≈ 101.69 (< 150)
1137        // → Degressiv wins at start
1138        let asset = FixedAsset::new(
1139            "FA-DEG2",
1140            "Fahrzeug",
1141            AssetClass::Vehicles,
1142            "DE01",
1143            test_date(2024, 1, 1),
1144            Decimal::from(6000),
1145        )
1146        .with_useful_life_months(60)
1147        .with_depreciation_method(DepreciationMethod::Degressiv);
1148
1149        let monthly = asset.calculate_monthly_depreciation(test_date(2024, 2, 1));
1150        assert_eq!(monthly, Decimal::from(150));
1151
1152        // For a 3-year asset, SL rate (33.3%) > Degressiv cap (30%),
1153        // so the switch to SL happens immediately.
1154        let short_asset = FixedAsset::new(
1155            "FA-DEG2S",
1156            "Server",
1157            AssetClass::ComputerHardware,
1158            "DE01",
1159            test_date(2024, 1, 1),
1160            Decimal::from(3600),
1161        )
1162        .with_useful_life_months(36)
1163        .with_depreciation_method(DepreciationMethod::Degressiv);
1164
1165        // months_elapsed=1, remaining=35, SL = 3600/35 ≈ 102.86 > Degressiv 90
1166        let monthly_short = short_asset.calculate_monthly_depreciation(test_date(2024, 2, 1));
1167        assert!(
1168            monthly_short > Decimal::from(100),
1169            "SL should win for 3-year asset"
1170        );
1171    }
1172
1173    #[test]
1174    fn test_degressiv_switches_to_straight_line() {
1175        // Degressiv should switch to SL when SL produces higher depreciation.
1176        // 10-year, 10000 EUR asset.
1177        // Degressiv rate = 30%, monthly = NBV * 0.30 / 12
1178        // SL on remaining = (NBV - salvage) / remaining_months
1179        let mut asset = FixedAsset::new(
1180            "FA-DEG3",
1181            "Fahrzeug",
1182            AssetClass::Vehicles,
1183            "DE01",
1184            test_date(2024, 1, 1),
1185            Decimal::from(10000),
1186        )
1187        .with_useful_life_months(120)
1188        .with_depreciation_method(DepreciationMethod::Degressiv);
1189
1190        // Simulate significant depreciation: NBV = 1000, 24 months remaining
1191        asset.apply_depreciation(Decimal::from(9000));
1192        // NBV = 1000
1193        // Degressiv: 1000 * 0.30 / 12 = 25
1194        // SL on remaining: 1000 / 24 ≈ 41.67
1195        // Should use SL (41.67) since it's higher
1196        let dep = asset.calculate_monthly_depreciation(test_date(2032, 1, 1));
1197        // months_elapsed = 96, remaining = 120 - 96 = 24
1198        // SL = 1000/24 = 41.666...
1199        // Degressiv = 1000 * 0.30 / 12 = 25
1200        // max(25, 41.666) = 41.666...
1201        assert!(
1202            dep > Decimal::from(25),
1203            "Should switch to SL when it exceeds Degressiv"
1204        );
1205        assert!(dep < Decimal::from(42), "SL should be ~41.67");
1206    }
1207
1208    #[test]
1209    fn test_gwg_field_default() {
1210        let asset = FixedAsset::new(
1211            "FA-GWG",
1212            "Keyboard",
1213            AssetClass::ComputerHardware,
1214            "DE01",
1215            test_date(2024, 1, 1),
1216            Decimal::from(200),
1217        );
1218        assert_eq!(asset.is_gwg, None, "is_gwg should default to None");
1219    }
1220}