1use 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#[derive(Debug, Clone, Serialize, Deserialize)]
12pub struct FixedAssetRecord {
13 pub asset_number: String,
15 pub sub_number: String,
17 pub company_code: String,
19 pub asset_class: AssetClass,
21 pub description: String,
23 pub serial_number: Option<String>,
25 pub inventory_number: Option<String>,
27 pub status: AssetStatus,
29 pub acquisition_date: NaiveDate,
31 pub capitalization_date: NaiveDate,
33 pub first_depreciation_date: NaiveDate,
35 pub acquisition_cost: Decimal,
37 pub currency: String,
39 pub accumulated_depreciation: Decimal,
41 pub net_book_value: Decimal,
43 pub depreciation_areas: Vec<DepreciationArea>,
45 pub cost_center: Option<String>,
47 pub profit_center: Option<String>,
49 pub plant: Option<String>,
51 pub location: Option<String>,
53 pub responsible_person: Option<String>,
55 pub vendor_id: Option<String>,
57 pub po_reference: Option<String>,
59 pub account_determination: AssetAccountDetermination,
61 #[serde(with = "crate::serde_timestamp::utc")]
63 pub created_at: DateTime<Utc>,
64 pub created_by: Option<String>,
66 #[serde(default, with = "crate::serde_timestamp::utc::option")]
68 pub modified_at: Option<DateTime<Utc>>,
69 pub notes: Option<String>,
71}
72
73impl FixedAssetRecord {
74 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 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 pub fn add_depreciation_area(&mut self, area: DepreciationArea) {
130 self.depreciation_areas.push(area);
131 }
132
133 pub fn with_standard_depreciation(
135 mut self,
136 useful_life_years: u32,
137 method: DepreciationMethod,
138 ) -> Self {
139 self.depreciation_areas.push(DepreciationArea::new(
141 DepreciationAreaType::Book,
142 method,
143 useful_life_years * 12,
144 self.acquisition_cost,
145 ));
146
147 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 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 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 pub fn is_fully_depreciated(&self) -> bool {
190 self.net_book_value <= Decimal::ZERO
191 }
192
193 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 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 pub fn with_cost_center(mut self, cost_center: String) -> Self {
211 self.cost_center = Some(cost_center);
212 self
213 }
214
215 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 pub fn asset_id(&self) -> &str {
226 &self.asset_number
227 }
228
229 pub fn current_acquisition_cost(&self) -> Decimal {
231 self.acquisition_cost
232 }
233
234 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 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 pub fn accumulated_depreciation_account(&self) -> &str {
252 &self.account_determination.accumulated_depreciation_account
253 }
254
255 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#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
272pub enum AssetClass {
273 Land,
275 Buildings,
277 BuildingImprovements,
279 MachineryEquipment,
281 Machinery,
283 Vehicles,
285 OfficeEquipment,
287 ComputerEquipment,
289 ItEquipment,
291 ComputerHardware,
293 Software,
295 Intangibles,
297 FurnitureFixtures,
299 Furniture,
301 LeaseholdImprovements,
303 ConstructionInProgress,
305 LowValueAssets,
307 Other,
309}
310
311impl AssetClass {
312 pub fn default_useful_life_years(&self) -> u32 {
314 match self {
315 AssetClass::Land => 0, 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, AssetClass::LowValueAssets => 1, AssetClass::Other => 7,
330 }
331 }
332
333 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#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
353pub enum AssetStatus {
354 UnderConstruction,
356 #[default]
358 Active,
359 HeldForSale,
361 FullyDepreciated,
363 Retired,
365 Transferred,
367}
368
369#[derive(Debug, Clone, Serialize, Deserialize)]
371pub struct DepreciationArea {
372 pub area_type: DepreciationAreaType,
374 pub method: DepreciationMethod,
376 pub useful_life_months: u32,
378 pub depreciation_start_date: Option<NaiveDate>,
380 pub acquisition_cost: Decimal,
382 pub accumulated_depreciation: Decimal,
384 pub net_book_value: Decimal,
386 pub salvage_value: Decimal,
388 pub periods_completed: u32,
390 pub last_depreciation_date: Option<NaiveDate>,
392}
393
394impl DepreciationArea {
395 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 pub fn with_salvage_value(mut self, value: Decimal) -> Self {
418 self.salvage_value = value;
419 self
420 }
421
422 pub fn remaining_life_months(&self) -> u32 {
424 self.useful_life_months
425 .saturating_sub(self.periods_completed)
426 }
427
428 pub fn is_fully_depreciated(&self) -> bool {
430 self.net_book_value <= self.salvage_value
431 }
432
433 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 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#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
480pub enum DepreciationAreaType {
481 Book,
483 Tax,
485 Group,
487 Management,
489 Insurance,
491}
492
493#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)]
495pub enum DepreciationMethod {
496 None,
498 StraightLine,
500 DecliningBalance {
502 rate: Decimal,
504 },
505 SumOfYearsDigits,
507 UnitsOfProduction {
509 total_units: u32,
511 period_units: u32,
513 },
514}
515
516#[derive(Debug, Clone, Serialize, Deserialize)]
518pub struct AssetAccountDetermination {
519 pub acquisition_account: String,
521 pub accumulated_depreciation_account: String,
523 pub depreciation_expense_account: String,
525 pub depreciation_account: String,
527 pub gain_on_disposal_account: String,
529 pub loss_on_disposal_account: String,
531 pub gain_loss_account: String,
533 pub clearing_account: String,
535}
536
537impl AssetAccountDetermination {
538 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#[derive(Debug, Clone, Serialize, Deserialize)]
575pub struct AssetAcquisition {
576 pub transaction_id: String,
578 pub asset_number: String,
580 pub sub_number: String,
582 pub transaction_date: NaiveDate,
584 pub posting_date: NaiveDate,
586 pub amount: Decimal,
588 pub acquisition_type: AcquisitionType,
590 pub vendor_id: Option<String>,
592 pub invoice_reference: Option<String>,
594 pub gl_reference: Option<GLReference>,
596 pub notes: Option<String>,
598}
599
600#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
602pub enum AcquisitionType {
603 ExternalPurchase,
605 InternalProduction,
607 TransferFromCIP,
609 IntercompanyTransfer,
611 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, dec!(100000),
644 )
645 .with_salvage_value(dec!(10000));
646
647 let monthly = area.calculate_monthly_depreciation();
648 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}