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