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: {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 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 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 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 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 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#[derive(Default)]
402pub struct CloseContext {
403 pub journal_entries: Vec<JournalEntry>,
405 pub depreciation_handler:
407 Option<Box<dyn Fn(&str, &FiscalPeriod) -> (Vec<JournalEntry>, Decimal)>>,
408 pub accrual_handler:
410 Option<Box<dyn Fn(&str, &FiscalPeriod, &CloseTask) -> (Vec<JournalEntry>, Decimal)>>,
411 pub prepaid_handler: Option<Box<dyn Fn(&str, &FiscalPeriod) -> (Vec<JournalEntry>, Decimal)>>,
413 pub reconciliation_handler:
415 Option<Box<dyn Fn(&str, &FiscalPeriod, &CloseTask) -> Result<Decimal, String>>>,
416 pub fx_revaluation_handler:
418 Option<Box<dyn Fn(&str, &FiscalPeriod) -> (Vec<JournalEntry>, Decimal)>>,
419 pub overhead_handler: Option<Box<dyn Fn(&str, &FiscalPeriod) -> (Vec<JournalEntry>, Decimal)>>,
421 pub ic_settlement_handler:
423 Option<Box<dyn Fn(&str, &FiscalPeriod) -> (Vec<JournalEntry>, Decimal)>>,
424 pub translation_handler:
426 Option<Box<dyn Fn(&str, &FiscalPeriod) -> (Vec<JournalEntry>, Decimal)>>,
427 pub elimination_handler:
429 Option<Box<dyn Fn(&str, &FiscalPeriod) -> (Vec<JournalEntry>, Decimal)>>,
430 pub tax_provision_handler:
432 Option<Box<dyn Fn(&str, &FiscalPeriod) -> (Vec<JournalEntry>, Decimal)>>,
433 pub income_close_handler:
435 Option<Box<dyn Fn(&str, &FiscalPeriod) -> (Vec<JournalEntry>, Decimal)>>,
436 pub re_rollforward_handler:
438 Option<Box<dyn Fn(&str, &FiscalPeriod) -> (Vec<JournalEntry>, Decimal)>>,
439 pub inventory_reval_handler:
441 Option<Box<dyn Fn(&str, &FiscalPeriod) -> (Vec<JournalEntry>, Decimal)>>,
442 pub custom_handlers: std::collections::HashMap<
444 String,
445 Box<dyn Fn(&str, &FiscalPeriod) -> (Vec<JournalEntry>, Decimal)>,
446 >,
447}
448
449#[derive(Debug, Clone)]
451pub struct CloseReadinessResult {
452 pub company_code: String,
454 pub fiscal_period: FiscalPeriod,
456 pub is_ready: bool,
458 pub blockers: Vec<String>,
460 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 assert!(!result.is_ready);
484 }
485}