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 pub created_at: DateTime<Utc>,
63 pub created_by: Option<String>,
65 pub modified_at: Option<DateTime<Utc>>,
67 pub notes: Option<String>,
69}
70
71impl FixedAssetRecord {
72 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 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 pub fn add_depreciation_area(&mut self, area: DepreciationArea) {
128 self.depreciation_areas.push(area);
129 }
130
131 pub fn with_standard_depreciation(
133 mut self,
134 useful_life_years: u32,
135 method: DepreciationMethod,
136 ) -> Self {
137 self.depreciation_areas.push(DepreciationArea::new(
139 DepreciationAreaType::Book,
140 method,
141 useful_life_years * 12,
142 self.acquisition_cost,
143 ));
144
145 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 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 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 pub fn is_fully_depreciated(&self) -> bool {
188 self.net_book_value <= Decimal::ZERO
189 }
190
191 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 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 pub fn with_cost_center(mut self, cost_center: String) -> Self {
209 self.cost_center = Some(cost_center);
210 self
211 }
212
213 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 pub fn asset_id(&self) -> &str {
224 &self.asset_number
225 }
226
227 pub fn current_acquisition_cost(&self) -> Decimal {
229 self.acquisition_cost
230 }
231
232 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 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 pub fn accumulated_depreciation_account(&self) -> &str {
250 &self.account_determination.accumulated_depreciation_account
251 }
252
253 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#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
270pub enum AssetClass {
271 Land,
273 Buildings,
275 BuildingImprovements,
277 MachineryEquipment,
279 Machinery,
281 Vehicles,
283 OfficeEquipment,
285 ComputerEquipment,
287 ItEquipment,
289 ComputerHardware,
291 Software,
293 Intangibles,
295 FurnitureFixtures,
297 Furniture,
299 LeaseholdImprovements,
301 ConstructionInProgress,
303 LowValueAssets,
305 Other,
307}
308
309impl AssetClass {
310 pub fn default_useful_life_years(&self) -> u32 {
312 match self {
313 AssetClass::Land => 0, 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, AssetClass::LowValueAssets => 1, AssetClass::Other => 7,
328 }
329 }
330
331 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#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
351pub enum AssetStatus {
352 UnderConstruction,
354 #[default]
356 Active,
357 HeldForSale,
359 FullyDepreciated,
361 Retired,
363 Transferred,
365}
366
367#[derive(Debug, Clone, Serialize, Deserialize)]
369pub struct DepreciationArea {
370 pub area_type: DepreciationAreaType,
372 pub method: DepreciationMethod,
374 pub useful_life_months: u32,
376 pub depreciation_start_date: Option<NaiveDate>,
378 pub acquisition_cost: Decimal,
380 pub accumulated_depreciation: Decimal,
382 pub net_book_value: Decimal,
384 pub salvage_value: Decimal,
386 pub periods_completed: u32,
388 pub last_depreciation_date: Option<NaiveDate>,
390}
391
392impl DepreciationArea {
393 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 pub fn with_salvage_value(mut self, value: Decimal) -> Self {
416 self.salvage_value = value;
417 self
418 }
419
420 pub fn remaining_life_months(&self) -> u32 {
422 self.useful_life_months
423 .saturating_sub(self.periods_completed)
424 }
425
426 pub fn is_fully_depreciated(&self) -> bool {
428 self.net_book_value <= self.salvage_value
429 }
430
431 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 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#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
478pub enum DepreciationAreaType {
479 Book,
481 Tax,
483 Group,
485 Management,
487 Insurance,
489}
490
491#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)]
493pub enum DepreciationMethod {
494 None,
496 StraightLine,
498 DecliningBalance {
500 rate: Decimal,
502 },
503 SumOfYearsDigits,
505 UnitsOfProduction {
507 total_units: u32,
509 period_units: u32,
511 },
512}
513
514#[derive(Debug, Clone, Serialize, Deserialize)]
516pub struct AssetAccountDetermination {
517 pub acquisition_account: String,
519 pub accumulated_depreciation_account: String,
521 pub depreciation_expense_account: String,
523 pub depreciation_account: String,
525 pub gain_on_disposal_account: String,
527 pub loss_on_disposal_account: String,
529 pub gain_loss_account: String,
531 pub clearing_account: String,
533}
534
535impl AssetAccountDetermination {
536 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#[derive(Debug, Clone, Serialize, Deserialize)]
573pub struct AssetAcquisition {
574 pub transaction_id: String,
576 pub asset_number: String,
578 pub sub_number: String,
580 pub transaction_date: NaiveDate,
582 pub posting_date: NaiveDate,
584 pub amount: Decimal,
586 pub acquisition_type: AcquisitionType,
588 pub vendor_id: Option<String>,
590 pub invoice_reference: Option<String>,
592 pub gl_reference: Option<GLReference>,
594 pub notes: Option<String>,
596}
597
598#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
600pub enum AcquisitionType {
601 ExternalPurchase,
603 InternalProduction,
605 TransferFromCIP,
607 IntercompanyTransfer,
609 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, dec!(100000),
641 )
642 .with_salvage_value(dec!(10000));
643
644 let monthly = area.calculate_monthly_depreciation();
645 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}