Skip to main content

datasynth_generators/period_close/
close_engine.rs

1//! Period close engine for orchestrating the close process.
2
3use rust_decimal::Decimal;
4use tracing::debug;
5
6use datasynth_core::models::{
7    CloseSchedule, CloseTask, CloseTaskResult, CloseTaskStatus, FiscalPeriod, JournalEntry,
8    PeriodCloseRun, PeriodCloseStatus, PeriodStatus,
9};
10
11/// Configuration for the close engine.
12#[derive(Debug, Clone)]
13pub struct CloseEngineConfig {
14    /// Whether to stop on first error.
15    pub stop_on_error: bool,
16    /// Whether to generate reversal entries for accruals.
17    pub auto_reverse_accruals: bool,
18    /// Whether to validate subledger reconciliation.
19    pub require_reconciliation: bool,
20    /// Tolerance for reconciliation differences.
21    pub reconciliation_tolerance: Decimal,
22}
23
24impl Default for CloseEngineConfig {
25    fn default() -> Self {
26        Self {
27            stop_on_error: false,
28            auto_reverse_accruals: true,
29            require_reconciliation: true,
30            reconciliation_tolerance: Decimal::new(1, 2), // 0.01
31        }
32    }
33}
34
35/// Period close engine that orchestrates the close process.
36pub struct CloseEngine {
37    config: CloseEngineConfig,
38    run_counter: u64,
39}
40
41impl CloseEngine {
42    /// Creates a new close engine.
43    pub fn new(config: CloseEngineConfig) -> Self {
44        Self {
45            config,
46            run_counter: 0,
47        }
48    }
49
50    /// Executes a period close for a company.
51    pub fn execute_close(
52        &mut self,
53        company_code: &str,
54        fiscal_period: FiscalPeriod,
55        schedule: &CloseSchedule,
56        context: &mut CloseContext,
57    ) -> PeriodCloseRun {
58        debug!(
59            company_code,
60            period = fiscal_period.period,
61            year = fiscal_period.year,
62            task_count = schedule.tasks.len(),
63            "Executing period close"
64        );
65        self.run_counter += 1;
66        let run_id = format!("CLOSE-{:08}", self.run_counter);
67
68        let mut run = PeriodCloseRun::new(run_id, company_code.to_string(), fiscal_period.clone());
69        run.status = PeriodCloseStatus::InProgress;
70        run.started_at = Some(fiscal_period.end_date);
71
72        // Execute tasks in sequence order
73        let mut _current_sequence = 0u32;
74        for scheduled_task in &schedule.tasks {
75            // Skip year-end tasks if not year-end
76            if scheduled_task.task.is_year_end_only() && !fiscal_period.is_year_end {
77                let mut result = CloseTaskResult::new(
78                    scheduled_task.task.clone(),
79                    company_code.to_string(),
80                    fiscal_period.clone(),
81                );
82                result.status = CloseTaskStatus::Skipped("Not year-end period".to_string());
83                run.task_results.push(result);
84                continue;
85            }
86
87            // Check dependencies
88            let deps_met = scheduled_task.depends_on.iter().all(|dep| {
89                run.task_results
90                    .iter()
91                    .any(|r| r.task == *dep && r.is_success())
92            });
93
94            if !deps_met {
95                let mut result = CloseTaskResult::new(
96                    scheduled_task.task.clone(),
97                    company_code.to_string(),
98                    fiscal_period.clone(),
99                );
100                result.status = CloseTaskStatus::Skipped("Dependencies not met".to_string());
101                run.task_results.push(result);
102                continue;
103            }
104
105            // Execute the task
106            let result =
107                self.execute_task(&scheduled_task.task, company_code, &fiscal_period, context);
108
109            run.total_journal_entries += result.journal_entries_created;
110
111            if let CloseTaskStatus::Failed(ref err) = result.status {
112                run.errors
113                    .push(format!("{}: {}", scheduled_task.task.name(), err));
114                if self.config.stop_on_error {
115                    run.task_results.push(result);
116                    run.status = PeriodCloseStatus::Failed;
117                    return run;
118                }
119            }
120
121            run.task_results.push(result);
122            _current_sequence = scheduled_task.sequence;
123        }
124
125        // Determine final status
126        run.completed_at = Some(fiscal_period.end_date);
127        if run.errors.is_empty() {
128            run.status = PeriodCloseStatus::Completed;
129        } else {
130            run.status = PeriodCloseStatus::CompletedWithErrors;
131        }
132
133        run
134    }
135
136    /// Executes a single close task.
137    fn execute_task(
138        &self,
139        task: &CloseTask,
140        company_code: &str,
141        fiscal_period: &FiscalPeriod,
142        context: &mut CloseContext,
143    ) -> CloseTaskResult {
144        let mut result = CloseTaskResult::new(
145            task.clone(),
146            company_code.to_string(),
147            fiscal_period.clone(),
148        );
149        result.status = CloseTaskStatus::InProgress;
150        result.started_at = Some(fiscal_period.end_date);
151
152        // Delegate to appropriate handler
153        match task {
154            CloseTask::RunDepreciation => {
155                if let Some(handler) = &context.depreciation_handler {
156                    let (entries, total) = handler(company_code, fiscal_period);
157                    result.journal_entries_created = entries.len() as u32;
158                    result.total_amount = total;
159                    context.journal_entries.extend(entries);
160                    result.status = CloseTaskStatus::Completed;
161                } else {
162                    result.status = CloseTaskStatus::Skipped("No depreciation handler".to_string());
163                }
164            }
165            CloseTask::PostAccruedExpenses | CloseTask::PostAccruedRevenue => {
166                if let Some(handler) = &context.accrual_handler {
167                    let (entries, total) = handler(company_code, fiscal_period, task);
168                    result.journal_entries_created = entries.len() as u32;
169                    result.total_amount = total;
170                    context.journal_entries.extend(entries);
171                    result.status = CloseTaskStatus::Completed;
172                } else {
173                    result.status = CloseTaskStatus::Skipped("No accrual handler".to_string());
174                }
175            }
176            CloseTask::PostPrepaidAmortization => {
177                if let Some(handler) = &context.prepaid_handler {
178                    let (entries, total) = handler(company_code, fiscal_period);
179                    result.journal_entries_created = entries.len() as u32;
180                    result.total_amount = total;
181                    context.journal_entries.extend(entries);
182                    result.status = CloseTaskStatus::Completed;
183                } else {
184                    result.status = CloseTaskStatus::Skipped("No prepaid handler".to_string());
185                }
186            }
187            CloseTask::ReconcileArToGl
188            | CloseTask::ReconcileApToGl
189            | CloseTask::ReconcileFaToGl
190            | CloseTask::ReconcileInventoryToGl => {
191                if let Some(handler) = &context.reconciliation_handler {
192                    match handler(company_code, fiscal_period, task) {
193                        Ok(diff) => {
194                            if diff.abs() <= self.config.reconciliation_tolerance {
195                                result.status = CloseTaskStatus::Completed;
196                            } else if self.config.require_reconciliation {
197                                result.status = CloseTaskStatus::Failed(format!(
198                                    "Reconciliation difference: {diff}"
199                                ));
200                            } else {
201                                result.status =
202                                    CloseTaskStatus::CompletedWithWarnings(vec![format!(
203                                        "Reconciliation difference: {}",
204                                        diff
205                                    )]);
206                            }
207                            result.total_amount = diff;
208                        }
209                        Err(e) => {
210                            result.status = CloseTaskStatus::Failed(e);
211                        }
212                    }
213                } else {
214                    result.status =
215                        CloseTaskStatus::Skipped("No reconciliation handler".to_string());
216                }
217            }
218            CloseTask::RevalueForeignCurrency => {
219                if let Some(handler) = &context.fx_revaluation_handler {
220                    let (entries, total) = handler(company_code, fiscal_period);
221                    result.journal_entries_created = entries.len() as u32;
222                    result.total_amount = total;
223                    context.journal_entries.extend(entries);
224                    result.status = CloseTaskStatus::Completed;
225                } else {
226                    result.status =
227                        CloseTaskStatus::Skipped("No FX revaluation handler".to_string());
228                }
229            }
230            CloseTask::AllocateCorporateOverhead => {
231                if let Some(handler) = &context.overhead_handler {
232                    let (entries, total) = handler(company_code, fiscal_period);
233                    result.journal_entries_created = entries.len() as u32;
234                    result.total_amount = total;
235                    context.journal_entries.extend(entries);
236                    result.status = CloseTaskStatus::Completed;
237                } else {
238                    result.status = CloseTaskStatus::Skipped("No overhead handler".to_string());
239                }
240            }
241            CloseTask::PostIntercompanySettlements => {
242                if let Some(handler) = &context.ic_settlement_handler {
243                    let (entries, total) = handler(company_code, fiscal_period);
244                    result.journal_entries_created = entries.len() as u32;
245                    result.total_amount = total;
246                    context.journal_entries.extend(entries);
247                    result.status = CloseTaskStatus::Completed;
248                } else {
249                    result.status =
250                        CloseTaskStatus::Skipped("No IC settlement handler".to_string());
251                }
252            }
253            CloseTask::TranslateForeignSubsidiaries => {
254                if let Some(handler) = &context.translation_handler {
255                    let (entries, total) = handler(company_code, fiscal_period);
256                    result.journal_entries_created = entries.len() as u32;
257                    result.total_amount = total;
258                    context.journal_entries.extend(entries);
259                    result.status = CloseTaskStatus::Completed;
260                } else {
261                    result.status = CloseTaskStatus::Skipped("No translation handler".to_string());
262                }
263            }
264            CloseTask::EliminateIntercompany => {
265                if let Some(handler) = &context.elimination_handler {
266                    let (entries, total) = handler(company_code, fiscal_period);
267                    result.journal_entries_created = entries.len() as u32;
268                    result.total_amount = total;
269                    context.journal_entries.extend(entries);
270                    result.status = CloseTaskStatus::Completed;
271                } else {
272                    result.status = CloseTaskStatus::Skipped("No elimination handler".to_string());
273                }
274            }
275            CloseTask::CalculateTaxProvision => {
276                if let Some(handler) = &context.tax_provision_handler {
277                    let (entries, total) = handler(company_code, fiscal_period);
278                    result.journal_entries_created = entries.len() as u32;
279                    result.total_amount = total;
280                    context.journal_entries.extend(entries);
281                    result.status = CloseTaskStatus::Completed;
282                } else {
283                    result.status =
284                        CloseTaskStatus::Skipped("No tax provision handler".to_string());
285                }
286            }
287            CloseTask::CloseIncomeStatement => {
288                if let Some(handler) = &context.income_close_handler {
289                    let (entries, total) = handler(company_code, fiscal_period);
290                    result.journal_entries_created = entries.len() as u32;
291                    result.total_amount = total;
292                    context.journal_entries.extend(entries);
293                    result.status = CloseTaskStatus::Completed;
294                } else {
295                    result.status = CloseTaskStatus::Skipped("No income close handler".to_string());
296                }
297            }
298            CloseTask::PostRetainedEarningsRollforward => {
299                if let Some(handler) = &context.re_rollforward_handler {
300                    let (entries, total) = handler(company_code, fiscal_period);
301                    result.journal_entries_created = entries.len() as u32;
302                    result.total_amount = total;
303                    context.journal_entries.extend(entries);
304                    result.status = CloseTaskStatus::Completed;
305                } else {
306                    result.status =
307                        CloseTaskStatus::Skipped("No RE rollforward handler".to_string());
308                }
309            }
310            CloseTask::GenerateTrialBalance | CloseTask::GenerateFinancialStatements => {
311                // These are reporting tasks, not JE generators
312                result.status = CloseTaskStatus::Completed;
313                result.notes.push("Report generation completed".to_string());
314            }
315            CloseTask::PostInventoryRevaluation => {
316                if let Some(handler) = &context.inventory_reval_handler {
317                    let (entries, total) = handler(company_code, fiscal_period);
318                    result.journal_entries_created = entries.len() as u32;
319                    result.total_amount = total;
320                    context.journal_entries.extend(entries);
321                    result.status = CloseTaskStatus::Completed;
322                } else {
323                    result.status =
324                        CloseTaskStatus::Skipped("No inventory reval handler".to_string());
325                }
326            }
327            CloseTask::Custom(name) => {
328                if let Some(handler) = context.custom_handlers.get(name) {
329                    let (entries, total) = handler(company_code, fiscal_period);
330                    result.journal_entries_created = entries.len() as u32;
331                    result.total_amount = total;
332                    context.journal_entries.extend(entries);
333                    result.status = CloseTaskStatus::Completed;
334                } else {
335                    // Return error status instead of silently skipping
336                    result.status = CloseTaskStatus::Failed(format!(
337                        "Custom close task '{name}' has no registered handler. \
338                         Register a handler via CloseContext.custom_handlers.insert(\"{name}\",...)"
339                    ));
340                }
341            }
342        }
343
344        result.completed_at = Some(fiscal_period.end_date);
345        result
346    }
347
348    /// Validates that a period can be closed.
349    pub fn validate_close_readiness(
350        &self,
351        company_code: &str,
352        fiscal_period: &FiscalPeriod,
353        context: &CloseContext,
354    ) -> CloseReadinessResult {
355        let mut result = CloseReadinessResult {
356            company_code: company_code.to_string(),
357            fiscal_period: fiscal_period.clone(),
358            is_ready: true,
359            blockers: Vec::new(),
360            warnings: Vec::new(),
361        };
362
363        // Check period status
364        if fiscal_period.status == PeriodStatus::Closed {
365            result.is_ready = false;
366            result.blockers.push("Period is already closed".to_string());
367        }
368
369        if fiscal_period.status == PeriodStatus::Locked {
370            result.is_ready = false;
371            result
372                .blockers
373                .push("Period is locked for audit".to_string());
374        }
375
376        // Check for required handlers
377        if context.depreciation_handler.is_none() {
378            result
379                .warnings
380                .push("No depreciation handler configured".to_string());
381        }
382
383        if context.accrual_handler.is_none() {
384            result
385                .warnings
386                .push("No accrual handler configured".to_string());
387        }
388
389        if self.config.require_reconciliation && context.reconciliation_handler.is_none() {
390            result.is_ready = false;
391            result
392                .blockers
393                .push("Reconciliation required but no handler configured".to_string());
394        }
395
396        result
397    }
398}
399
400/// Context for close execution containing handlers and state.
401#[derive(Default)]
402pub struct CloseContext {
403    /// Journal entries generated during close.
404    pub journal_entries: Vec<JournalEntry>,
405    /// Handler for depreciation.
406    pub depreciation_handler:
407        Option<Box<dyn Fn(&str, &FiscalPeriod) -> (Vec<JournalEntry>, Decimal)>>,
408    /// Handler for accruals.
409    pub accrual_handler:
410        Option<Box<dyn Fn(&str, &FiscalPeriod, &CloseTask) -> (Vec<JournalEntry>, Decimal)>>,
411    /// Handler for prepaid amortization.
412    pub prepaid_handler: Option<Box<dyn Fn(&str, &FiscalPeriod) -> (Vec<JournalEntry>, Decimal)>>,
413    /// Handler for reconciliation.
414    pub reconciliation_handler:
415        Option<Box<dyn Fn(&str, &FiscalPeriod, &CloseTask) -> Result<Decimal, String>>>,
416    /// Handler for FX revaluation.
417    pub fx_revaluation_handler:
418        Option<Box<dyn Fn(&str, &FiscalPeriod) -> (Vec<JournalEntry>, Decimal)>>,
419    /// Handler for overhead allocation.
420    pub overhead_handler: Option<Box<dyn Fn(&str, &FiscalPeriod) -> (Vec<JournalEntry>, Decimal)>>,
421    /// Handler for IC settlements.
422    pub ic_settlement_handler:
423        Option<Box<dyn Fn(&str, &FiscalPeriod) -> (Vec<JournalEntry>, Decimal)>>,
424    /// Handler for currency translation.
425    pub translation_handler:
426        Option<Box<dyn Fn(&str, &FiscalPeriod) -> (Vec<JournalEntry>, Decimal)>>,
427    /// Handler for IC elimination.
428    pub elimination_handler:
429        Option<Box<dyn Fn(&str, &FiscalPeriod) -> (Vec<JournalEntry>, Decimal)>>,
430    /// Handler for tax provision.
431    pub tax_provision_handler:
432        Option<Box<dyn Fn(&str, &FiscalPeriod) -> (Vec<JournalEntry>, Decimal)>>,
433    /// Handler for income statement close.
434    pub income_close_handler:
435        Option<Box<dyn Fn(&str, &FiscalPeriod) -> (Vec<JournalEntry>, Decimal)>>,
436    /// Handler for retained earnings rollforward.
437    pub re_rollforward_handler:
438        Option<Box<dyn Fn(&str, &FiscalPeriod) -> (Vec<JournalEntry>, Decimal)>>,
439    /// Handler for inventory revaluation.
440    pub inventory_reval_handler:
441        Option<Box<dyn Fn(&str, &FiscalPeriod) -> (Vec<JournalEntry>, Decimal)>>,
442    /// Handlers for custom close tasks, keyed by task name.
443    pub custom_handlers: std::collections::HashMap<
444        String,
445        Box<dyn Fn(&str, &FiscalPeriod) -> (Vec<JournalEntry>, Decimal)>,
446    >,
447}
448
449/// Result of close readiness validation.
450#[derive(Debug, Clone)]
451pub struct CloseReadinessResult {
452    /// Company code.
453    pub company_code: String,
454    /// Fiscal period.
455    pub fiscal_period: FiscalPeriod,
456    /// Whether the period is ready to close.
457    pub is_ready: bool,
458    /// Blocking issues that prevent close.
459    pub blockers: Vec<String>,
460    /// Non-blocking warnings.
461    pub warnings: Vec<String>,
462}
463
464#[cfg(test)]
465#[allow(clippy::unwrap_used)]
466mod tests {
467    use super::*;
468
469    #[test]
470    fn test_close_engine_creation() {
471        let engine = CloseEngine::new(CloseEngineConfig::default());
472        assert!(!engine.config.stop_on_error);
473    }
474
475    #[test]
476    fn test_close_readiness() {
477        let engine = CloseEngine::new(CloseEngineConfig::default());
478        let period = FiscalPeriod::monthly(2024, 1);
479        let context = CloseContext::default();
480
481        let result = engine.validate_close_readiness("1000", &period, &context);
482        // Without reconciliation handler, should not be ready
483        assert!(!result.is_ready);
484    }
485}