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