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