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 {
544 use crate::accounts::asset_class_accounts as ac;
545
546 let acquisition_account = match class {
547 AssetClass::Land => ac::LAND,
548 AssetClass::Buildings => ac::BUILDINGS,
549 AssetClass::BuildingImprovements => ac::BUILDING_IMPROVEMENTS,
550 AssetClass::MachineryEquipment | AssetClass::Machinery => ac::MACHINERY_EQUIPMENT,
551 AssetClass::Vehicles => ac::VEHICLES,
552 AssetClass::OfficeEquipment => ac::OFFICE_EQUIPMENT,
553 AssetClass::ComputerEquipment
554 | AssetClass::ItEquipment
555 | AssetClass::ComputerHardware => ac::COMPUTER_HARDWARE,
556 AssetClass::Software | AssetClass::Intangibles => ac::SOFTWARE_INTANGIBLES,
557 AssetClass::FurnitureFixtures | AssetClass::Furniture => ac::FURNITURE_FIXTURES,
558 AssetClass::LeaseholdImprovements => ac::LEASEHOLD_IMPROVEMENTS,
559 AssetClass::ConstructionInProgress => ac::CONSTRUCTION_IN_PROGRESS,
560 AssetClass::LowValueAssets => ac::LOW_VALUE_ASSETS,
561 AssetClass::Other => ac::OTHER_ASSETS,
562 };
563
564 let depreciation_account = format!("{}9", &acquisition_account[..3]);
566
567 Self {
568 acquisition_account: acquisition_account.to_string(),
569 accumulated_depreciation_account: depreciation_account.clone(),
570 depreciation_expense_account: ac::DEPRECIATION_EXPENSE.to_string(),
571 depreciation_account,
572 gain_on_disposal_account: ac::GAIN_ON_DISPOSAL.to_string(),
573 loss_on_disposal_account: ac::LOSS_ON_DISPOSAL.to_string(),
574 gain_loss_account: ac::GAIN_ON_DISPOSAL.to_string(),
575 clearing_account: ac::ACQUISITION_CLEARING.to_string(),
576 }
577 }
578}
579
580#[derive(Debug, Clone, Serialize, Deserialize)]
582pub struct AssetAcquisition {
583 pub transaction_id: String,
585 pub asset_number: String,
587 pub sub_number: String,
589 pub transaction_date: NaiveDate,
591 pub posting_date: NaiveDate,
593 pub amount: Decimal,
595 pub acquisition_type: AcquisitionType,
597 pub vendor_id: Option<String>,
599 pub invoice_reference: Option<String>,
601 pub gl_reference: Option<GLReference>,
603 pub notes: Option<String>,
605}
606
607#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
609pub enum AcquisitionType {
610 ExternalPurchase,
612 InternalProduction,
614 TransferFromCIP,
616 IntercompanyTransfer,
618 PostCapitalization,
620}
621
622#[cfg(test)]
623mod tests {
624 use super::*;
625
626 #[test]
627 fn test_asset_creation() {
628 let asset = FixedAssetRecord::new(
629 "ASSET001".to_string(),
630 "1000".to_string(),
631 AssetClass::MachineryEquipment,
632 "Production Machine".to_string(),
633 NaiveDate::from_ymd_opt(2024, 1, 15).unwrap(),
634 dec!(100000),
635 "USD".to_string(),
636 );
637
638 assert_eq!(asset.acquisition_cost, dec!(100000));
639 assert_eq!(asset.net_book_value, dec!(100000));
640 assert_eq!(asset.status, AssetStatus::Active);
641 }
642
643 #[test]
644 fn test_depreciation_area() {
645 let area = DepreciationArea::new(
646 DepreciationAreaType::Book,
647 DepreciationMethod::StraightLine,
648 60, dec!(100000),
650 )
651 .with_salvage_value(dec!(10000));
652
653 let monthly = area.calculate_monthly_depreciation();
654 assert_eq!(monthly, dec!(1500));
656 }
657
658 #[test]
659 fn test_record_depreciation() {
660 let mut asset = FixedAssetRecord::new(
661 "ASSET001".to_string(),
662 "1000".to_string(),
663 AssetClass::ComputerEquipment,
664 "Server".to_string(),
665 NaiveDate::from_ymd_opt(2024, 1, 15).unwrap(),
666 dec!(50000),
667 "USD".to_string(),
668 )
669 .with_standard_depreciation(5, DepreciationMethod::StraightLine);
670
671 asset.record_depreciation(dec!(833.33), DepreciationAreaType::Book);
672
673 assert_eq!(asset.accumulated_depreciation, dec!(833.33));
674 assert_eq!(asset.net_book_value, dec!(49166.67));
675 }
676
677 #[test]
678 fn test_asset_class_defaults() {
679 assert_eq!(AssetClass::Buildings.default_useful_life_years(), 39);
680 assert_eq!(AssetClass::ComputerEquipment.default_useful_life_years(), 5);
681 assert_eq!(AssetClass::Land.default_useful_life_years(), 0);
682 }
683}