datasynth_generators/period_close/
depreciation.rs

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