use rust_decimal::Decimal;
use rust_decimal_macros::dec;
use tracing::debug;
use datasynth_core::accounts::{control_accounts, expense_accounts};
use datasynth_core::models::subledger::fa::{
AssetStatus, DepreciationAreaType, DepreciationEntry, DepreciationRun, DepreciationRunStatus,
FixedAssetRecord,
};
use datasynth_core::models::{FiscalPeriod, JournalEntry, JournalEntryLine};
#[derive(Debug, Clone)]
pub struct DepreciationRunConfig {
pub default_expense_account: String,
pub default_accum_depr_account: String,
pub post_zero_entries: bool,
pub minimum_amount: Decimal,
}
impl Default for DepreciationRunConfig {
fn default() -> Self {
Self {
default_expense_account: expense_accounts::DEPRECIATION.to_string(),
default_accum_depr_account: control_accounts::ACCUMULATED_DEPRECIATION.to_string(),
post_zero_entries: false,
minimum_amount: dec!(0.01),
}
}
}
impl From<&datasynth_core::FrameworkAccounts> for DepreciationRunConfig {
fn from(fa: &datasynth_core::FrameworkAccounts) -> Self {
Self {
default_expense_account: fa.depreciation_expense.clone(),
default_accum_depr_account: fa.accumulated_depreciation.clone(),
..Default::default()
}
}
}
pub struct DepreciationRunGenerator {
config: DepreciationRunConfig,
run_counter: u64,
}
impl DepreciationRunGenerator {
pub fn new(config: DepreciationRunConfig) -> Self {
Self {
config,
run_counter: 0,
}
}
pub fn execute_run(
&mut self,
company_code: &str,
assets: &mut [FixedAssetRecord],
fiscal_period: &FiscalPeriod,
) -> DepreciationRunResult {
debug!(
company_code,
asset_count = assets.len(),
period = fiscal_period.period,
year = fiscal_period.year,
"Executing depreciation run"
);
self.run_counter += 1;
let run_id = format!("DEPR-{}-{:08}", company_code, self.run_counter);
let mut run = DepreciationRun::new(
run_id.clone(),
company_code.to_string(),
fiscal_period.year,
fiscal_period.period as u32,
DepreciationAreaType::Book,
fiscal_period.end_date,
"SYSTEM".to_string(),
);
run.start();
let mut journal_entries = Vec::new();
let errors = Vec::new();
for asset in assets.iter_mut() {
if asset.status != AssetStatus::Active {
continue;
}
if asset.company_code != company_code {
continue;
}
if let Some(entry) = DepreciationEntry::from_asset(asset, DepreciationAreaType::Book) {
if entry.depreciation_amount < self.config.minimum_amount
&& !self.config.post_zero_entries
{
continue;
}
let je = self.generate_depreciation_je(asset, &entry, fiscal_period);
asset.record_depreciation(entry.depreciation_amount, DepreciationAreaType::Book);
if asset.is_fully_depreciated() {
asset.status = AssetStatus::FullyDepreciated;
}
run.add_entry(entry);
journal_entries.push(je);
}
}
run.complete();
DepreciationRunResult {
run,
journal_entries,
errors,
}
}
fn generate_depreciation_je(
&self,
asset: &FixedAssetRecord,
entry: &DepreciationEntry,
period: &FiscalPeriod,
) -> JournalEntry {
let expense_account = if entry.expense_account.is_empty() {
&self.config.default_expense_account
} else {
&entry.expense_account
};
let accum_account = if entry.accum_depr_account.is_empty() {
&self.config.default_accum_depr_account
} else {
&entry.accum_depr_account
};
let mut je = JournalEntry::new_simple(
format!("DEPR-{}", asset.asset_number),
asset.company_code.clone(),
period.end_date,
format!(
"Depreciation {} P{}/{}",
asset.asset_number, period.year, period.period
),
);
je.add_line(JournalEntryLine {
line_number: 1,
gl_account: expense_account.to_string(),
debit_amount: entry.depreciation_amount,
cost_center: asset.cost_center.clone(),
profit_center: asset.profit_center.clone(),
reference: Some(asset.asset_number.clone()),
assignment: Some(format!("{:?}", asset.asset_class)),
text: Some(asset.description.clone()),
..Default::default()
});
je.add_line(JournalEntryLine {
line_number: 2,
gl_account: accum_account.to_string(),
credit_amount: entry.depreciation_amount,
reference: Some(asset.asset_number.clone()),
assignment: Some(format!("{:?}", asset.asset_class)),
..Default::default()
});
je
}
pub fn forecast_depreciation(
&self,
assets: &[FixedAssetRecord],
start_period: &FiscalPeriod,
months: u32,
) -> Vec<DepreciationForecastEntry> {
let mut forecast = Vec::new();
let mut simulated_assets: Vec<SimulatedAsset> = assets
.iter()
.filter(|a| a.status == AssetStatus::Active)
.map(|a| {
let monthly_depr = a
.depreciation_areas
.first()
.map(datasynth_core::subledger::fa::DepreciationArea::calculate_monthly_depreciation)
.unwrap_or(Decimal::ZERO);
SimulatedAsset {
asset_number: a.asset_number.clone(),
net_book_value: a.net_book_value,
salvage_value: a.salvage_value(),
monthly_depreciation: monthly_depr,
}
})
.collect();
let mut current_year = start_period.year;
let mut current_month = start_period.period;
for _ in 0..months {
let period_key = format!("{current_year}-{current_month:02}");
let mut period_total = Decimal::ZERO;
for sim_asset in &mut simulated_assets {
let remaining = sim_asset.net_book_value - sim_asset.salvage_value;
if remaining > Decimal::ZERO {
let depr = sim_asset.monthly_depreciation.min(remaining);
sim_asset.net_book_value -= depr;
period_total += depr;
}
}
forecast.push(DepreciationForecastEntry {
period_key,
fiscal_year: current_year,
fiscal_period: current_month,
forecasted_depreciation: period_total,
});
if current_month == 12 {
current_month = 1;
current_year += 1;
} else {
current_month += 1;
}
}
forecast
}
}
struct SimulatedAsset {
#[allow(dead_code)]
asset_number: String,
net_book_value: Decimal,
salvage_value: Decimal,
monthly_depreciation: Decimal,
}
#[derive(Debug, Clone)]
pub struct DepreciationRunResult {
pub run: DepreciationRun,
pub journal_entries: Vec<JournalEntry>,
pub errors: Vec<DepreciationError>,
}
impl DepreciationRunResult {
pub fn is_success(&self) -> bool {
matches!(
self.run.status,
DepreciationRunStatus::Completed | DepreciationRunStatus::CompletedWithErrors
)
}
pub fn total_depreciation(&self) -> Decimal {
self.run.total_depreciation
}
}
#[derive(Debug, Clone)]
pub struct DepreciationError {
pub asset_number: String,
pub error: String,
}
#[derive(Debug, Clone)]
pub struct DepreciationForecastEntry {
pub period_key: String,
pub fiscal_year: i32,
pub fiscal_period: u8,
pub forecasted_depreciation: Decimal,
}
#[cfg(test)]
#[allow(clippy::unwrap_used)]
mod tests {
use super::*;
use chrono::NaiveDate;
use datasynth_core::models::subledger::fa::{AssetClass, DepreciationArea, DepreciationMethod};
use rust_decimal_macros::dec;
fn create_test_asset() -> FixedAssetRecord {
let mut asset = FixedAssetRecord::new(
"FA00001".to_string(),
"1000".to_string(),
AssetClass::MachineryEquipment,
"Test Machine".to_string(),
NaiveDate::from_ymd_opt(2024, 1, 1).unwrap(),
dec!(120000),
"USD".to_string(),
);
let area = DepreciationArea::new(
DepreciationAreaType::Book,
DepreciationMethod::StraightLine,
60, dec!(120000),
)
.with_salvage_value(dec!(12000));
asset.add_depreciation_area(area);
asset.cost_center = Some("CC100".to_string());
asset
}
#[test]
fn test_depreciation_run() {
let mut generator = DepreciationRunGenerator::new(DepreciationRunConfig::default());
let mut assets = vec![create_test_asset()];
let period = FiscalPeriod::monthly(2024, 1);
let result = generator.execute_run("1000", &mut assets, &period);
assert!(result.is_success());
assert!(result.journal_entries.iter().all(|je| je.is_balanced()));
assert_eq!(result.total_depreciation(), dec!(1800));
}
#[test]
fn test_depreciation_forecast() {
let generator = DepreciationRunGenerator::new(DepreciationRunConfig::default());
let assets = vec![create_test_asset()];
let period = FiscalPeriod::monthly(2024, 1);
let forecast = generator.forecast_depreciation(&assets, &period, 12);
assert_eq!(forecast.len(), 12);
assert!(forecast
.iter()
.all(|f| f.forecasted_depreciation == dec!(1800)));
}
}