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: {}",
199                                    diff
200                                ));
201                            } else {
202                                result.status =
203                                    CloseTaskStatus::CompletedWithWarnings(vec![format!(
204                                        "Reconciliation difference: {}",
205                                        diff
206                                    )]);
207                            }
208                            result.total_amount = diff;
209                        }
210                        Err(e) => {
211                            result.status = CloseTaskStatus::Failed(e);
212                        }
213                    }
214                } else {
215                    result.status =
216                        CloseTaskStatus::Skipped("No reconciliation handler".to_string());
217                }
218            }
219            CloseTask::RevalueForeignCurrency => {
220                if let Some(handler) = &context.fx_revaluation_handler {
221                    let (entries, total) = handler(company_code, fiscal_period);
222                    result.journal_entries_created = entries.len() as u32;
223                    result.total_amount = total;
224                    context.journal_entries.extend(entries);
225                    result.status = CloseTaskStatus::Completed;
226                } else {
227                    result.status =
228                        CloseTaskStatus::Skipped("No FX revaluation handler".to_string());
229                }
230            }
231            CloseTask::AllocateCorporateOverhead => {
232                if let Some(handler) = &context.overhead_handler {
233                    let (entries, total) = handler(company_code, fiscal_period);
234                    result.journal_entries_created = entries.len() as u32;
235                    result.total_amount = total;
236                    context.journal_entries.extend(entries);
237                    result.status = CloseTaskStatus::Completed;
238                } else {
239                    result.status = CloseTaskStatus::Skipped("No overhead handler".to_string());
240                }
241            }
242            CloseTask::PostIntercompanySettlements => {
243                if let Some(handler) = &context.ic_settlement_handler {
244                    let (entries, total) = handler(company_code, fiscal_period);
245                    result.journal_entries_created = entries.len() as u32;
246                    result.total_amount = total;
247                    context.journal_entries.extend(entries);
248                    result.status = CloseTaskStatus::Completed;
249                } else {
250                    result.status =
251                        CloseTaskStatus::Skipped("No IC settlement handler".to_string());
252                }
253            }
254            CloseTask::TranslateForeignSubsidiaries => {
255                if let Some(handler) = &context.translation_handler {
256                    let (entries, total) = handler(company_code, fiscal_period);
257                    result.journal_entries_created = entries.len() as u32;
258                    result.total_amount = total;
259                    context.journal_entries.extend(entries);
260                    result.status = CloseTaskStatus::Completed;
261                } else {
262                    result.status = CloseTaskStatus::Skipped("No translation handler".to_string());
263                }
264            }
265            CloseTask::EliminateIntercompany => {
266                if let Some(handler) = &context.elimination_handler {
267                    let (entries, total) = handler(company_code, fiscal_period);
268                    result.journal_entries_created = entries.len() as u32;
269                    result.total_amount = total;
270                    context.journal_entries.extend(entries);
271                    result.status = CloseTaskStatus::Completed;
272                } else {
273                    result.status = CloseTaskStatus::Skipped("No elimination handler".to_string());
274                }
275            }
276            CloseTask::CalculateTaxProvision => {
277                if let Some(handler) = &context.tax_provision_handler {
278                    let (entries, total) = handler(company_code, fiscal_period);
279                    result.journal_entries_created = entries.len() as u32;
280                    result.total_amount = total;
281                    context.journal_entries.extend(entries);
282                    result.status = CloseTaskStatus::Completed;
283                } else {
284                    result.status =
285                        CloseTaskStatus::Skipped("No tax provision handler".to_string());
286                }
287            }
288            CloseTask::CloseIncomeStatement => {
289                if let Some(handler) = &context.income_close_handler {
290                    let (entries, total) = handler(company_code, fiscal_period);
291                    result.journal_entries_created = entries.len() as u32;
292                    result.total_amount = total;
293                    context.journal_entries.extend(entries);
294                    result.status = CloseTaskStatus::Completed;
295                } else {
296                    result.status = CloseTaskStatus::Skipped("No income close handler".to_string());
297                }
298            }
299            CloseTask::PostRetainedEarningsRollforward => {
300                if let Some(handler) = &context.re_rollforward_handler {
301                    let (entries, total) = handler(company_code, fiscal_period);
302                    result.journal_entries_created = entries.len() as u32;
303                    result.total_amount = total;
304                    context.journal_entries.extend(entries);
305                    result.status = CloseTaskStatus::Completed;
306                } else {
307                    result.status =
308                        CloseTaskStatus::Skipped("No RE rollforward handler".to_string());
309                }
310            }
311            CloseTask::GenerateTrialBalance | CloseTask::GenerateFinancialStatements => {
312                // These are reporting tasks, not JE generators
313                result.status = CloseTaskStatus::Completed;
314                result.notes.push("Report generation completed".to_string());
315            }
316            CloseTask::PostInventoryRevaluation => {
317                if let Some(handler) = &context.inventory_reval_handler {
318                    let (entries, total) = handler(company_code, fiscal_period);
319                    result.journal_entries_created = entries.len() as u32;
320                    result.total_amount = total;
321                    context.journal_entries.extend(entries);
322                    result.status = CloseTaskStatus::Completed;
323                } else {
324                    result.status =
325                        CloseTaskStatus::Skipped("No inventory reval handler".to_string());
326                }
327            }
328            CloseTask::Custom(name) => {
329                if let Some(handler) = context.custom_handlers.get(name) {
330                    let (entries, total) = handler(company_code, fiscal_period);
331                    result.journal_entries_created = entries.len() as u32;
332                    result.total_amount = total;
333                    context.journal_entries.extend(entries);
334                    result.status = CloseTaskStatus::Completed;
335                } else {
336                    // Return error status instead of silently skipping
337                    result.status = CloseTaskStatus::Failed(format!(
338                        "Custom close task '{}' has no registered handler. \
339                         Register a handler via CloseContext.custom_handlers.insert(\"{}\",...)",
340                        name, name
341                    ));
342                }
343            }
344        }
345
346        result.completed_at = Some(fiscal_period.end_date);
347        result
348    }
349
350    /// Validates that a period can be closed.
351    pub fn validate_close_readiness(
352        &self,
353        company_code: &str,
354        fiscal_period: &FiscalPeriod,
355        context: &CloseContext,
356    ) -> CloseReadinessResult {
357        let mut result = CloseReadinessResult {
358            company_code: company_code.to_string(),
359            fiscal_period: fiscal_period.clone(),
360            is_ready: true,
361            blockers: Vec::new(),
362            warnings: Vec::new(),
363        };
364
365        // Check period status
366        if fiscal_period.status == PeriodStatus::Closed {
367            result.is_ready = false;
368            result.blockers.push("Period is already closed".to_string());
369        }
370
371        if fiscal_period.status == PeriodStatus::Locked {
372            result.is_ready = false;
373            result
374                .blockers
375                .push("Period is locked for audit".to_string());
376        }
377
378        // Check for required handlers
379        if context.depreciation_handler.is_none() {
380            result
381                .warnings
382                .push("No depreciation handler configured".to_string());
383        }
384
385        if context.accrual_handler.is_none() {
386            result
387                .warnings
388                .push("No accrual handler configured".to_string());
389        }
390
391        if self.config.require_reconciliation && context.reconciliation_handler.is_none() {
392            result.is_ready = false;
393            result
394                .blockers
395                .push("Reconciliation required but no handler configured".to_string());
396        }
397
398        result
399    }
400}
401
402/// Context for close execution containing handlers and state.
403#[derive(Default)]
404pub struct CloseContext {
405    /// Journal entries generated during close.
406    pub journal_entries: Vec<JournalEntry>,
407    /// Handler for depreciation.
408    pub depreciation_handler:
409        Option<Box<dyn Fn(&str, &FiscalPeriod) -> (Vec<JournalEntry>, Decimal)>>,
410    /// Handler for accruals.
411    pub accrual_handler:
412        Option<Box<dyn Fn(&str, &FiscalPeriod, &CloseTask) -> (Vec<JournalEntry>, Decimal)>>,
413    /// Handler for prepaid amortization.
414    pub prepaid_handler: Option<Box<dyn Fn(&str, &FiscalPeriod) -> (Vec<JournalEntry>, Decimal)>>,
415    /// Handler for reconciliation.
416    pub reconciliation_handler:
417        Option<Box<dyn Fn(&str, &FiscalPeriod, &CloseTask) -> Result<Decimal, String>>>,
418    /// Handler for FX revaluation.
419    pub fx_revaluation_handler:
420        Option<Box<dyn Fn(&str, &FiscalPeriod) -> (Vec<JournalEntry>, Decimal)>>,
421    /// Handler for overhead allocation.
422    pub overhead_handler: Option<Box<dyn Fn(&str, &FiscalPeriod) -> (Vec<JournalEntry>, Decimal)>>,
423    /// Handler for IC settlements.
424    pub ic_settlement_handler:
425        Option<Box<dyn Fn(&str, &FiscalPeriod) -> (Vec<JournalEntry>, Decimal)>>,
426    /// Handler for currency translation.
427    pub translation_handler:
428        Option<Box<dyn Fn(&str, &FiscalPeriod) -> (Vec<JournalEntry>, Decimal)>>,
429    /// Handler for IC elimination.
430    pub elimination_handler:
431        Option<Box<dyn Fn(&str, &FiscalPeriod) -> (Vec<JournalEntry>, Decimal)>>,
432    /// Handler for tax provision.
433    pub tax_provision_handler:
434        Option<Box<dyn Fn(&str, &FiscalPeriod) -> (Vec<JournalEntry>, Decimal)>>,
435    /// Handler for income statement close.
436    pub income_close_handler:
437        Option<Box<dyn Fn(&str, &FiscalPeriod) -> (Vec<JournalEntry>, Decimal)>>,
438    /// Handler for retained earnings rollforward.
439    pub re_rollforward_handler:
440        Option<Box<dyn Fn(&str, &FiscalPeriod) -> (Vec<JournalEntry>, Decimal)>>,
441    /// Handler for inventory revaluation.
442    pub inventory_reval_handler:
443        Option<Box<dyn Fn(&str, &FiscalPeriod) -> (Vec<JournalEntry>, Decimal)>>,
444    /// Handlers for custom close tasks, keyed by task name.
445    pub custom_handlers: std::collections::HashMap<
446        String,
447        Box<dyn Fn(&str, &FiscalPeriod) -> (Vec<JournalEntry>, Decimal)>,
448    >,
449}
450
451/// Result of close readiness validation.
452#[derive(Debug, Clone)]
453pub struct CloseReadinessResult {
454    /// Company code.
455    pub company_code: String,
456    /// Fiscal period.
457    pub fiscal_period: FiscalPeriod,
458    /// Whether the period is ready to close.
459    pub is_ready: bool,
460    /// Blocking issues that prevent close.
461    pub blockers: Vec<String>,
462    /// Non-blocking warnings.
463    pub warnings: Vec<String>,
464}
465
466#[cfg(test)]
467#[allow(clippy::unwrap_used)]
468mod tests {
469    use super::*;
470
471    #[test]
472    fn test_close_engine_creation() {
473        let engine = CloseEngine::new(CloseEngineConfig::default());
474        assert!(!engine.config.stop_on_error);
475    }
476
477    #[test]
478    fn test_close_readiness() {
479        let engine = CloseEngine::new(CloseEngineConfig::default());
480        let period = FiscalPeriod::monthly(2024, 1);
481        let context = CloseContext::default();
482
483        let result = engine.validate_close_readiness("1000", &period, &context);
484        // Without reconciliation handler, should not be ready
485        assert!(!result.is_ready);
486    }
487}