use rust_decimal::Decimal;
use tracing::debug;
use datasynth_core::models::{
CloseSchedule, CloseTask, CloseTaskResult, CloseTaskStatus, FiscalPeriod, JournalEntry,
PeriodCloseRun, PeriodCloseStatus, PeriodStatus,
};
#[derive(Debug, Clone)]
pub struct CloseEngineConfig {
pub stop_on_error: bool,
pub auto_reverse_accruals: bool,
pub require_reconciliation: bool,
pub reconciliation_tolerance: Decimal,
}
impl Default for CloseEngineConfig {
fn default() -> Self {
Self {
stop_on_error: false,
auto_reverse_accruals: true,
require_reconciliation: true,
reconciliation_tolerance: Decimal::new(1, 2), }
}
}
pub struct CloseEngine {
config: CloseEngineConfig,
run_counter: u64,
}
impl CloseEngine {
pub fn new(config: CloseEngineConfig) -> Self {
Self {
config,
run_counter: 0,
}
}
pub fn execute_close(
&mut self,
company_code: &str,
fiscal_period: FiscalPeriod,
schedule: &CloseSchedule,
context: &mut CloseContext,
) -> PeriodCloseRun {
debug!(
company_code,
period = fiscal_period.period,
year = fiscal_period.year,
task_count = schedule.tasks.len(),
"Executing period close"
);
self.run_counter += 1;
let run_id = format!("CLOSE-{:08}", self.run_counter);
let mut run = PeriodCloseRun::new(run_id, company_code.to_string(), fiscal_period.clone());
run.status = PeriodCloseStatus::InProgress;
run.started_at = Some(fiscal_period.end_date);
let mut _current_sequence = 0u32;
for scheduled_task in &schedule.tasks {
if scheduled_task.task.is_year_end_only() && !fiscal_period.is_year_end {
let mut result = CloseTaskResult::new(
scheduled_task.task.clone(),
company_code.to_string(),
fiscal_period.clone(),
);
result.status = CloseTaskStatus::Skipped("Not year-end period".to_string());
run.task_results.push(result);
continue;
}
let deps_met = scheduled_task.depends_on.iter().all(|dep| {
run.task_results
.iter()
.any(|r| r.task == *dep && r.is_success())
});
if !deps_met {
let mut result = CloseTaskResult::new(
scheduled_task.task.clone(),
company_code.to_string(),
fiscal_period.clone(),
);
result.status = CloseTaskStatus::Skipped("Dependencies not met".to_string());
run.task_results.push(result);
continue;
}
let result =
self.execute_task(&scheduled_task.task, company_code, &fiscal_period, context);
run.total_journal_entries += result.journal_entries_created;
if let CloseTaskStatus::Failed(ref err) = result.status {
run.errors
.push(format!("{}: {}", scheduled_task.task.name(), err));
if self.config.stop_on_error {
run.task_results.push(result);
run.status = PeriodCloseStatus::Failed;
return run;
}
}
run.task_results.push(result);
_current_sequence = scheduled_task.sequence;
}
run.completed_at = Some(fiscal_period.end_date);
if run.errors.is_empty() {
run.status = PeriodCloseStatus::Completed;
} else {
run.status = PeriodCloseStatus::CompletedWithErrors;
}
run
}
fn execute_task(
&self,
task: &CloseTask,
company_code: &str,
fiscal_period: &FiscalPeriod,
context: &mut CloseContext,
) -> CloseTaskResult {
let mut result = CloseTaskResult::new(
task.clone(),
company_code.to_string(),
fiscal_period.clone(),
);
result.status = CloseTaskStatus::InProgress;
result.started_at = Some(fiscal_period.end_date);
match task {
CloseTask::RunDepreciation => {
if let Some(handler) = &context.depreciation_handler {
let (entries, total) = handler(company_code, fiscal_period);
result.journal_entries_created = entries.len() as u32;
result.total_amount = total;
context.journal_entries.extend(entries);
result.status = CloseTaskStatus::Completed;
} else {
result.status = CloseTaskStatus::Skipped("No depreciation handler".to_string());
}
}
CloseTask::PostAccruedExpenses | CloseTask::PostAccruedRevenue => {
if let Some(handler) = &context.accrual_handler {
let (entries, total) = handler(company_code, fiscal_period, task);
result.journal_entries_created = entries.len() as u32;
result.total_amount = total;
context.journal_entries.extend(entries);
result.status = CloseTaskStatus::Completed;
} else {
result.status = CloseTaskStatus::Skipped("No accrual handler".to_string());
}
}
CloseTask::PostPrepaidAmortization => {
if let Some(handler) = &context.prepaid_handler {
let (entries, total) = handler(company_code, fiscal_period);
result.journal_entries_created = entries.len() as u32;
result.total_amount = total;
context.journal_entries.extend(entries);
result.status = CloseTaskStatus::Completed;
} else {
result.status = CloseTaskStatus::Skipped("No prepaid handler".to_string());
}
}
CloseTask::ReconcileArToGl
| CloseTask::ReconcileApToGl
| CloseTask::ReconcileFaToGl
| CloseTask::ReconcileInventoryToGl => {
if let Some(handler) = &context.reconciliation_handler {
match handler(company_code, fiscal_period, task) {
Ok(diff) => {
if diff.abs() <= self.config.reconciliation_tolerance {
result.status = CloseTaskStatus::Completed;
} else if self.config.require_reconciliation {
result.status = CloseTaskStatus::Failed(format!(
"Reconciliation difference: {diff}"
));
} else {
result.status =
CloseTaskStatus::CompletedWithWarnings(vec![format!(
"Reconciliation difference: {}",
diff
)]);
}
result.total_amount = diff;
}
Err(e) => {
result.status = CloseTaskStatus::Failed(e);
}
}
} else {
result.status =
CloseTaskStatus::Skipped("No reconciliation handler".to_string());
}
}
CloseTask::RevalueForeignCurrency => {
if let Some(handler) = &context.fx_revaluation_handler {
let (entries, total) = handler(company_code, fiscal_period);
result.journal_entries_created = entries.len() as u32;
result.total_amount = total;
context.journal_entries.extend(entries);
result.status = CloseTaskStatus::Completed;
} else {
result.status =
CloseTaskStatus::Skipped("No FX revaluation handler".to_string());
}
}
CloseTask::AllocateCorporateOverhead => {
if let Some(handler) = &context.overhead_handler {
let (entries, total) = handler(company_code, fiscal_period);
result.journal_entries_created = entries.len() as u32;
result.total_amount = total;
context.journal_entries.extend(entries);
result.status = CloseTaskStatus::Completed;
} else {
result.status = CloseTaskStatus::Skipped("No overhead handler".to_string());
}
}
CloseTask::PostIntercompanySettlements => {
if let Some(handler) = &context.ic_settlement_handler {
let (entries, total) = handler(company_code, fiscal_period);
result.journal_entries_created = entries.len() as u32;
result.total_amount = total;
context.journal_entries.extend(entries);
result.status = CloseTaskStatus::Completed;
} else {
result.status =
CloseTaskStatus::Skipped("No IC settlement handler".to_string());
}
}
CloseTask::TranslateForeignSubsidiaries => {
if let Some(handler) = &context.translation_handler {
let (entries, total) = handler(company_code, fiscal_period);
result.journal_entries_created = entries.len() as u32;
result.total_amount = total;
context.journal_entries.extend(entries);
result.status = CloseTaskStatus::Completed;
} else {
result.status = CloseTaskStatus::Skipped("No translation handler".to_string());
}
}
CloseTask::EliminateIntercompany => {
if let Some(handler) = &context.elimination_handler {
let (entries, total) = handler(company_code, fiscal_period);
result.journal_entries_created = entries.len() as u32;
result.total_amount = total;
context.journal_entries.extend(entries);
result.status = CloseTaskStatus::Completed;
} else {
result.status = CloseTaskStatus::Skipped("No elimination handler".to_string());
}
}
CloseTask::CalculateTaxProvision => {
if let Some(handler) = &context.tax_provision_handler {
let (entries, total) = handler(company_code, fiscal_period);
result.journal_entries_created = entries.len() as u32;
result.total_amount = total;
context.journal_entries.extend(entries);
result.status = CloseTaskStatus::Completed;
} else {
result.status =
CloseTaskStatus::Skipped("No tax provision handler".to_string());
}
}
CloseTask::CloseIncomeStatement => {
if let Some(handler) = &context.income_close_handler {
let (entries, total) = handler(company_code, fiscal_period);
result.journal_entries_created = entries.len() as u32;
result.total_amount = total;
context.journal_entries.extend(entries);
result.status = CloseTaskStatus::Completed;
} else {
result.status = CloseTaskStatus::Skipped("No income close handler".to_string());
}
}
CloseTask::PostRetainedEarningsRollforward => {
if let Some(handler) = &context.re_rollforward_handler {
let (entries, total) = handler(company_code, fiscal_period);
result.journal_entries_created = entries.len() as u32;
result.total_amount = total;
context.journal_entries.extend(entries);
result.status = CloseTaskStatus::Completed;
} else {
result.status =
CloseTaskStatus::Skipped("No RE rollforward handler".to_string());
}
}
CloseTask::GenerateTrialBalance | CloseTask::GenerateFinancialStatements => {
result.status = CloseTaskStatus::Completed;
result.notes.push("Report generation completed".to_string());
}
CloseTask::PostInventoryRevaluation => {
if let Some(handler) = &context.inventory_reval_handler {
let (entries, total) = handler(company_code, fiscal_period);
result.journal_entries_created = entries.len() as u32;
result.total_amount = total;
context.journal_entries.extend(entries);
result.status = CloseTaskStatus::Completed;
} else {
result.status =
CloseTaskStatus::Skipped("No inventory reval handler".to_string());
}
}
CloseTask::Custom(name) => {
if let Some(handler) = context.custom_handlers.get(name) {
let (entries, total) = handler(company_code, fiscal_period);
result.journal_entries_created = entries.len() as u32;
result.total_amount = total;
context.journal_entries.extend(entries);
result.status = CloseTaskStatus::Completed;
} else {
result.status = CloseTaskStatus::Failed(format!(
"Custom close task '{name}' has no registered handler. \
Register a handler via CloseContext.custom_handlers.insert(\"{name}\",...)"
));
}
}
}
result.completed_at = Some(fiscal_period.end_date);
result
}
pub fn validate_close_readiness(
&self,
company_code: &str,
fiscal_period: &FiscalPeriod,
context: &CloseContext,
) -> CloseReadinessResult {
let mut result = CloseReadinessResult {
company_code: company_code.to_string(),
fiscal_period: fiscal_period.clone(),
is_ready: true,
blockers: Vec::new(),
warnings: Vec::new(),
};
if fiscal_period.status == PeriodStatus::Closed {
result.is_ready = false;
result.blockers.push("Period is already closed".to_string());
}
if fiscal_period.status == PeriodStatus::Locked {
result.is_ready = false;
result
.blockers
.push("Period is locked for audit".to_string());
}
if context.depreciation_handler.is_none() {
result
.warnings
.push("No depreciation handler configured".to_string());
}
if context.accrual_handler.is_none() {
result
.warnings
.push("No accrual handler configured".to_string());
}
if self.config.require_reconciliation && context.reconciliation_handler.is_none() {
result.is_ready = false;
result
.blockers
.push("Reconciliation required but no handler configured".to_string());
}
result
}
}
#[derive(Default)]
pub struct CloseContext {
pub journal_entries: Vec<JournalEntry>,
pub depreciation_handler:
Option<Box<dyn Fn(&str, &FiscalPeriod) -> (Vec<JournalEntry>, Decimal)>>,
pub accrual_handler:
Option<Box<dyn Fn(&str, &FiscalPeriod, &CloseTask) -> (Vec<JournalEntry>, Decimal)>>,
pub prepaid_handler: Option<Box<dyn Fn(&str, &FiscalPeriod) -> (Vec<JournalEntry>, Decimal)>>,
pub reconciliation_handler:
Option<Box<dyn Fn(&str, &FiscalPeriod, &CloseTask) -> Result<Decimal, String>>>,
pub fx_revaluation_handler:
Option<Box<dyn Fn(&str, &FiscalPeriod) -> (Vec<JournalEntry>, Decimal)>>,
pub overhead_handler: Option<Box<dyn Fn(&str, &FiscalPeriod) -> (Vec<JournalEntry>, Decimal)>>,
pub ic_settlement_handler:
Option<Box<dyn Fn(&str, &FiscalPeriod) -> (Vec<JournalEntry>, Decimal)>>,
pub translation_handler:
Option<Box<dyn Fn(&str, &FiscalPeriod) -> (Vec<JournalEntry>, Decimal)>>,
pub elimination_handler:
Option<Box<dyn Fn(&str, &FiscalPeriod) -> (Vec<JournalEntry>, Decimal)>>,
pub tax_provision_handler:
Option<Box<dyn Fn(&str, &FiscalPeriod) -> (Vec<JournalEntry>, Decimal)>>,
pub income_close_handler:
Option<Box<dyn Fn(&str, &FiscalPeriod) -> (Vec<JournalEntry>, Decimal)>>,
pub re_rollforward_handler:
Option<Box<dyn Fn(&str, &FiscalPeriod) -> (Vec<JournalEntry>, Decimal)>>,
pub inventory_reval_handler:
Option<Box<dyn Fn(&str, &FiscalPeriod) -> (Vec<JournalEntry>, Decimal)>>,
pub custom_handlers: std::collections::HashMap<
String,
Box<dyn Fn(&str, &FiscalPeriod) -> (Vec<JournalEntry>, Decimal)>,
>,
}
#[derive(Debug, Clone)]
pub struct CloseReadinessResult {
pub company_code: String,
pub fiscal_period: FiscalPeriod,
pub is_ready: bool,
pub blockers: Vec<String>,
pub warnings: Vec<String>,
}
#[cfg(test)]
#[allow(clippy::unwrap_used)]
mod tests {
use super::*;
#[test]
fn test_close_engine_creation() {
let engine = CloseEngine::new(CloseEngineConfig::default());
assert!(!engine.config.stop_on_error);
}
#[test]
fn test_close_readiness() {
let engine = CloseEngine::new(CloseEngineConfig::default());
let period = FiscalPeriod::monthly(2024, 1);
let context = CloseContext::default();
let result = engine.validate_close_readiness("1000", &period, &context);
assert!(!result.is_ready);
}
}