Skip to main content

datasynth_core/models/subledger/fa/
asset.rs

1//! Fixed Asset model.
2
3use chrono::{DateTime, Datelike, NaiveDate, Utc};
4use rust_decimal::Decimal;
5use rust_decimal_macros::dec;
6use serde::{Deserialize, Serialize};
7
8use crate::models::subledger::GLReference;
9
10/// Fixed Asset record.
11#[derive(Debug, Clone, Serialize, Deserialize)]
12pub struct FixedAssetRecord {
13    /// Asset number (unique identifier).
14    pub asset_number: String,
15    /// Sub-number (for complex assets).
16    pub sub_number: String,
17    /// Company code.
18    pub company_code: String,
19    /// Asset class.
20    pub asset_class: AssetClass,
21    /// Description.
22    pub description: String,
23    /// Serial number.
24    pub serial_number: Option<String>,
25    /// Inventory number.
26    pub inventory_number: Option<String>,
27    /// Asset status.
28    pub status: AssetStatus,
29    /// Acquisition date.
30    pub acquisition_date: NaiveDate,
31    /// Capitalization date.
32    pub capitalization_date: NaiveDate,
33    /// First depreciation period.
34    pub first_depreciation_date: NaiveDate,
35    /// Original acquisition cost.
36    pub acquisition_cost: Decimal,
37    /// Currency.
38    pub currency: String,
39    /// Accumulated depreciation.
40    pub accumulated_depreciation: Decimal,
41    /// Net book value.
42    pub net_book_value: Decimal,
43    /// Depreciation areas.
44    pub depreciation_areas: Vec<DepreciationArea>,
45    /// Cost center.
46    pub cost_center: Option<String>,
47    /// Profit center.
48    pub profit_center: Option<String>,
49    /// Plant/location.
50    pub plant: Option<String>,
51    /// Room/location detail.
52    pub location: Option<String>,
53    /// Responsible person.
54    pub responsible_person: Option<String>,
55    /// Vendor (for acquisitions).
56    pub vendor_id: Option<String>,
57    /// Purchase order reference.
58    pub po_reference: Option<String>,
59    /// GL account mappings.
60    pub account_determination: AssetAccountDetermination,
61    /// Created timestamp.
62    #[serde(with = "crate::serde_timestamp::utc")]
63    pub created_at: DateTime<Utc>,
64    /// Created by user.
65    pub created_by: Option<String>,
66    /// Last modified.
67    #[serde(default, with = "crate::serde_timestamp::utc::option")]
68    pub modified_at: Option<DateTime<Utc>>,
69    /// Notes.
70    pub notes: Option<String>,
71}
72
73impl FixedAssetRecord {
74    /// Creates a new fixed asset.
75    pub fn new(
76        asset_number: String,
77        company_code: String,
78        asset_class: AssetClass,
79        description: String,
80        acquisition_date: NaiveDate,
81        acquisition_cost: Decimal,
82        currency: String,
83    ) -> Self {
84        Self {
85            asset_number,
86            sub_number: "0".to_string(),
87            company_code,
88            asset_class,
89            description,
90            serial_number: None,
91            inventory_number: None,
92            status: AssetStatus::Active,
93            acquisition_date,
94            capitalization_date: acquisition_date,
95            first_depreciation_date: Self::calculate_first_depreciation_date(acquisition_date),
96            acquisition_cost,
97            currency,
98            accumulated_depreciation: Decimal::ZERO,
99            net_book_value: acquisition_cost,
100            depreciation_areas: Vec::new(),
101            cost_center: None,
102            profit_center: None,
103            plant: None,
104            location: None,
105            responsible_person: None,
106            vendor_id: None,
107            po_reference: None,
108            account_determination: AssetAccountDetermination::default_for_class(asset_class),
109            created_at: Utc::now(),
110            created_by: None,
111            modified_at: None,
112            notes: None,
113        }
114    }
115
116    /// Calculates first depreciation date (start of next month).
117    fn calculate_first_depreciation_date(acquisition_date: NaiveDate) -> NaiveDate {
118        let year = acquisition_date.year();
119        let month = acquisition_date.month();
120
121        if month == 12 {
122            NaiveDate::from_ymd_opt(year + 1, 1, 1).expect("valid date components")
123        } else {
124            NaiveDate::from_ymd_opt(year, month + 1, 1).expect("valid date components")
125        }
126    }
127
128    /// Adds a depreciation area.
129    pub fn add_depreciation_area(&mut self, area: DepreciationArea) {
130        self.depreciation_areas.push(area);
131    }
132
133    /// Creates with standard depreciation areas (book and tax).
134    pub fn with_standard_depreciation(
135        mut self,
136        useful_life_years: u32,
137        method: DepreciationMethod,
138    ) -> Self {
139        // Book depreciation
140        self.depreciation_areas.push(DepreciationArea::new(
141            DepreciationAreaType::Book,
142            method,
143            useful_life_years * 12,
144            self.acquisition_cost,
145        ));
146
147        // Tax depreciation (may use different life)
148        self.depreciation_areas.push(DepreciationArea::new(
149            DepreciationAreaType::Tax,
150            method,
151            useful_life_years * 12,
152            self.acquisition_cost,
153        ));
154
155        self
156    }
157
158    /// Records depreciation.
159    pub fn record_depreciation(&mut self, amount: Decimal, area_type: DepreciationAreaType) {
160        self.accumulated_depreciation += amount;
161        self.net_book_value = self.acquisition_cost - self.accumulated_depreciation;
162
163        if let Some(area) = self
164            .depreciation_areas
165            .iter_mut()
166            .find(|a| a.area_type == area_type)
167        {
168            area.accumulated_depreciation += amount;
169            area.net_book_value = area.acquisition_cost - area.accumulated_depreciation;
170        }
171
172        self.modified_at = Some(Utc::now());
173    }
174
175    /// Records an acquisition addition.
176    pub fn add_acquisition(&mut self, amount: Decimal, _date: NaiveDate) {
177        self.acquisition_cost += amount;
178        self.net_book_value += amount;
179
180        for area in &mut self.depreciation_areas {
181            area.acquisition_cost += amount;
182            area.net_book_value += amount;
183        }
184
185        self.modified_at = Some(Utc::now());
186    }
187
188    /// Checks if fully depreciated.
189    pub fn is_fully_depreciated(&self) -> bool {
190        self.net_book_value <= Decimal::ZERO
191    }
192
193    /// Gets remaining useful life in months.
194    pub fn remaining_life_months(&self, area_type: DepreciationAreaType) -> u32 {
195        self.depreciation_areas
196            .iter()
197            .find(|a| a.area_type == area_type)
198            .map(DepreciationArea::remaining_life_months)
199            .unwrap_or(0)
200    }
201
202    /// Sets location information.
203    pub fn with_location(mut self, plant: String, location: String) -> Self {
204        self.plant = Some(plant);
205        self.location = Some(location);
206        self
207    }
208
209    /// Sets cost center.
210    pub fn with_cost_center(mut self, cost_center: String) -> Self {
211        self.cost_center = Some(cost_center);
212        self
213    }
214
215    /// Sets vendor reference.
216    pub fn with_vendor(mut self, vendor_id: String, po_reference: Option<String>) -> Self {
217        self.vendor_id = Some(vendor_id);
218        self.po_reference = po_reference;
219        self
220    }
221
222    // === Backward compatibility accessor methods ===
223
224    /// Gets asset_id (alias for asset_number).
225    pub fn asset_id(&self) -> &str {
226        &self.asset_number
227    }
228
229    /// Gets current acquisition cost (alias for acquisition_cost).
230    pub fn current_acquisition_cost(&self) -> Decimal {
231        self.acquisition_cost
232    }
233
234    /// Gets salvage value from the first depreciation area.
235    pub fn salvage_value(&self) -> Decimal {
236        self.depreciation_areas
237            .first()
238            .map(|a| a.salvage_value)
239            .unwrap_or(Decimal::ZERO)
240    }
241
242    /// Gets useful life in months from the first depreciation area.
243    pub fn useful_life_months(&self) -> u32 {
244        self.depreciation_areas
245            .first()
246            .map(|a| a.useful_life_months)
247            .unwrap_or(0)
248    }
249
250    /// Gets accumulated depreciation account from account determination.
251    pub fn accumulated_depreciation_account(&self) -> &str {
252        &self.account_determination.accumulated_depreciation_account
253    }
254
255    /// Marks as inactive (retired).
256    pub fn retire(&mut self, retirement_date: NaiveDate) {
257        self.status = AssetStatus::Retired;
258        self.notes = Some(format!(
259            "{}Retired on {}",
260            self.notes
261                .as_ref()
262                .map(|n| format!("{n}. "))
263                .unwrap_or_default(),
264            retirement_date
265        ));
266        self.modified_at = Some(Utc::now());
267    }
268}
269
270/// Asset class enumeration.
271#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
272pub enum AssetClass {
273    /// Land.
274    Land,
275    /// Buildings.
276    Buildings,
277    /// Building improvements.
278    BuildingImprovements,
279    /// Machinery and equipment.
280    MachineryEquipment,
281    /// Machinery (alias for MachineryEquipment).
282    Machinery,
283    /// Vehicles.
284    Vehicles,
285    /// Office equipment.
286    OfficeEquipment,
287    /// Computer equipment.
288    ComputerEquipment,
289    /// IT Equipment (alias for ComputerEquipment).
290    ItEquipment,
291    /// Computer hardware (alias for ComputerEquipment).
292    ComputerHardware,
293    /// Software.
294    Software,
295    /// Intangibles (alias for Software).
296    Intangibles,
297    /// Furniture and fixtures.
298    FurnitureFixtures,
299    /// Furniture (alias for FurnitureFixtures).
300    Furniture,
301    /// Leasehold improvements.
302    LeaseholdImprovements,
303    /// Construction in progress.
304    ConstructionInProgress,
305    /// Low value assets.
306    LowValueAssets,
307    /// Other.
308    Other,
309}
310
311impl AssetClass {
312    /// Gets default useful life in years.
313    pub fn default_useful_life_years(&self) -> u32 {
314        match self {
315            AssetClass::Land => 0, // Land doesn't depreciate
316            AssetClass::Buildings => 39,
317            AssetClass::BuildingImprovements => 15,
318            AssetClass::MachineryEquipment | AssetClass::Machinery => 7,
319            AssetClass::Vehicles => 5,
320            AssetClass::OfficeEquipment => 7,
321            AssetClass::ComputerEquipment
322            | AssetClass::ItEquipment
323            | AssetClass::ComputerHardware => 5,
324            AssetClass::Software | AssetClass::Intangibles => 3,
325            AssetClass::FurnitureFixtures | AssetClass::Furniture => 7,
326            AssetClass::LeaseholdImprovements => 10,
327            AssetClass::ConstructionInProgress => 0, // CIP doesn't depreciate
328            AssetClass::LowValueAssets => 1,         // Typically expensed immediately
329            AssetClass::Other => 7,
330        }
331    }
332
333    /// Gets default depreciation method.
334    pub fn default_depreciation_method(&self) -> DepreciationMethod {
335        match self {
336            AssetClass::Land | AssetClass::ConstructionInProgress => DepreciationMethod::None,
337            AssetClass::ComputerEquipment
338            | AssetClass::ItEquipment
339            | AssetClass::ComputerHardware
340            | AssetClass::Software
341            | AssetClass::Intangibles => DepreciationMethod::StraightLine,
342            AssetClass::Vehicles | AssetClass::MachineryEquipment | AssetClass::Machinery => {
343                DepreciationMethod::DecliningBalance { rate: dec!(0.40) }
344            }
345            AssetClass::LowValueAssets => DepreciationMethod::StraightLine,
346            _ => DepreciationMethod::StraightLine,
347        }
348    }
349}
350
351/// Asset status.
352#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
353pub enum AssetStatus {
354    /// Under construction.
355    UnderConstruction,
356    /// Active and depreciating.
357    #[default]
358    Active,
359    /// Held for sale.
360    HeldForSale,
361    /// Fully depreciated but still in use.
362    FullyDepreciated,
363    /// Retired/disposed.
364    Retired,
365    /// Transferred to another entity.
366    Transferred,
367}
368
369/// Depreciation area (book, tax, etc.).
370#[derive(Debug, Clone, Serialize, Deserialize)]
371pub struct DepreciationArea {
372    /// Area type.
373    pub area_type: DepreciationAreaType,
374    /// Depreciation method.
375    pub method: DepreciationMethod,
376    /// Useful life in months.
377    pub useful_life_months: u32,
378    /// Depreciation start date.
379    pub depreciation_start_date: Option<NaiveDate>,
380    /// Acquisition cost (can differ from main asset).
381    pub acquisition_cost: Decimal,
382    /// Accumulated depreciation.
383    pub accumulated_depreciation: Decimal,
384    /// Net book value.
385    pub net_book_value: Decimal,
386    /// Salvage value.
387    pub salvage_value: Decimal,
388    /// Depreciation periods completed.
389    pub periods_completed: u32,
390    /// Last depreciation date.
391    pub last_depreciation_date: Option<NaiveDate>,
392}
393
394impl DepreciationArea {
395    /// Creates a new depreciation area.
396    pub fn new(
397        area_type: DepreciationAreaType,
398        method: DepreciationMethod,
399        useful_life_months: u32,
400        acquisition_cost: Decimal,
401    ) -> Self {
402        Self {
403            area_type,
404            method,
405            useful_life_months,
406            depreciation_start_date: None,
407            acquisition_cost,
408            accumulated_depreciation: Decimal::ZERO,
409            net_book_value: acquisition_cost,
410            salvage_value: Decimal::ZERO,
411            periods_completed: 0,
412            last_depreciation_date: None,
413        }
414    }
415
416    /// Sets salvage value.
417    pub fn with_salvage_value(mut self, value: Decimal) -> Self {
418        self.salvage_value = value;
419        self
420    }
421
422    /// Gets remaining useful life in months.
423    pub fn remaining_life_months(&self) -> u32 {
424        self.useful_life_months
425            .saturating_sub(self.periods_completed)
426    }
427
428    /// Checks if fully depreciated.
429    pub fn is_fully_depreciated(&self) -> bool {
430        self.net_book_value <= self.salvage_value
431    }
432
433    /// Calculates monthly depreciation based on method.
434    pub fn calculate_monthly_depreciation(&self) -> Decimal {
435        if self.is_fully_depreciated() {
436            return Decimal::ZERO;
437        }
438
439        let depreciable_base = self.acquisition_cost - self.salvage_value;
440
441        match self.method {
442            DepreciationMethod::None => Decimal::ZERO,
443            DepreciationMethod::StraightLine => {
444                if self.useful_life_months > 0 {
445                    (depreciable_base / Decimal::from(self.useful_life_months)).round_dp(2)
446                } else {
447                    Decimal::ZERO
448                }
449            }
450            DepreciationMethod::DecliningBalance { rate } => {
451                let annual_depreciation = self.net_book_value * rate;
452                (annual_depreciation / dec!(12)).round_dp(2)
453            }
454            DepreciationMethod::SumOfYearsDigits => {
455                let remaining_years = self.remaining_life_months() / 12;
456                let total_years = self.useful_life_months / 12;
457                let sum_of_years = (total_years * (total_years + 1)) / 2;
458                if sum_of_years > 0 {
459                    let annual = depreciable_base * Decimal::from(remaining_years)
460                        / Decimal::from(sum_of_years);
461                    (annual / dec!(12)).round_dp(2)
462                } else {
463                    Decimal::ZERO
464                }
465            }
466            DepreciationMethod::UnitsOfProduction { total_units, .. } => {
467                // This needs production data; return estimate
468                if total_units > 0 {
469                    depreciable_base / Decimal::from(total_units) / dec!(12)
470                } else {
471                    Decimal::ZERO
472                }
473            }
474        }
475    }
476}
477
478/// Type of depreciation area.
479#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
480pub enum DepreciationAreaType {
481    /// Book (GAAP/IFRS) depreciation.
482    Book,
483    /// Tax depreciation.
484    Tax,
485    /// Group/consolidated reporting.
486    Group,
487    /// Management reporting.
488    Management,
489    /// Insurance valuation.
490    Insurance,
491}
492
493/// Depreciation method.
494#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)]
495pub enum DepreciationMethod {
496    /// No depreciation (e.g., land).
497    None,
498    /// Straight-line depreciation.
499    StraightLine,
500    /// Declining balance (accelerated).
501    DecliningBalance {
502        /// Annual rate (e.g., 0.40 for 40%).
503        rate: Decimal,
504    },
505    /// Sum of years digits.
506    SumOfYearsDigits,
507    /// Units of production.
508    UnitsOfProduction {
509        /// Total expected units.
510        total_units: u32,
511        /// Units this period.
512        period_units: u32,
513    },
514}
515
516/// Account determination for asset class.
517#[derive(Debug, Clone, Serialize, Deserialize)]
518pub struct AssetAccountDetermination {
519    /// Asset acquisition account.
520    pub acquisition_account: String,
521    /// Accumulated depreciation account.
522    pub accumulated_depreciation_account: String,
523    /// Depreciation expense account.
524    pub depreciation_expense_account: String,
525    /// Depreciation account (alias for accumulated_depreciation_account).
526    pub depreciation_account: String,
527    /// Gain on disposal account.
528    pub gain_on_disposal_account: String,
529    /// Loss on disposal account.
530    pub loss_on_disposal_account: String,
531    /// Gain/loss account (combined, for backward compatibility).
532    pub gain_loss_account: String,
533    /// Clearing account (for acquisitions).
534    pub clearing_account: String,
535}
536
537impl AssetAccountDetermination {
538    /// Creates default account determination for asset class.
539    pub fn default_for_class(class: AssetClass) -> Self {
540        let prefix = match class {
541            AssetClass::Land => "1510",
542            AssetClass::Buildings => "1520",
543            AssetClass::BuildingImprovements => "1525",
544            AssetClass::MachineryEquipment | AssetClass::Machinery => "1530",
545            AssetClass::Vehicles => "1540",
546            AssetClass::OfficeEquipment => "1550",
547            AssetClass::ComputerEquipment
548            | AssetClass::ItEquipment
549            | AssetClass::ComputerHardware => "1555",
550            AssetClass::Software | AssetClass::Intangibles => "1560",
551            AssetClass::FurnitureFixtures | AssetClass::Furniture => "1570",
552            AssetClass::LeaseholdImprovements => "1580",
553            AssetClass::ConstructionInProgress => "1600",
554            AssetClass::LowValueAssets => "1595",
555            AssetClass::Other => "1590",
556        };
557
558        let depreciation_account = format!("{}9", &prefix[..3]);
559
560        Self {
561            acquisition_account: prefix.to_string(),
562            accumulated_depreciation_account: depreciation_account.clone(),
563            depreciation_expense_account: "7100".to_string(),
564            depreciation_account,
565            gain_on_disposal_account: "4900".to_string(),
566            loss_on_disposal_account: "7900".to_string(),
567            gain_loss_account: "4900".to_string(),
568            clearing_account: "1599".to_string(),
569        }
570    }
571}
572
573/// Asset acquisition record.
574#[derive(Debug, Clone, Serialize, Deserialize)]
575pub struct AssetAcquisition {
576    /// Transaction ID.
577    pub transaction_id: String,
578    /// Asset number.
579    pub asset_number: String,
580    /// Sub-number.
581    pub sub_number: String,
582    /// Transaction date.
583    pub transaction_date: NaiveDate,
584    /// Posting date.
585    pub posting_date: NaiveDate,
586    /// Acquisition amount.
587    pub amount: Decimal,
588    /// Acquisition type.
589    pub acquisition_type: AcquisitionType,
590    /// Vendor ID.
591    pub vendor_id: Option<String>,
592    /// Invoice reference.
593    pub invoice_reference: Option<String>,
594    /// GL reference.
595    pub gl_reference: Option<GLReference>,
596    /// Notes.
597    pub notes: Option<String>,
598}
599
600/// Type of acquisition.
601#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
602pub enum AcquisitionType {
603    /// External purchase.
604    ExternalPurchase,
605    /// Internal production.
606    InternalProduction,
607    /// Transfer from CIP.
608    TransferFromCIP,
609    /// Intercompany transfer.
610    IntercompanyTransfer,
611    /// Post-capitalization.
612    PostCapitalization,
613}
614
615#[cfg(test)]
616#[allow(clippy::unwrap_used)]
617mod tests {
618    use super::*;
619
620    #[test]
621    fn test_asset_creation() {
622        let asset = FixedAssetRecord::new(
623            "ASSET001".to_string(),
624            "1000".to_string(),
625            AssetClass::MachineryEquipment,
626            "Production Machine".to_string(),
627            NaiveDate::from_ymd_opt(2024, 1, 15).unwrap(),
628            dec!(100000),
629            "USD".to_string(),
630        );
631
632        assert_eq!(asset.acquisition_cost, dec!(100000));
633        assert_eq!(asset.net_book_value, dec!(100000));
634        assert_eq!(asset.status, AssetStatus::Active);
635    }
636
637    #[test]
638    fn test_depreciation_area() {
639        let area = DepreciationArea::new(
640            DepreciationAreaType::Book,
641            DepreciationMethod::StraightLine,
642            60, // 5 years
643            dec!(100000),
644        )
645        .with_salvage_value(dec!(10000));
646
647        let monthly = area.calculate_monthly_depreciation();
648        // (100000 - 10000) / 60 = 1500
649        assert_eq!(monthly, dec!(1500));
650    }
651
652    #[test]
653    fn test_record_depreciation() {
654        let mut asset = FixedAssetRecord::new(
655            "ASSET001".to_string(),
656            "1000".to_string(),
657            AssetClass::ComputerEquipment,
658            "Server".to_string(),
659            NaiveDate::from_ymd_opt(2024, 1, 15).unwrap(),
660            dec!(50000),
661            "USD".to_string(),
662        )
663        .with_standard_depreciation(5, DepreciationMethod::StraightLine);
664
665        asset.record_depreciation(dec!(833.33), DepreciationAreaType::Book);
666
667        assert_eq!(asset.accumulated_depreciation, dec!(833.33));
668        assert_eq!(asset.net_book_value, dec!(49166.67));
669    }
670
671    #[test]
672    fn test_asset_class_defaults() {
673        assert_eq!(AssetClass::Buildings.default_useful_life_years(), 39);
674        assert_eq!(AssetClass::ComputerEquipment.default_useful_life_years(), 5);
675        assert_eq!(AssetClass::Land.default_useful_life_years(), 0);
676    }
677}