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(datasynth_core::subledger::fa::DepreciationArea::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!("{current_year}-{current_month:02}");
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    /// Asset number — not read during forecast computation but retained so
261    /// that debug/trace output can identify which asset a forecast line
262    /// belongs to.
263    #[allow(dead_code)]
264    asset_number: String,
265    net_book_value: Decimal,
266    salvage_value: Decimal,
267    monthly_depreciation: Decimal,
268}
269
270/// Result of a depreciation run.
271#[derive(Debug, Clone)]
272pub struct DepreciationRunResult {
273    /// The depreciation run record.
274    pub run: DepreciationRun,
275    /// Generated journal entries.
276    pub journal_entries: Vec<JournalEntry>,
277    /// Errors encountered.
278    pub errors: Vec<DepreciationError>,
279}
280
281impl DepreciationRunResult {
282    /// Returns true if the run completed successfully.
283    pub fn is_success(&self) -> bool {
284        matches!(
285            self.run.status,
286            DepreciationRunStatus::Completed | DepreciationRunStatus::CompletedWithErrors
287        )
288    }
289
290    /// Returns the total depreciation amount.
291    pub fn total_depreciation(&self) -> Decimal {
292        self.run.total_depreciation
293    }
294}
295
296/// Error during depreciation processing.
297#[derive(Debug, Clone)]
298pub struct DepreciationError {
299    /// Asset ID.
300    pub asset_number: String,
301    /// Error message.
302    pub error: String,
303}
304
305/// Depreciation forecast entry.
306#[derive(Debug, Clone)]
307pub struct DepreciationForecastEntry {
308    /// Period key (YYYY-MM).
309    pub period_key: String,
310    /// Fiscal year.
311    pub fiscal_year: i32,
312    /// Fiscal period.
313    pub fiscal_period: u8,
314    /// Forecasted depreciation amount.
315    pub forecasted_depreciation: Decimal,
316}
317
318#[cfg(test)]
319#[allow(clippy::unwrap_used)]
320mod tests {
321    use super::*;
322    use chrono::NaiveDate;
323    use datasynth_core::models::subledger::fa::{AssetClass, DepreciationArea, DepreciationMethod};
324    use rust_decimal_macros::dec;
325
326    fn create_test_asset() -> FixedAssetRecord {
327        let mut asset = FixedAssetRecord::new(
328            "FA00001".to_string(),
329            "1000".to_string(),
330            AssetClass::MachineryEquipment,
331            "Test Machine".to_string(),
332            NaiveDate::from_ymd_opt(2024, 1, 1).unwrap(),
333            dec!(120000),
334            "USD".to_string(),
335        );
336
337        // Add depreciation area with salvage value
338        let area = DepreciationArea::new(
339            DepreciationAreaType::Book,
340            DepreciationMethod::StraightLine,
341            60, // 5 years
342            dec!(120000),
343        )
344        .with_salvage_value(dec!(12000));
345
346        asset.add_depreciation_area(area);
347        asset.cost_center = Some("CC100".to_string());
348
349        asset
350    }
351
352    #[test]
353    fn test_depreciation_run() {
354        let mut generator = DepreciationRunGenerator::new(DepreciationRunConfig::default());
355        let mut assets = vec![create_test_asset()];
356        let period = FiscalPeriod::monthly(2024, 1);
357
358        let result = generator.execute_run("1000", &mut assets, &period);
359
360        assert!(result.is_success());
361        assert!(result.journal_entries.iter().all(|je| je.is_balanced()));
362
363        // Monthly depreciation should be (120000 - 12000) / 60 = 1800
364        assert_eq!(result.total_depreciation(), dec!(1800));
365    }
366
367    #[test]
368    fn test_depreciation_forecast() {
369        let generator = DepreciationRunGenerator::new(DepreciationRunConfig::default());
370        let assets = vec![create_test_asset()];
371        let period = FiscalPeriod::monthly(2024, 1);
372
373        let forecast = generator.forecast_depreciation(&assets, &period, 12);
374
375        assert_eq!(forecast.len(), 12);
376        assert!(forecast
377            .iter()
378            .all(|f| f.forecasted_depreciation == dec!(1800)));
379    }
380}