Skip to main content

datasynth_generators/period_close/
depreciation.rs

1//! Depreciation run generator for period close.
2
3use rust_decimal::Decimal;
4use rust_decimal_macros::dec;
5use tracing::debug;
6
7use datasynth_core::accounts::{control_accounts, expense_accounts};
8use datasynth_core::models::subledger::fa::{
9    AssetStatus, DepreciationAreaType, DepreciationEntry, DepreciationRun, DepreciationRunStatus,
10    FixedAssetRecord,
11};
12use datasynth_core::models::{FiscalPeriod, JournalEntry, JournalEntryLine};
13
14/// Configuration for depreciation run.
15#[derive(Debug, Clone)]
16pub struct DepreciationRunConfig {
17    /// Default depreciation expense account.
18    pub default_expense_account: String,
19    /// Default accumulated depreciation account.
20    pub default_accum_depr_account: String,
21    /// Whether to post zero depreciation entries.
22    pub post_zero_entries: bool,
23    /// Minimum depreciation amount to post.
24    pub minimum_amount: Decimal,
25}
26
27impl Default for DepreciationRunConfig {
28    fn default() -> Self {
29        Self {
30            default_expense_account: expense_accounts::DEPRECIATION.to_string(),
31            default_accum_depr_account: control_accounts::ACCUMULATED_DEPRECIATION.to_string(),
32            post_zero_entries: false,
33            minimum_amount: dec!(0.01),
34        }
35    }
36}
37
38/// Generator for depreciation runs.
39pub struct DepreciationRunGenerator {
40    config: DepreciationRunConfig,
41    run_counter: u64,
42}
43
44impl DepreciationRunGenerator {
45    /// Creates a new depreciation run generator.
46    pub fn new(config: DepreciationRunConfig) -> Self {
47        Self {
48            config,
49            run_counter: 0,
50        }
51    }
52
53    /// Executes a depreciation run for a company.
54    pub fn execute_run(
55        &mut self,
56        company_code: &str,
57        assets: &mut [FixedAssetRecord],
58        fiscal_period: &FiscalPeriod,
59    ) -> DepreciationRunResult {
60        debug!(
61            company_code,
62            asset_count = assets.len(),
63            period = fiscal_period.period,
64            year = fiscal_period.year,
65            "Executing depreciation run"
66        );
67        self.run_counter += 1;
68        let run_id = format!("DEPR-{}-{:08}", company_code, self.run_counter);
69
70        let mut run = DepreciationRun::new(
71            run_id.clone(),
72            company_code.to_string(),
73            fiscal_period.year,
74            fiscal_period.period as u32,
75            DepreciationAreaType::Book,
76            fiscal_period.end_date,
77            "SYSTEM".to_string(),
78        );
79
80        run.start();
81
82        let mut journal_entries = Vec::new();
83        let errors = Vec::new();
84
85        for asset in assets.iter_mut() {
86            // Skip non-active assets
87            if asset.status != AssetStatus::Active {
88                continue;
89            }
90
91            // Skip if company doesn't match
92            if asset.company_code != company_code {
93                continue;
94            }
95
96            // Create entry from asset using the from_asset method
97            if let Some(entry) = DepreciationEntry::from_asset(asset, DepreciationAreaType::Book) {
98                if entry.depreciation_amount < self.config.minimum_amount
99                    && !self.config.post_zero_entries
100                {
101                    continue;
102                }
103
104                // Generate journal entry
105                let je = self.generate_depreciation_je(asset, &entry, fiscal_period);
106
107                // Update asset's depreciation
108                asset.record_depreciation(entry.depreciation_amount, DepreciationAreaType::Book);
109
110                // Check if fully depreciated
111                if asset.is_fully_depreciated() {
112                    asset.status = AssetStatus::FullyDepreciated;
113                }
114
115                run.add_entry(entry);
116                journal_entries.push(je);
117            }
118        }
119
120        run.complete();
121
122        DepreciationRunResult {
123            run,
124            journal_entries,
125            errors,
126        }
127    }
128
129    /// Generates the journal entry for a depreciation entry.
130    fn generate_depreciation_je(
131        &self,
132        asset: &FixedAssetRecord,
133        entry: &DepreciationEntry,
134        period: &FiscalPeriod,
135    ) -> JournalEntry {
136        let expense_account = if entry.expense_account.is_empty() {
137            &self.config.default_expense_account
138        } else {
139            &entry.expense_account
140        };
141
142        let accum_account = if entry.accum_depr_account.is_empty() {
143            &self.config.default_accum_depr_account
144        } else {
145            &entry.accum_depr_account
146        };
147
148        let mut je = JournalEntry::new_simple(
149            format!("DEPR-{}", asset.asset_number),
150            asset.company_code.clone(),
151            period.end_date,
152            format!(
153                "Depreciation {} P{}/{}",
154                asset.asset_number, period.year, period.period
155            ),
156        );
157
158        // Debit Depreciation Expense
159        je.add_line(JournalEntryLine {
160            line_number: 1,
161            gl_account: expense_account.to_string(),
162            debit_amount: entry.depreciation_amount,
163            cost_center: asset.cost_center.clone(),
164            profit_center: asset.profit_center.clone(),
165            reference: Some(asset.asset_number.clone()),
166            assignment: Some(format!("{:?}", asset.asset_class)),
167            text: Some(asset.description.clone()),
168            ..Default::default()
169        });
170
171        // Credit Accumulated Depreciation
172        je.add_line(JournalEntryLine {
173            line_number: 2,
174            gl_account: accum_account.to_string(),
175            credit_amount: entry.depreciation_amount,
176            reference: Some(asset.asset_number.clone()),
177            assignment: Some(format!("{:?}", asset.asset_class)),
178            ..Default::default()
179        });
180
181        je
182    }
183
184    /// Generates a depreciation forecast for planning purposes.
185    pub fn forecast_depreciation(
186        &self,
187        assets: &[FixedAssetRecord],
188        start_period: &FiscalPeriod,
189        months: u32,
190    ) -> Vec<DepreciationForecastEntry> {
191        let mut forecast = Vec::new();
192
193        // Create simulated asset states
194        let mut simulated_assets: Vec<SimulatedAsset> = assets
195            .iter()
196            .filter(|a| a.status == AssetStatus::Active)
197            .map(|a| {
198                let monthly_depr = a
199                    .depreciation_areas
200                    .first()
201                    .map(|area| area.calculate_monthly_depreciation())
202                    .unwrap_or(Decimal::ZERO);
203                SimulatedAsset {
204                    asset_number: a.asset_number.clone(),
205                    net_book_value: a.net_book_value,
206                    salvage_value: a.salvage_value(),
207                    monthly_depreciation: monthly_depr,
208                }
209            })
210            .collect();
211
212        let mut current_year = start_period.year;
213        let mut current_month = start_period.period;
214
215        for _ in 0..months {
216            let period_key = format!("{}-{:02}", current_year, current_month);
217            let mut period_total = Decimal::ZERO;
218
219            for sim_asset in &mut simulated_assets {
220                let remaining = sim_asset.net_book_value - sim_asset.salvage_value;
221                if remaining > Decimal::ZERO {
222                    let depr = sim_asset.monthly_depreciation.min(remaining);
223                    sim_asset.net_book_value -= depr;
224                    period_total += depr;
225                }
226            }
227
228            forecast.push(DepreciationForecastEntry {
229                period_key,
230                fiscal_year: current_year,
231                fiscal_period: current_month,
232                forecasted_depreciation: period_total,
233            });
234
235            // Advance to next month
236            if current_month == 12 {
237                current_month = 1;
238                current_year += 1;
239            } else {
240                current_month += 1;
241            }
242        }
243
244        forecast
245    }
246}
247
248/// Simulated asset state for forecasting.
249struct SimulatedAsset {
250    // Retained for debugging/tracing individual asset forecast lines.
251    #[allow(dead_code)]
252    asset_number: String,
253    net_book_value: Decimal,
254    salvage_value: Decimal,
255    monthly_depreciation: Decimal,
256}
257
258/// Result of a depreciation run.
259#[derive(Debug, Clone)]
260pub struct DepreciationRunResult {
261    /// The depreciation run record.
262    pub run: DepreciationRun,
263    /// Generated journal entries.
264    pub journal_entries: Vec<JournalEntry>,
265    /// Errors encountered.
266    pub errors: Vec<DepreciationError>,
267}
268
269impl DepreciationRunResult {
270    /// Returns true if the run completed successfully.
271    pub fn is_success(&self) -> bool {
272        matches!(
273            self.run.status,
274            DepreciationRunStatus::Completed | DepreciationRunStatus::CompletedWithErrors
275        )
276    }
277
278    /// Returns the total depreciation amount.
279    pub fn total_depreciation(&self) -> Decimal {
280        self.run.total_depreciation
281    }
282}
283
284/// Error during depreciation processing.
285#[derive(Debug, Clone)]
286pub struct DepreciationError {
287    /// Asset ID.
288    pub asset_number: String,
289    /// Error message.
290    pub error: String,
291}
292
293/// Depreciation forecast entry.
294#[derive(Debug, Clone)]
295pub struct DepreciationForecastEntry {
296    /// Period key (YYYY-MM).
297    pub period_key: String,
298    /// Fiscal year.
299    pub fiscal_year: i32,
300    /// Fiscal period.
301    pub fiscal_period: u8,
302    /// Forecasted depreciation amount.
303    pub forecasted_depreciation: Decimal,
304}
305
306#[cfg(test)]
307#[allow(clippy::unwrap_used)]
308mod tests {
309    use super::*;
310    use chrono::NaiveDate;
311    use datasynth_core::models::subledger::fa::{AssetClass, DepreciationArea, DepreciationMethod};
312    use rust_decimal_macros::dec;
313
314    fn create_test_asset() -> FixedAssetRecord {
315        let mut asset = FixedAssetRecord::new(
316            "FA00001".to_string(),
317            "1000".to_string(),
318            AssetClass::MachineryEquipment,
319            "Test Machine".to_string(),
320            NaiveDate::from_ymd_opt(2024, 1, 1).unwrap(),
321            dec!(120000),
322            "USD".to_string(),
323        );
324
325        // Add depreciation area with salvage value
326        let area = DepreciationArea::new(
327            DepreciationAreaType::Book,
328            DepreciationMethod::StraightLine,
329            60, // 5 years
330            dec!(120000),
331        )
332        .with_salvage_value(dec!(12000));
333
334        asset.add_depreciation_area(area);
335        asset.cost_center = Some("CC100".to_string());
336
337        asset
338    }
339
340    #[test]
341    fn test_depreciation_run() {
342        let mut generator = DepreciationRunGenerator::new(DepreciationRunConfig::default());
343        let mut assets = vec![create_test_asset()];
344        let period = FiscalPeriod::monthly(2024, 1);
345
346        let result = generator.execute_run("1000", &mut assets, &period);
347
348        assert!(result.is_success());
349        assert!(result.journal_entries.iter().all(|je| je.is_balanced()));
350
351        // Monthly depreciation should be (120000 - 12000) / 60 = 1800
352        assert_eq!(result.total_depreciation(), dec!(1800));
353    }
354
355    #[test]
356    fn test_depreciation_forecast() {
357        let generator = DepreciationRunGenerator::new(DepreciationRunConfig::default());
358        let assets = vec![create_test_asset()];
359        let period = FiscalPeriod::monthly(2024, 1);
360
361        let forecast = generator.forecast_depreciation(&assets, &period, 12);
362
363        assert_eq!(forecast.len(), 12);
364        assert!(forecast
365            .iter()
366            .all(|f| f.forecasted_depreciation == dec!(1800)));
367    }
368}