use chrono::{DateTime, Datelike, NaiveDate, Utc};
use rust_decimal::Decimal;
use rust_decimal_macros::dec;
use serde::{Deserialize, Serialize};
use std::collections::HashMap;
use super::{DepreciationAreaType, DepreciationMethod, FixedAssetRecord};
use crate::models::subledger::GLReference;
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct DepreciationRun {
pub run_id: String,
pub company_code: String,
pub fiscal_year: i32,
pub fiscal_period: u32,
pub depreciation_area: DepreciationAreaType,
pub run_date: NaiveDate,
pub posting_date: NaiveDate,
pub status: DepreciationRunStatus,
pub asset_entries: Vec<DepreciationEntry>,
pub total_depreciation: Decimal,
pub asset_count: u32,
pub gl_references: Vec<GLReference>,
pub created_by: String,
#[serde(with = "crate::serde_timestamp::utc")]
pub created_at: DateTime<Utc>,
#[serde(default, with = "crate::serde_timestamp::utc::option")]
pub completed_at: Option<DateTime<Utc>>,
pub error_count: u32,
pub errors: Vec<DepreciationError>,
}
impl DepreciationRun {
pub fn new(
run_id: String,
company_code: String,
fiscal_year: i32,
fiscal_period: u32,
depreciation_area: DepreciationAreaType,
run_date: NaiveDate,
created_by: String,
) -> Self {
let posting_date = Self::calculate_period_end(fiscal_year, fiscal_period);
Self {
run_id,
company_code,
fiscal_year,
fiscal_period,
depreciation_area,
run_date,
posting_date,
status: DepreciationRunStatus::Created,
asset_entries: Vec::new(),
total_depreciation: Decimal::ZERO,
asset_count: 0,
gl_references: Vec::new(),
created_by,
created_at: Utc::now(),
completed_at: None,
error_count: 0,
errors: Vec::new(),
}
}
fn calculate_period_end(year: i32, period: u32) -> NaiveDate {
let month = period;
let next_month = if month == 12 { 1 } else { month + 1 };
let next_year = if month == 12 { year + 1 } else { year };
NaiveDate::from_ymd_opt(next_year, next_month, 1)
.expect("valid date components")
.pred_opt()
.expect("valid date components")
}
pub fn add_entry(&mut self, entry: DepreciationEntry) {
self.total_depreciation += entry.depreciation_amount;
self.asset_count += 1;
self.asset_entries.push(entry);
}
pub fn add_error(&mut self, error: DepreciationError) {
self.error_count += 1;
self.errors.push(error);
}
pub fn start(&mut self) {
self.status = DepreciationRunStatus::Running;
}
pub fn complete(&mut self) {
self.status = if self.error_count > 0 {
DepreciationRunStatus::CompletedWithErrors
} else {
DepreciationRunStatus::Completed
};
self.completed_at = Some(Utc::now());
}
pub fn post(&mut self) {
self.status = DepreciationRunStatus::Posted;
}
pub fn summary_by_class(&self) -> HashMap<String, DepreciationSummary> {
let mut summary: HashMap<String, DepreciationSummary> = HashMap::new();
for entry in &self.asset_entries {
let class_summary =
summary
.entry(entry.asset_class.clone())
.or_insert_with(|| DepreciationSummary {
category: entry.asset_class.clone(),
asset_count: 0,
total_depreciation: Decimal::ZERO,
total_net_book_value: Decimal::ZERO,
});
class_summary.asset_count += 1;
class_summary.total_depreciation += entry.depreciation_amount;
class_summary.total_net_book_value += entry.net_book_value_after;
}
summary
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
pub enum DepreciationRunStatus {
Created,
Running,
Completed,
CompletedWithErrors,
Posted,
Cancelled,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct DepreciationEntry {
pub asset_number: String,
pub sub_number: String,
pub asset_description: String,
pub asset_class: String,
pub depreciation_method: DepreciationMethod,
pub acquisition_cost: Decimal,
pub accumulated_before: Decimal,
pub depreciation_amount: Decimal,
pub accumulated_after: Decimal,
pub net_book_value_after: Decimal,
pub fully_depreciated: bool,
pub expense_account: String,
pub accum_depr_account: String,
pub cost_center: Option<String>,
}
impl DepreciationEntry {
pub fn from_asset(asset: &FixedAssetRecord, area_type: DepreciationAreaType) -> Option<Self> {
let area = asset
.depreciation_areas
.iter()
.find(|a| a.area_type == area_type)?;
let depreciation_amount = area.calculate_monthly_depreciation();
let accumulated_after = area.accumulated_depreciation + depreciation_amount;
let nbv_after = area.acquisition_cost - accumulated_after;
let fully_depreciated = nbv_after <= area.salvage_value;
Some(Self {
asset_number: asset.asset_number.clone(),
sub_number: asset.sub_number.clone(),
asset_description: asset.description.clone(),
asset_class: format!("{:?}", asset.asset_class),
depreciation_method: area.method,
acquisition_cost: area.acquisition_cost,
accumulated_before: area.accumulated_depreciation,
depreciation_amount,
accumulated_after,
net_book_value_after: nbv_after.max(Decimal::ZERO),
fully_depreciated,
expense_account: asset
.account_determination
.depreciation_expense_account
.clone(),
accum_depr_account: asset
.account_determination
.accumulated_depreciation_account
.clone(),
cost_center: asset.cost_center.clone(),
})
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct DepreciationError {
pub asset_number: String,
pub error_code: DepreciationErrorCode,
pub message: String,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
pub enum DepreciationErrorCode {
AssetNotFound,
FullyDepreciated,
NotActive,
MissingDepreciationArea,
InvalidMethod,
AlreadyDepreciated,
MissingCostCenter,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct DepreciationSummary {
pub category: String,
pub asset_count: u32,
pub total_depreciation: Decimal,
pub total_net_book_value: Decimal,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct DepreciationForecast {
pub company_code: String,
pub start_date: NaiveDate,
pub periods: u32,
pub monthly_forecasts: Vec<MonthlyDepreciationForecast>,
pub total_forecast: Decimal,
#[serde(with = "crate::serde_timestamp::utc")]
pub generated_at: DateTime<Utc>,
}
impl DepreciationForecast {
pub fn from_assets(
company_code: String,
assets: &[FixedAssetRecord],
start_date: NaiveDate,
periods: u32,
area_type: DepreciationAreaType,
) -> Self {
let active_assets: Vec<_> = assets
.iter()
.filter(|a| {
a.company_code == company_code
&& a.status == super::AssetStatus::Active
&& !a.is_fully_depreciated()
})
.collect();
let mut monthly_forecasts = Vec::new();
let mut total_forecast = Decimal::ZERO;
let mut current_date = start_date;
for period in 0..periods {
let mut period_total = Decimal::ZERO;
let mut asset_details = Vec::new();
for asset in &active_assets {
if let Some(area) = asset
.depreciation_areas
.iter()
.find(|a| a.area_type == area_type)
{
let projected_accum = area.accumulated_depreciation
+ area.calculate_monthly_depreciation() * Decimal::from(period);
let remaining_nbv =
(area.acquisition_cost - projected_accum).max(Decimal::ZERO);
if remaining_nbv > area.salvage_value {
let monthly = area.calculate_monthly_depreciation();
period_total += monthly;
asset_details.push(AssetDepreciationForecast {
asset_number: asset.asset_number.clone(),
depreciation_amount: monthly,
projected_nbv: remaining_nbv - monthly,
});
}
}
}
monthly_forecasts.push(MonthlyDepreciationForecast {
period_date: current_date,
total_depreciation: period_total,
asset_count: asset_details.len() as u32,
asset_details,
});
total_forecast += period_total;
current_date = if current_date.month() == 12 {
NaiveDate::from_ymd_opt(current_date.year() + 1, 1, 1)
.expect("valid date components")
} else {
NaiveDate::from_ymd_opt(current_date.year(), current_date.month() + 1, 1)
.expect("valid date components")
};
}
Self {
company_code,
start_date,
periods,
monthly_forecasts,
total_forecast,
generated_at: Utc::now(),
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct MonthlyDepreciationForecast {
pub period_date: NaiveDate,
pub total_depreciation: Decimal,
pub asset_count: u32,
pub asset_details: Vec<AssetDepreciationForecast>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct AssetDepreciationForecast {
pub asset_number: String,
pub depreciation_amount: Decimal,
pub projected_nbv: Decimal,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct DepreciationSchedule {
pub asset_number: String,
pub description: String,
pub acquisition_cost: Decimal,
pub salvage_value: Decimal,
pub method: DepreciationMethod,
pub useful_life_months: u32,
pub start_date: NaiveDate,
pub annual_entries: Vec<AnnualDepreciationEntry>,
}
impl DepreciationSchedule {
pub fn for_asset(asset: &FixedAssetRecord, area_type: DepreciationAreaType) -> Option<Self> {
let area = asset
.depreciation_areas
.iter()
.find(|a| a.area_type == area_type)?;
let depreciable_base = area.acquisition_cost - area.salvage_value;
let years = (area.useful_life_months as f64 / 12.0).ceil() as u32;
let mut annual_entries = Vec::new();
let mut cumulative = Decimal::ZERO;
let monthly = area.calculate_monthly_depreciation();
for year in 1..=years {
let annual = (monthly * dec!(12)).min(depreciable_base - cumulative);
cumulative += annual;
let ending_nbv = area.acquisition_cost - cumulative;
annual_entries.push(AnnualDepreciationEntry {
year,
beginning_nbv: area.acquisition_cost - (cumulative - annual),
depreciation: annual,
ending_nbv: ending_nbv.max(area.salvage_value),
});
if ending_nbv <= area.salvage_value {
break;
}
}
Some(Self {
asset_number: asset.asset_number.clone(),
description: asset.description.clone(),
acquisition_cost: area.acquisition_cost,
salvage_value: area.salvage_value,
method: area.method,
useful_life_months: area.useful_life_months,
start_date: asset.first_depreciation_date,
annual_entries,
})
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct AnnualDepreciationEntry {
pub year: u32,
pub beginning_nbv: Decimal,
pub depreciation: Decimal,
pub ending_nbv: Decimal,
}
#[cfg(test)]
#[allow(clippy::unwrap_used)]
mod tests {
use super::*;
use crate::models::subledger::fa::{AssetClass, DepreciationArea};
#[test]
fn test_depreciation_run() {
let mut run = DepreciationRun::new(
"RUN001".to_string(),
"1000".to_string(),
2024,
1,
DepreciationAreaType::Book,
NaiveDate::from_ymd_opt(2024, 1, 31).unwrap(),
"USER1".to_string(),
);
let entry = DepreciationEntry {
asset_number: "ASSET001".to_string(),
sub_number: "0".to_string(),
asset_description: "Test Asset".to_string(),
asset_class: "MachineryEquipment".to_string(),
depreciation_method: DepreciationMethod::StraightLine,
acquisition_cost: dec!(100000),
accumulated_before: Decimal::ZERO,
depreciation_amount: dec!(1666.67),
accumulated_after: dec!(1666.67),
net_book_value_after: dec!(98333.33),
fully_depreciated: false,
expense_account: "7100".to_string(),
accum_depr_account: "1539".to_string(),
cost_center: Some("CC100".to_string()),
};
run.add_entry(entry);
run.complete();
assert_eq!(run.asset_count, 1);
assert_eq!(run.total_depreciation, dec!(1666.67));
assert_eq!(run.status, DepreciationRunStatus::Completed);
}
#[test]
fn test_depreciation_forecast() {
let mut asset = FixedAssetRecord::new(
"ASSET001".to_string(),
"1000".to_string(),
AssetClass::MachineryEquipment,
"Machine".to_string(),
NaiveDate::from_ymd_opt(2024, 1, 1).unwrap(),
dec!(60000),
"USD".to_string(),
);
asset.add_depreciation_area(DepreciationArea::new(
DepreciationAreaType::Book,
DepreciationMethod::StraightLine,
60,
dec!(60000),
));
let assets = vec![asset];
let forecast = DepreciationForecast::from_assets(
"1000".to_string(),
&assets,
NaiveDate::from_ymd_opt(2024, 1, 1).unwrap(),
12,
DepreciationAreaType::Book,
);
assert_eq!(forecast.monthly_forecasts.len(), 12);
assert!(forecast.total_forecast > Decimal::ZERO);
}
#[test]
fn test_calculate_period_end() {
let end_jan = DepreciationRun::calculate_period_end(2024, 1);
assert_eq!(end_jan, NaiveDate::from_ymd_opt(2024, 1, 31).unwrap());
let end_dec = DepreciationRun::calculate_period_end(2024, 12);
assert_eq!(end_dec, NaiveDate::from_ymd_opt(2024, 12, 31).unwrap());
}
}