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
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
48pub struct DepreciationRunGenerator {
50 config: DepreciationRunConfig,
51 run_counter: u64,
52}
53
54impl DepreciationRunGenerator {
55 pub fn new(config: DepreciationRunConfig) -> Self {
57 Self {
58 config,
59 run_counter: 0,
60 }
61 }
62
63 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 if asset.status != AssetStatus::Active {
98 continue;
99 }
100
101 if asset.company_code != company_code {
103 continue;
104 }
105
106 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 let je = self.generate_depreciation_je(asset, &entry, fiscal_period);
116
117 asset.record_depreciation(entry.depreciation_amount, DepreciationAreaType::Book);
119
120 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 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 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 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 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 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 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
258struct SimulatedAsset {
260 #[allow(dead_code)]
262 asset_number: String,
263 net_book_value: Decimal,
264 salvage_value: Decimal,
265 monthly_depreciation: Decimal,
266}
267
268#[derive(Debug, Clone)]
270pub struct DepreciationRunResult {
271 pub run: DepreciationRun,
273 pub journal_entries: Vec<JournalEntry>,
275 pub errors: Vec<DepreciationError>,
277}
278
279impl DepreciationRunResult {
280 pub fn is_success(&self) -> bool {
282 matches!(
283 self.run.status,
284 DepreciationRunStatus::Completed | DepreciationRunStatus::CompletedWithErrors
285 )
286 }
287
288 pub fn total_depreciation(&self) -> Decimal {
290 self.run.total_depreciation
291 }
292}
293
294#[derive(Debug, Clone)]
296pub struct DepreciationError {
297 pub asset_number: String,
299 pub error: String,
301}
302
303#[derive(Debug, Clone)]
305pub struct DepreciationForecastEntry {
306 pub period_key: String,
308 pub fiscal_year: i32,
310 pub fiscal_period: u8,
312 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 let area = DepreciationArea::new(
337 DepreciationAreaType::Book,
338 DepreciationMethod::StraightLine,
339 60, 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 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}