Skip to main content

datasynth_core/models/subledger/fa/
depreciation.rs

1//! Depreciation models and calculations.
2
3use chrono::{DateTime, Datelike, NaiveDate, Utc};
4use rust_decimal::Decimal;
5use rust_decimal_macros::dec;
6use serde::{Deserialize, Serialize};
7use std::collections::HashMap;
8
9use super::{DepreciationAreaType, DepreciationMethod, FixedAssetRecord};
10use crate::models::subledger::GLReference;
11
12/// Depreciation run (batch depreciation posting).
13#[derive(Debug, Clone, Serialize, Deserialize)]
14pub struct DepreciationRun {
15    /// Run ID.
16    pub run_id: String,
17    /// Company code.
18    pub company_code: String,
19    /// Fiscal year.
20    pub fiscal_year: i32,
21    /// Fiscal period.
22    pub fiscal_period: u32,
23    /// Depreciation area.
24    pub depreciation_area: DepreciationAreaType,
25    /// Run date.
26    pub run_date: NaiveDate,
27    /// Posting date.
28    pub posting_date: NaiveDate,
29    /// Run status.
30    pub status: DepreciationRunStatus,
31    /// Individual asset depreciation.
32    pub asset_entries: Vec<DepreciationEntry>,
33    /// Total depreciation amount.
34    pub total_depreciation: Decimal,
35    /// Asset count processed.
36    pub asset_count: u32,
37    /// GL references.
38    pub gl_references: Vec<GLReference>,
39    /// Created by.
40    pub created_by: String,
41    /// Created at.
42    pub created_at: DateTime<Utc>,
43    /// Completed at.
44    pub completed_at: Option<DateTime<Utc>>,
45    /// Error count.
46    pub error_count: u32,
47    /// Errors.
48    pub errors: Vec<DepreciationError>,
49}
50
51impl DepreciationRun {
52    /// Creates a new depreciation run.
53    pub fn new(
54        run_id: String,
55        company_code: String,
56        fiscal_year: i32,
57        fiscal_period: u32,
58        depreciation_area: DepreciationAreaType,
59        run_date: NaiveDate,
60        created_by: String,
61    ) -> Self {
62        // Calculate posting date (end of fiscal period)
63        let posting_date = Self::calculate_period_end(fiscal_year, fiscal_period);
64
65        Self {
66            run_id,
67            company_code,
68            fiscal_year,
69            fiscal_period,
70            depreciation_area,
71            run_date,
72            posting_date,
73            status: DepreciationRunStatus::Created,
74            asset_entries: Vec::new(),
75            total_depreciation: Decimal::ZERO,
76            asset_count: 0,
77            gl_references: Vec::new(),
78            created_by,
79            created_at: Utc::now(),
80            completed_at: None,
81            error_count: 0,
82            errors: Vec::new(),
83        }
84    }
85
86    /// Calculates period end date.
87    fn calculate_period_end(year: i32, period: u32) -> NaiveDate {
88        let month = period;
89        let next_month = if month == 12 { 1 } else { month + 1 };
90        let next_year = if month == 12 { year + 1 } else { year };
91        NaiveDate::from_ymd_opt(next_year, next_month, 1)
92            .unwrap()
93            .pred_opt()
94            .unwrap()
95    }
96
97    /// Adds a depreciation entry.
98    pub fn add_entry(&mut self, entry: DepreciationEntry) {
99        self.total_depreciation += entry.depreciation_amount;
100        self.asset_count += 1;
101        self.asset_entries.push(entry);
102    }
103
104    /// Adds an error.
105    pub fn add_error(&mut self, error: DepreciationError) {
106        self.error_count += 1;
107        self.errors.push(error);
108    }
109
110    /// Starts the run.
111    pub fn start(&mut self) {
112        self.status = DepreciationRunStatus::Running;
113    }
114
115    /// Completes the run.
116    pub fn complete(&mut self) {
117        self.status = if self.error_count > 0 {
118            DepreciationRunStatus::CompletedWithErrors
119        } else {
120            DepreciationRunStatus::Completed
121        };
122        self.completed_at = Some(Utc::now());
123    }
124
125    /// Posts the depreciation.
126    pub fn post(&mut self) {
127        self.status = DepreciationRunStatus::Posted;
128    }
129
130    /// Gets summary by asset class.
131    pub fn summary_by_class(&self) -> HashMap<String, DepreciationSummary> {
132        let mut summary: HashMap<String, DepreciationSummary> = HashMap::new();
133
134        for entry in &self.asset_entries {
135            let class_summary =
136                summary
137                    .entry(entry.asset_class.clone())
138                    .or_insert_with(|| DepreciationSummary {
139                        category: entry.asset_class.clone(),
140                        asset_count: 0,
141                        total_depreciation: Decimal::ZERO,
142                        total_net_book_value: Decimal::ZERO,
143                    });
144
145            class_summary.asset_count += 1;
146            class_summary.total_depreciation += entry.depreciation_amount;
147            class_summary.total_net_book_value += entry.net_book_value_after;
148        }
149
150        summary
151    }
152}
153
154/// Status of depreciation run.
155#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
156pub enum DepreciationRunStatus {
157    /// Created, not started.
158    Created,
159    /// Running.
160    Running,
161    /// Completed successfully.
162    Completed,
163    /// Completed with errors.
164    CompletedWithErrors,
165    /// Posted to GL.
166    Posted,
167    /// Cancelled.
168    Cancelled,
169}
170
171/// Individual asset depreciation entry.
172#[derive(Debug, Clone, Serialize, Deserialize)]
173pub struct DepreciationEntry {
174    /// Asset number.
175    pub asset_number: String,
176    /// Sub-number.
177    pub sub_number: String,
178    /// Asset description.
179    pub asset_description: String,
180    /// Asset class.
181    pub asset_class: String,
182    /// Depreciation method.
183    pub depreciation_method: DepreciationMethod,
184    /// Acquisition cost.
185    pub acquisition_cost: Decimal,
186    /// Accumulated depreciation before.
187    pub accumulated_before: Decimal,
188    /// Depreciation amount.
189    pub depreciation_amount: Decimal,
190    /// Accumulated depreciation after.
191    pub accumulated_after: Decimal,
192    /// Net book value after.
193    pub net_book_value_after: Decimal,
194    /// Is fully depreciated after this run.
195    pub fully_depreciated: bool,
196    /// Depreciation accounts.
197    pub expense_account: String,
198    /// Accumulated depreciation account.
199    pub accum_depr_account: String,
200    /// Cost center.
201    pub cost_center: Option<String>,
202}
203
204impl DepreciationEntry {
205    /// Creates from asset record.
206    pub fn from_asset(asset: &FixedAssetRecord, area_type: DepreciationAreaType) -> Option<Self> {
207        let area = asset
208            .depreciation_areas
209            .iter()
210            .find(|a| a.area_type == area_type)?;
211
212        let depreciation_amount = area.calculate_monthly_depreciation();
213        let accumulated_after = area.accumulated_depreciation + depreciation_amount;
214        let nbv_after = area.acquisition_cost - accumulated_after;
215        let fully_depreciated = nbv_after <= area.salvage_value;
216
217        Some(Self {
218            asset_number: asset.asset_number.clone(),
219            sub_number: asset.sub_number.clone(),
220            asset_description: asset.description.clone(),
221            asset_class: format!("{:?}", asset.asset_class),
222            depreciation_method: area.method,
223            acquisition_cost: area.acquisition_cost,
224            accumulated_before: area.accumulated_depreciation,
225            depreciation_amount,
226            accumulated_after,
227            net_book_value_after: nbv_after.max(Decimal::ZERO),
228            fully_depreciated,
229            expense_account: asset
230                .account_determination
231                .depreciation_expense_account
232                .clone(),
233            accum_depr_account: asset
234                .account_determination
235                .accumulated_depreciation_account
236                .clone(),
237            cost_center: asset.cost_center.clone(),
238        })
239    }
240}
241
242/// Error during depreciation run.
243#[derive(Debug, Clone, Serialize, Deserialize)]
244pub struct DepreciationError {
245    /// Asset number.
246    pub asset_number: String,
247    /// Error code.
248    pub error_code: DepreciationErrorCode,
249    /// Error message.
250    pub message: String,
251}
252
253/// Depreciation error codes.
254#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
255pub enum DepreciationErrorCode {
256    /// Asset not found.
257    AssetNotFound,
258    /// Asset already fully depreciated.
259    FullyDepreciated,
260    /// Asset not active.
261    NotActive,
262    /// Missing depreciation area.
263    MissingDepreciationArea,
264    /// Invalid depreciation method.
265    InvalidMethod,
266    /// Already depreciated for period.
267    AlreadyDepreciated,
268    /// Missing cost center.
269    MissingCostCenter,
270}
271
272/// Summary of depreciation by category.
273#[derive(Debug, Clone, Serialize, Deserialize)]
274pub struct DepreciationSummary {
275    /// Category (asset class).
276    pub category: String,
277    /// Asset count.
278    pub asset_count: u32,
279    /// Total depreciation.
280    pub total_depreciation: Decimal,
281    /// Total net book value.
282    pub total_net_book_value: Decimal,
283}
284
285/// Depreciation forecast.
286#[derive(Debug, Clone, Serialize, Deserialize)]
287pub struct DepreciationForecast {
288    /// Company code.
289    pub company_code: String,
290    /// Forecast start date.
291    pub start_date: NaiveDate,
292    /// Forecast periods.
293    pub periods: u32,
294    /// Monthly forecasts.
295    pub monthly_forecasts: Vec<MonthlyDepreciationForecast>,
296    /// Total forecasted depreciation.
297    pub total_forecast: Decimal,
298    /// Generated at.
299    pub generated_at: DateTime<Utc>,
300}
301
302impl DepreciationForecast {
303    /// Creates a forecast from assets.
304    pub fn from_assets(
305        company_code: String,
306        assets: &[FixedAssetRecord],
307        start_date: NaiveDate,
308        periods: u32,
309        area_type: DepreciationAreaType,
310    ) -> Self {
311        let active_assets: Vec<_> = assets
312            .iter()
313            .filter(|a| {
314                a.company_code == company_code
315                    && a.status == super::AssetStatus::Active
316                    && !a.is_fully_depreciated()
317            })
318            .collect();
319
320        let mut monthly_forecasts = Vec::new();
321        let mut total_forecast = Decimal::ZERO;
322        let mut current_date = start_date;
323
324        for period in 0..periods {
325            let mut period_total = Decimal::ZERO;
326            let mut asset_details = Vec::new();
327
328            for asset in &active_assets {
329                if let Some(area) = asset
330                    .depreciation_areas
331                    .iter()
332                    .find(|a| a.area_type == area_type)
333                {
334                    // Simulate depreciation considering fully depreciated threshold
335                    let projected_accum = area.accumulated_depreciation
336                        + area.calculate_monthly_depreciation() * Decimal::from(period);
337                    let remaining_nbv =
338                        (area.acquisition_cost - projected_accum).max(Decimal::ZERO);
339
340                    if remaining_nbv > area.salvage_value {
341                        let monthly = area.calculate_monthly_depreciation();
342                        period_total += monthly;
343                        asset_details.push(AssetDepreciationForecast {
344                            asset_number: asset.asset_number.clone(),
345                            depreciation_amount: monthly,
346                            projected_nbv: remaining_nbv - monthly,
347                        });
348                    }
349                }
350            }
351
352            monthly_forecasts.push(MonthlyDepreciationForecast {
353                period_date: current_date,
354                total_depreciation: period_total,
355                asset_count: asset_details.len() as u32,
356                asset_details,
357            });
358
359            total_forecast += period_total;
360
361            // Move to next month
362            current_date = if current_date.month() == 12 {
363                NaiveDate::from_ymd_opt(current_date.year() + 1, 1, 1).unwrap()
364            } else {
365                NaiveDate::from_ymd_opt(current_date.year(), current_date.month() + 1, 1).unwrap()
366            };
367        }
368
369        Self {
370            company_code,
371            start_date,
372            periods,
373            monthly_forecasts,
374            total_forecast,
375            generated_at: Utc::now(),
376        }
377    }
378}
379
380/// Monthly depreciation forecast.
381#[derive(Debug, Clone, Serialize, Deserialize)]
382pub struct MonthlyDepreciationForecast {
383    /// Period date (first of month).
384    pub period_date: NaiveDate,
385    /// Total depreciation.
386    pub total_depreciation: Decimal,
387    /// Asset count.
388    pub asset_count: u32,
389    /// Asset details.
390    pub asset_details: Vec<AssetDepreciationForecast>,
391}
392
393/// Asset-level depreciation forecast.
394#[derive(Debug, Clone, Serialize, Deserialize)]
395pub struct AssetDepreciationForecast {
396    /// Asset number.
397    pub asset_number: String,
398    /// Depreciation amount.
399    pub depreciation_amount: Decimal,
400    /// Projected net book value.
401    pub projected_nbv: Decimal,
402}
403
404/// Depreciation schedule (annual view).
405#[derive(Debug, Clone, Serialize, Deserialize)]
406pub struct DepreciationSchedule {
407    /// Asset number.
408    pub asset_number: String,
409    /// Asset description.
410    pub description: String,
411    /// Acquisition cost.
412    pub acquisition_cost: Decimal,
413    /// Salvage value.
414    pub salvage_value: Decimal,
415    /// Depreciation method.
416    pub method: DepreciationMethod,
417    /// Useful life in months.
418    pub useful_life_months: u32,
419    /// Start date.
420    pub start_date: NaiveDate,
421    /// Annual schedule.
422    pub annual_entries: Vec<AnnualDepreciationEntry>,
423}
424
425impl DepreciationSchedule {
426    /// Creates a schedule for an asset.
427    pub fn for_asset(asset: &FixedAssetRecord, area_type: DepreciationAreaType) -> Option<Self> {
428        let area = asset
429            .depreciation_areas
430            .iter()
431            .find(|a| a.area_type == area_type)?;
432
433        let depreciable_base = area.acquisition_cost - area.salvage_value;
434        let years = (area.useful_life_months as f64 / 12.0).ceil() as u32;
435
436        let mut annual_entries = Vec::new();
437        let mut cumulative = Decimal::ZERO;
438        let monthly = area.calculate_monthly_depreciation();
439
440        for year in 1..=years {
441            let annual = (monthly * dec!(12)).min(depreciable_base - cumulative);
442            cumulative += annual;
443            let ending_nbv = area.acquisition_cost - cumulative;
444
445            annual_entries.push(AnnualDepreciationEntry {
446                year,
447                beginning_nbv: area.acquisition_cost - (cumulative - annual),
448                depreciation: annual,
449                ending_nbv: ending_nbv.max(area.salvage_value),
450            });
451
452            if ending_nbv <= area.salvage_value {
453                break;
454            }
455        }
456
457        Some(Self {
458            asset_number: asset.asset_number.clone(),
459            description: asset.description.clone(),
460            acquisition_cost: area.acquisition_cost,
461            salvage_value: area.salvage_value,
462            method: area.method,
463            useful_life_months: area.useful_life_months,
464            start_date: asset.first_depreciation_date,
465            annual_entries,
466        })
467    }
468}
469
470/// Annual depreciation entry in schedule.
471#[derive(Debug, Clone, Serialize, Deserialize)]
472pub struct AnnualDepreciationEntry {
473    /// Year number.
474    pub year: u32,
475    /// Beginning net book value.
476    pub beginning_nbv: Decimal,
477    /// Depreciation amount.
478    pub depreciation: Decimal,
479    /// Ending net book value.
480    pub ending_nbv: Decimal,
481}
482
483#[cfg(test)]
484mod tests {
485    use super::*;
486    use crate::models::subledger::fa::{AssetClass, DepreciationArea};
487
488    #[test]
489    fn test_depreciation_run() {
490        let mut run = DepreciationRun::new(
491            "RUN001".to_string(),
492            "1000".to_string(),
493            2024,
494            1,
495            DepreciationAreaType::Book,
496            NaiveDate::from_ymd_opt(2024, 1, 31).unwrap(),
497            "USER1".to_string(),
498        );
499
500        let entry = DepreciationEntry {
501            asset_number: "ASSET001".to_string(),
502            sub_number: "0".to_string(),
503            asset_description: "Test Asset".to_string(),
504            asset_class: "MachineryEquipment".to_string(),
505            depreciation_method: DepreciationMethod::StraightLine,
506            acquisition_cost: dec!(100000),
507            accumulated_before: Decimal::ZERO,
508            depreciation_amount: dec!(1666.67),
509            accumulated_after: dec!(1666.67),
510            net_book_value_after: dec!(98333.33),
511            fully_depreciated: false,
512            expense_account: "7100".to_string(),
513            accum_depr_account: "1539".to_string(),
514            cost_center: Some("CC100".to_string()),
515        };
516
517        run.add_entry(entry);
518        run.complete();
519
520        assert_eq!(run.asset_count, 1);
521        assert_eq!(run.total_depreciation, dec!(1666.67));
522        assert_eq!(run.status, DepreciationRunStatus::Completed);
523    }
524
525    #[test]
526    fn test_depreciation_forecast() {
527        let mut asset = FixedAssetRecord::new(
528            "ASSET001".to_string(),
529            "1000".to_string(),
530            AssetClass::MachineryEquipment,
531            "Machine".to_string(),
532            NaiveDate::from_ymd_opt(2024, 1, 1).unwrap(),
533            dec!(60000),
534            "USD".to_string(),
535        );
536
537        asset.add_depreciation_area(DepreciationArea::new(
538            DepreciationAreaType::Book,
539            DepreciationMethod::StraightLine,
540            60,
541            dec!(60000),
542        ));
543
544        let assets = vec![asset];
545        let forecast = DepreciationForecast::from_assets(
546            "1000".to_string(),
547            &assets,
548            NaiveDate::from_ymd_opt(2024, 1, 1).unwrap(),
549            12,
550            DepreciationAreaType::Book,
551        );
552
553        assert_eq!(forecast.monthly_forecasts.len(), 12);
554        assert!(forecast.total_forecast > Decimal::ZERO);
555    }
556
557    #[test]
558    fn test_calculate_period_end() {
559        let end_jan = DepreciationRun::calculate_period_end(2024, 1);
560        assert_eq!(end_jan, NaiveDate::from_ymd_opt(2024, 1, 31).unwrap());
561
562        let end_dec = DepreciationRun::calculate_period_end(2024, 12);
563        assert_eq!(end_dec, NaiveDate::from_ymd_opt(2024, 12, 31).unwrap());
564    }
565}