datasynth_generators/period_close/
close_engine.rs1use rust_decimal::Decimal;
4
5use datasynth_core::models::{
6 CloseSchedule, CloseTask, CloseTaskResult, CloseTaskStatus, FiscalPeriod, JournalEntry,
7 PeriodCloseRun, PeriodCloseStatus, PeriodStatus,
8};
9
10#[derive(Debug, Clone)]
12pub struct CloseEngineConfig {
13 pub stop_on_error: bool,
15 pub auto_reverse_accruals: bool,
17 pub require_reconciliation: bool,
19 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), }
31 }
32}
33
34pub struct CloseEngine {
36 config: CloseEngineConfig,
37 run_counter: u64,
38}
39
40impl CloseEngine {
41 pub fn new(config: CloseEngineConfig) -> Self {
43 Self {
44 config,
45 run_counter: 0,
46 }
47 }
48
49 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 let mut _current_sequence = 0u32;
66 for scheduled_task in &schedule.tasks {
67 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 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 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 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 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 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 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 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 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 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 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#[derive(Default)]
396pub struct CloseContext {
397 pub journal_entries: Vec<JournalEntry>,
399 pub depreciation_handler:
401 Option<Box<dyn Fn(&str, &FiscalPeriod) -> (Vec<JournalEntry>, Decimal)>>,
402 pub accrual_handler:
404 Option<Box<dyn Fn(&str, &FiscalPeriod, &CloseTask) -> (Vec<JournalEntry>, Decimal)>>,
405 pub prepaid_handler: Option<Box<dyn Fn(&str, &FiscalPeriod) -> (Vec<JournalEntry>, Decimal)>>,
407 pub reconciliation_handler:
409 Option<Box<dyn Fn(&str, &FiscalPeriod, &CloseTask) -> Result<Decimal, String>>>,
410 pub fx_revaluation_handler:
412 Option<Box<dyn Fn(&str, &FiscalPeriod) -> (Vec<JournalEntry>, Decimal)>>,
413 pub overhead_handler: Option<Box<dyn Fn(&str, &FiscalPeriod) -> (Vec<JournalEntry>, Decimal)>>,
415 pub ic_settlement_handler:
417 Option<Box<dyn Fn(&str, &FiscalPeriod) -> (Vec<JournalEntry>, Decimal)>>,
418 pub translation_handler:
420 Option<Box<dyn Fn(&str, &FiscalPeriod) -> (Vec<JournalEntry>, Decimal)>>,
421 pub elimination_handler:
423 Option<Box<dyn Fn(&str, &FiscalPeriod) -> (Vec<JournalEntry>, Decimal)>>,
424 pub tax_provision_handler:
426 Option<Box<dyn Fn(&str, &FiscalPeriod) -> (Vec<JournalEntry>, Decimal)>>,
427 pub income_close_handler:
429 Option<Box<dyn Fn(&str, &FiscalPeriod) -> (Vec<JournalEntry>, Decimal)>>,
430 pub re_rollforward_handler:
432 Option<Box<dyn Fn(&str, &FiscalPeriod) -> (Vec<JournalEntry>, Decimal)>>,
433 pub inventory_reval_handler:
435 Option<Box<dyn Fn(&str, &FiscalPeriod) -> (Vec<JournalEntry>, Decimal)>>,
436 pub custom_handlers: std::collections::HashMap<
438 String,
439 Box<dyn Fn(&str, &FiscalPeriod) -> (Vec<JournalEntry>, Decimal)>,
440 >,
441}
442
443#[derive(Debug, Clone)]
445pub struct CloseReadinessResult {
446 pub company_code: String,
448 pub fiscal_period: FiscalPeriod,
450 pub is_ready: bool,
452 pub blockers: Vec<String>,
454 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 assert!(!result.is_ready);
477 }
478}