datasynth_generators/period_close/
close_engine.rs1use 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#[derive(Debug, Clone)]
13pub struct CloseEngineConfig {
14 pub stop_on_error: bool,
16 pub auto_reverse_accruals: bool,
18 pub require_reconciliation: bool,
20 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), }
32 }
33}
34
35pub struct CloseEngine {
37 config: CloseEngineConfig,
38 run_counter: u64,
39}
40
41impl CloseEngine {
42 pub fn new(config: CloseEngineConfig) -> Self {
44 Self {
45 config,
46 run_counter: 0,
47 }
48 }
49
50 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 let mut _current_sequence = 0u32;
74 for scheduled_task in &schedule.tasks {
75 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 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 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 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 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 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 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 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 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 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 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#[derive(Default)]
404pub struct CloseContext {
405 pub journal_entries: Vec<JournalEntry>,
407 pub depreciation_handler:
409 Option<Box<dyn Fn(&str, &FiscalPeriod) -> (Vec<JournalEntry>, Decimal)>>,
410 pub accrual_handler:
412 Option<Box<dyn Fn(&str, &FiscalPeriod, &CloseTask) -> (Vec<JournalEntry>, Decimal)>>,
413 pub prepaid_handler: Option<Box<dyn Fn(&str, &FiscalPeriod) -> (Vec<JournalEntry>, Decimal)>>,
415 pub reconciliation_handler:
417 Option<Box<dyn Fn(&str, &FiscalPeriod, &CloseTask) -> Result<Decimal, String>>>,
418 pub fx_revaluation_handler:
420 Option<Box<dyn Fn(&str, &FiscalPeriod) -> (Vec<JournalEntry>, Decimal)>>,
421 pub overhead_handler: Option<Box<dyn Fn(&str, &FiscalPeriod) -> (Vec<JournalEntry>, Decimal)>>,
423 pub ic_settlement_handler:
425 Option<Box<dyn Fn(&str, &FiscalPeriod) -> (Vec<JournalEntry>, Decimal)>>,
426 pub translation_handler:
428 Option<Box<dyn Fn(&str, &FiscalPeriod) -> (Vec<JournalEntry>, Decimal)>>,
429 pub elimination_handler:
431 Option<Box<dyn Fn(&str, &FiscalPeriod) -> (Vec<JournalEntry>, Decimal)>>,
432 pub tax_provision_handler:
434 Option<Box<dyn Fn(&str, &FiscalPeriod) -> (Vec<JournalEntry>, Decimal)>>,
435 pub income_close_handler:
437 Option<Box<dyn Fn(&str, &FiscalPeriod) -> (Vec<JournalEntry>, Decimal)>>,
438 pub re_rollforward_handler:
440 Option<Box<dyn Fn(&str, &FiscalPeriod) -> (Vec<JournalEntry>, Decimal)>>,
441 pub inventory_reval_handler:
443 Option<Box<dyn Fn(&str, &FiscalPeriod) -> (Vec<JournalEntry>, Decimal)>>,
444 pub custom_handlers: std::collections::HashMap<
446 String,
447 Box<dyn Fn(&str, &FiscalPeriod) -> (Vec<JournalEntry>, Decimal)>,
448 >,
449}
450
451#[derive(Debug, Clone)]
453pub struct CloseReadinessResult {
454 pub company_code: String,
456 pub fiscal_period: FiscalPeriod,
458 pub is_ready: bool,
460 pub blockers: Vec<String>,
462 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 assert!(!result.is_ready);
486 }
487}