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