use chrono::{DateTime, Datelike, NaiveDate, Utc};
use rust_decimal::Decimal;
use rust_decimal_macros::dec;
use serde::{Deserialize, Serialize};
use crate::models::subledger::GLReference;
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct FixedAssetRecord {
pub asset_number: String,
pub sub_number: String,
pub company_code: String,
pub asset_class: AssetClass,
pub description: String,
pub serial_number: Option<String>,
pub inventory_number: Option<String>,
pub status: AssetStatus,
pub acquisition_date: NaiveDate,
pub capitalization_date: NaiveDate,
pub first_depreciation_date: NaiveDate,
pub acquisition_cost: Decimal,
pub currency: String,
pub accumulated_depreciation: Decimal,
pub net_book_value: Decimal,
pub depreciation_areas: Vec<DepreciationArea>,
pub cost_center: Option<String>,
pub profit_center: Option<String>,
pub plant: Option<String>,
pub location: Option<String>,
pub responsible_person: Option<String>,
pub vendor_id: Option<String>,
pub po_reference: Option<String>,
pub account_determination: AssetAccountDetermination,
#[serde(with = "crate::serde_timestamp::utc")]
pub created_at: DateTime<Utc>,
pub created_by: Option<String>,
#[serde(default, with = "crate::serde_timestamp::utc::option")]
pub modified_at: Option<DateTime<Utc>>,
pub notes: Option<String>,
}
impl FixedAssetRecord {
pub fn new(
asset_number: String,
company_code: String,
asset_class: AssetClass,
description: String,
acquisition_date: NaiveDate,
acquisition_cost: Decimal,
currency: String,
) -> Self {
Self {
asset_number,
sub_number: "0".to_string(),
company_code,
asset_class,
description,
serial_number: None,
inventory_number: None,
status: AssetStatus::Active,
acquisition_date,
capitalization_date: acquisition_date,
first_depreciation_date: Self::calculate_first_depreciation_date(acquisition_date),
acquisition_cost,
currency,
accumulated_depreciation: Decimal::ZERO,
net_book_value: acquisition_cost,
depreciation_areas: Vec::new(),
cost_center: None,
profit_center: None,
plant: None,
location: None,
responsible_person: None,
vendor_id: None,
po_reference: None,
account_determination: AssetAccountDetermination::default_for_class(asset_class),
created_at: Utc::now(),
created_by: None,
modified_at: None,
notes: None,
}
}
fn calculate_first_depreciation_date(acquisition_date: NaiveDate) -> NaiveDate {
let year = acquisition_date.year();
let month = acquisition_date.month();
if month == 12 {
NaiveDate::from_ymd_opt(year + 1, 1, 1).expect("valid date components")
} else {
NaiveDate::from_ymd_opt(year, month + 1, 1).expect("valid date components")
}
}
pub fn add_depreciation_area(&mut self, area: DepreciationArea) {
self.depreciation_areas.push(area);
}
pub fn with_standard_depreciation(
mut self,
useful_life_years: u32,
method: DepreciationMethod,
) -> Self {
self.depreciation_areas.push(DepreciationArea::new(
DepreciationAreaType::Book,
method,
useful_life_years * 12,
self.acquisition_cost,
));
self.depreciation_areas.push(DepreciationArea::new(
DepreciationAreaType::Tax,
method,
useful_life_years * 12,
self.acquisition_cost,
));
self
}
pub fn record_depreciation(&mut self, amount: Decimal, area_type: DepreciationAreaType) {
self.accumulated_depreciation += amount;
self.net_book_value = self.acquisition_cost - self.accumulated_depreciation;
if let Some(area) = self
.depreciation_areas
.iter_mut()
.find(|a| a.area_type == area_type)
{
area.accumulated_depreciation += amount;
area.net_book_value = area.acquisition_cost - area.accumulated_depreciation;
}
self.modified_at = Some(Utc::now());
}
pub fn add_acquisition(&mut self, amount: Decimal, _date: NaiveDate) {
self.acquisition_cost += amount;
self.net_book_value += amount;
for area in &mut self.depreciation_areas {
area.acquisition_cost += amount;
area.net_book_value += amount;
}
self.modified_at = Some(Utc::now());
}
pub fn is_fully_depreciated(&self) -> bool {
self.net_book_value <= Decimal::ZERO
}
pub fn remaining_life_months(&self, area_type: DepreciationAreaType) -> u32 {
self.depreciation_areas
.iter()
.find(|a| a.area_type == area_type)
.map(DepreciationArea::remaining_life_months)
.unwrap_or(0)
}
pub fn with_location(mut self, plant: String, location: String) -> Self {
self.plant = Some(plant);
self.location = Some(location);
self
}
pub fn with_cost_center(mut self, cost_center: String) -> Self {
self.cost_center = Some(cost_center);
self
}
pub fn with_vendor(mut self, vendor_id: String, po_reference: Option<String>) -> Self {
self.vendor_id = Some(vendor_id);
self.po_reference = po_reference;
self
}
pub fn asset_id(&self) -> &str {
&self.asset_number
}
pub fn current_acquisition_cost(&self) -> Decimal {
self.acquisition_cost
}
pub fn salvage_value(&self) -> Decimal {
self.depreciation_areas
.first()
.map(|a| a.salvage_value)
.unwrap_or(Decimal::ZERO)
}
pub fn useful_life_months(&self) -> u32 {
self.depreciation_areas
.first()
.map(|a| a.useful_life_months)
.unwrap_or(0)
}
pub fn accumulated_depreciation_account(&self) -> &str {
&self.account_determination.accumulated_depreciation_account
}
pub fn retire(&mut self, retirement_date: NaiveDate) {
self.status = AssetStatus::Retired;
self.notes = Some(format!(
"{}Retired on {}",
self.notes
.as_ref()
.map(|n| format!("{n}. "))
.unwrap_or_default(),
retirement_date
));
self.modified_at = Some(Utc::now());
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
pub enum AssetClass {
Land,
Buildings,
BuildingImprovements,
MachineryEquipment,
Machinery,
Vehicles,
OfficeEquipment,
ComputerEquipment,
ItEquipment,
ComputerHardware,
Software,
Intangibles,
FurnitureFixtures,
Furniture,
LeaseholdImprovements,
ConstructionInProgress,
LowValueAssets,
Other,
}
impl AssetClass {
pub fn default_useful_life_years(&self) -> u32 {
match self {
AssetClass::Land => 0, AssetClass::Buildings => 39,
AssetClass::BuildingImprovements => 15,
AssetClass::MachineryEquipment | AssetClass::Machinery => 7,
AssetClass::Vehicles => 5,
AssetClass::OfficeEquipment => 7,
AssetClass::ComputerEquipment
| AssetClass::ItEquipment
| AssetClass::ComputerHardware => 5,
AssetClass::Software | AssetClass::Intangibles => 3,
AssetClass::FurnitureFixtures | AssetClass::Furniture => 7,
AssetClass::LeaseholdImprovements => 10,
AssetClass::ConstructionInProgress => 0, AssetClass::LowValueAssets => 1, AssetClass::Other => 7,
}
}
pub fn default_depreciation_method(&self) -> DepreciationMethod {
match self {
AssetClass::Land | AssetClass::ConstructionInProgress => DepreciationMethod::None,
AssetClass::ComputerEquipment
| AssetClass::ItEquipment
| AssetClass::ComputerHardware
| AssetClass::Software
| AssetClass::Intangibles => DepreciationMethod::StraightLine,
AssetClass::Vehicles | AssetClass::MachineryEquipment | AssetClass::Machinery => {
DepreciationMethod::DecliningBalance { rate: dec!(0.40) }
}
AssetClass::LowValueAssets => DepreciationMethod::StraightLine,
_ => DepreciationMethod::StraightLine,
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
pub enum AssetStatus {
UnderConstruction,
#[default]
Active,
HeldForSale,
FullyDepreciated,
Retired,
Transferred,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct DepreciationArea {
pub area_type: DepreciationAreaType,
pub method: DepreciationMethod,
pub useful_life_months: u32,
pub depreciation_start_date: Option<NaiveDate>,
pub acquisition_cost: Decimal,
pub accumulated_depreciation: Decimal,
pub net_book_value: Decimal,
pub salvage_value: Decimal,
pub periods_completed: u32,
pub last_depreciation_date: Option<NaiveDate>,
}
impl DepreciationArea {
pub fn new(
area_type: DepreciationAreaType,
method: DepreciationMethod,
useful_life_months: u32,
acquisition_cost: Decimal,
) -> Self {
Self {
area_type,
method,
useful_life_months,
depreciation_start_date: None,
acquisition_cost,
accumulated_depreciation: Decimal::ZERO,
net_book_value: acquisition_cost,
salvage_value: Decimal::ZERO,
periods_completed: 0,
last_depreciation_date: None,
}
}
pub fn with_salvage_value(mut self, value: Decimal) -> Self {
self.salvage_value = value;
self
}
pub fn remaining_life_months(&self) -> u32 {
self.useful_life_months
.saturating_sub(self.periods_completed)
}
pub fn is_fully_depreciated(&self) -> bool {
self.net_book_value <= self.salvage_value
}
pub fn calculate_monthly_depreciation(&self) -> Decimal {
if self.is_fully_depreciated() {
return Decimal::ZERO;
}
let depreciable_base = self.acquisition_cost - self.salvage_value;
match self.method {
DepreciationMethod::None => Decimal::ZERO,
DepreciationMethod::StraightLine => {
if self.useful_life_months > 0 {
(depreciable_base / Decimal::from(self.useful_life_months)).round_dp(2)
} else {
Decimal::ZERO
}
}
DepreciationMethod::DecliningBalance { rate } => {
let annual_depreciation = self.net_book_value * rate;
(annual_depreciation / dec!(12)).round_dp(2)
}
DepreciationMethod::SumOfYearsDigits => {
let remaining_years = self.remaining_life_months() / 12;
let total_years = self.useful_life_months / 12;
let sum_of_years = (total_years * (total_years + 1)) / 2;
if sum_of_years > 0 {
let annual = depreciable_base * Decimal::from(remaining_years)
/ Decimal::from(sum_of_years);
(annual / dec!(12)).round_dp(2)
} else {
Decimal::ZERO
}
}
DepreciationMethod::UnitsOfProduction { total_units, .. } => {
if total_units > 0 {
depreciable_base / Decimal::from(total_units) / dec!(12)
} else {
Decimal::ZERO
}
}
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
pub enum DepreciationAreaType {
Book,
Tax,
Group,
Management,
Insurance,
}
#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)]
pub enum DepreciationMethod {
None,
StraightLine,
DecliningBalance {
rate: Decimal,
},
SumOfYearsDigits,
UnitsOfProduction {
total_units: u32,
period_units: u32,
},
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct AssetAccountDetermination {
pub acquisition_account: String,
pub accumulated_depreciation_account: String,
pub depreciation_expense_account: String,
pub depreciation_account: String,
pub gain_on_disposal_account: String,
pub loss_on_disposal_account: String,
pub gain_loss_account: String,
pub clearing_account: String,
}
impl AssetAccountDetermination {
pub fn default_for_class(class: AssetClass) -> Self {
let prefix = match class {
AssetClass::Land => "1510",
AssetClass::Buildings => "1520",
AssetClass::BuildingImprovements => "1525",
AssetClass::MachineryEquipment | AssetClass::Machinery => "1530",
AssetClass::Vehicles => "1540",
AssetClass::OfficeEquipment => "1550",
AssetClass::ComputerEquipment
| AssetClass::ItEquipment
| AssetClass::ComputerHardware => "1555",
AssetClass::Software | AssetClass::Intangibles => "1560",
AssetClass::FurnitureFixtures | AssetClass::Furniture => "1570",
AssetClass::LeaseholdImprovements => "1580",
AssetClass::ConstructionInProgress => "1600",
AssetClass::LowValueAssets => "1595",
AssetClass::Other => "1590",
};
let depreciation_account = format!("{}9", &prefix[..3]);
Self {
acquisition_account: prefix.to_string(),
accumulated_depreciation_account: depreciation_account.clone(),
depreciation_expense_account: "7100".to_string(),
depreciation_account,
gain_on_disposal_account: "4900".to_string(),
loss_on_disposal_account: "7900".to_string(),
gain_loss_account: "4900".to_string(),
clearing_account: "1599".to_string(),
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct AssetAcquisition {
pub transaction_id: String,
pub asset_number: String,
pub sub_number: String,
pub transaction_date: NaiveDate,
pub posting_date: NaiveDate,
pub amount: Decimal,
pub acquisition_type: AcquisitionType,
pub vendor_id: Option<String>,
pub invoice_reference: Option<String>,
pub gl_reference: Option<GLReference>,
pub notes: Option<String>,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
pub enum AcquisitionType {
ExternalPurchase,
InternalProduction,
TransferFromCIP,
IntercompanyTransfer,
PostCapitalization,
}
#[cfg(test)]
#[allow(clippy::unwrap_used)]
mod tests {
use super::*;
#[test]
fn test_asset_creation() {
let asset = FixedAssetRecord::new(
"ASSET001".to_string(),
"1000".to_string(),
AssetClass::MachineryEquipment,
"Production Machine".to_string(),
NaiveDate::from_ymd_opt(2024, 1, 15).unwrap(),
dec!(100000),
"USD".to_string(),
);
assert_eq!(asset.acquisition_cost, dec!(100000));
assert_eq!(asset.net_book_value, dec!(100000));
assert_eq!(asset.status, AssetStatus::Active);
}
#[test]
fn test_depreciation_area() {
let area = DepreciationArea::new(
DepreciationAreaType::Book,
DepreciationMethod::StraightLine,
60, dec!(100000),
)
.with_salvage_value(dec!(10000));
let monthly = area.calculate_monthly_depreciation();
assert_eq!(monthly, dec!(1500));
}
#[test]
fn test_record_depreciation() {
let mut asset = FixedAssetRecord::new(
"ASSET001".to_string(),
"1000".to_string(),
AssetClass::ComputerEquipment,
"Server".to_string(),
NaiveDate::from_ymd_opt(2024, 1, 15).unwrap(),
dec!(50000),
"USD".to_string(),
)
.with_standard_depreciation(5, DepreciationMethod::StraightLine);
asset.record_depreciation(dec!(833.33), DepreciationAreaType::Book);
assert_eq!(asset.accumulated_depreciation, dec!(833.33));
assert_eq!(asset.net_book_value, dec!(49166.67));
}
#[test]
fn test_asset_class_defaults() {
assert_eq!(AssetClass::Buildings.default_useful_life_years(), 39);
assert_eq!(AssetClass::ComputerEquipment.default_useful_life_years(), 5);
assert_eq!(AssetClass::Land.default_useful_life_years(), 0);
}
}