datasynth_generators/period_close/
depreciation.rs1use 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#[derive(Debug, Clone)]
16pub struct DepreciationRunConfig {
17 pub default_expense_account: String,
19 pub default_accum_depr_account: String,
21 pub post_zero_entries: bool,
23 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
38pub struct DepreciationRunGenerator {
40 config: DepreciationRunConfig,
41 run_counter: u64,
42}
43
44impl DepreciationRunGenerator {
45 pub fn new(config: DepreciationRunConfig) -> Self {
47 Self {
48 config,
49 run_counter: 0,
50 }
51 }
52
53 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 if asset.status != AssetStatus::Active {
88 continue;
89 }
90
91 if asset.company_code != company_code {
93 continue;
94 }
95
96 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 let je = self.generate_depreciation_je(asset, &entry, fiscal_period);
106
107 asset.record_depreciation(entry.depreciation_amount, DepreciationAreaType::Book);
109
110 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 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 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 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 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 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 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
248struct SimulatedAsset {
250 #[allow(dead_code)]
252 asset_number: String,
253 net_book_value: Decimal,
254 salvage_value: Decimal,
255 monthly_depreciation: Decimal,
256}
257
258#[derive(Debug, Clone)]
260pub struct DepreciationRunResult {
261 pub run: DepreciationRun,
263 pub journal_entries: Vec<JournalEntry>,
265 pub errors: Vec<DepreciationError>,
267}
268
269impl DepreciationRunResult {
270 pub fn is_success(&self) -> bool {
272 matches!(
273 self.run.status,
274 DepreciationRunStatus::Completed | DepreciationRunStatus::CompletedWithErrors
275 )
276 }
277
278 pub fn total_depreciation(&self) -> Decimal {
280 self.run.total_depreciation
281 }
282}
283
284#[derive(Debug, Clone)]
286pub struct DepreciationError {
287 pub asset_number: String,
289 pub error: String,
291}
292
293#[derive(Debug, Clone)]
295pub struct DepreciationForecastEntry {
296 pub period_key: String,
298 pub fiscal_year: i32,
300 pub fiscal_period: u8,
302 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 let area = DepreciationArea::new(
327 DepreciationAreaType::Book,
328 DepreciationMethod::StraightLine,
329 60, 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 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}