use chrono::NaiveDate;
use datasynth_core::utils::seeded_rng;
use rand::prelude::*;
use rand_chacha::ChaCha8Rng;
use rust_decimal::Decimal;
use tracing::debug;
use datasynth_core::models::{
BusinessProcess, ChartOfAccounts, ControlMappingRegistry, ControlStatus, InternalControl,
JournalEntry, RiskLevel, SodConflictPair, SodConflictType, SodViolation,
};
#[derive(Debug, Clone)]
pub struct ControlGeneratorConfig {
pub exception_rate: f64,
pub sod_violation_rate: f64,
pub enable_sox_marking: bool,
pub sox_materiality_threshold: Decimal,
pub assessed_date: NaiveDate,
}
impl Default for ControlGeneratorConfig {
fn default() -> Self {
Self {
exception_rate: 0.02, sod_violation_rate: 0.01, enable_sox_marking: true,
sox_materiality_threshold: Decimal::from(10000),
assessed_date: NaiveDate::from_ymd_opt(2025, 1, 15).expect("valid date"),
}
}
}
pub struct ControlGenerator {
rng: ChaCha8Rng,
seed: u64,
config: ControlGeneratorConfig,
registry: ControlMappingRegistry,
controls: Vec<InternalControl>,
sod_checker: SodChecker,
}
impl ControlGenerator {
pub fn new(seed: u64) -> Self {
Self::with_config(seed, ControlGeneratorConfig::default())
}
pub fn with_config(seed: u64, config: ControlGeneratorConfig) -> Self {
let mut controls = InternalControl::standard_controls();
for ctrl in &mut controls {
ctrl.derive_from_maturity(config.assessed_date);
ctrl.derive_account_classes();
}
Self {
rng: seeded_rng(seed, 0),
seed,
config: config.clone(),
registry: ControlMappingRegistry::standard(),
controls,
sod_checker: SodChecker::new(seed + 1, config.sod_violation_rate),
}
}
pub fn apply_controls(&mut self, entry: &mut JournalEntry, coa: &ChartOfAccounts) {
debug!(
document_id = %entry.header.document_id,
company_code = %entry.header.company_code,
exception_rate = self.config.exception_rate,
"Applying controls to journal entry"
);
let mut all_control_ids = Vec::new();
for line in &entry.lines {
let amount = if line.debit_amount > Decimal::ZERO {
line.debit_amount
} else {
line.credit_amount
};
let account_sub_type = coa.get_account(&line.gl_account).map(|acc| acc.sub_type);
let control_ids = self.registry.get_applicable_controls(
&line.gl_account,
account_sub_type.as_ref(),
entry.header.business_process.as_ref(),
amount,
Some(&entry.header.document_type),
);
all_control_ids.extend(control_ids);
}
all_control_ids.sort();
all_control_ids.dedup();
entry.header.control_ids = all_control_ids;
entry.header.sox_relevant = self.determine_sox_relevance(entry);
entry.header.control_status = self.determine_control_status(entry);
let (sod_violation, sod_conflict_type) = self.sod_checker.check_entry(entry);
entry.header.sod_violation = sod_violation;
entry.header.sod_conflict_type = sod_conflict_type;
}
fn determine_sox_relevance(&self, entry: &JournalEntry) -> bool {
if !self.config.enable_sox_marking {
return false;
}
let total_amount = entry.total_debit();
if total_amount >= self.config.sox_materiality_threshold {
return true;
}
let has_key_control = entry.header.control_ids.iter().any(|cid| {
self.controls
.iter()
.any(|c| c.control_id == *cid && c.is_key_control)
});
if has_key_control {
return true;
}
if let Some(bp) = &entry.header.business_process {
matches!(
bp,
BusinessProcess::R2R | BusinessProcess::P2P | BusinessProcess::O2C
)
} else {
false
}
}
fn determine_control_status(&mut self, entry: &JournalEntry) -> ControlStatus {
if entry.header.control_ids.is_empty() {
return ControlStatus::NotTested;
}
if self.rng.random::<f64>() < self.config.exception_rate {
ControlStatus::Exception
} else {
ControlStatus::Effective
}
}
pub fn controls(&self) -> &[InternalControl] {
&self.controls
}
pub fn registry(&self) -> &ControlMappingRegistry {
&self.registry
}
pub fn reset(&mut self) {
self.rng = seeded_rng(self.seed, 0);
self.sod_checker.reset();
}
}
pub struct SodChecker {
rng: ChaCha8Rng,
seed: u64,
violation_rate: f64,
conflict_pairs: Vec<SodConflictPair>,
}
impl SodChecker {
pub fn new(seed: u64, violation_rate: f64) -> Self {
Self {
rng: seeded_rng(seed, 0),
seed,
violation_rate,
conflict_pairs: SodConflictPair::standard_conflicts(),
}
}
pub fn check_entry(&mut self, entry: &JournalEntry) -> (bool, Option<SodConflictType>) {
if self.rng.random::<f64>() >= self.violation_rate {
return (false, None);
}
let conflict_type = self.select_conflict_type(entry);
(true, Some(conflict_type))
}
fn select_conflict_type(&mut self, entry: &JournalEntry) -> SodConflictType {
let likely_conflicts: Vec<SodConflictType> = match entry.header.business_process {
Some(BusinessProcess::P2P) => vec![
SodConflictType::PaymentReleaser,
SodConflictType::MasterDataMaintainer,
SodConflictType::PreparerApprover,
],
Some(BusinessProcess::O2C) => vec![
SodConflictType::PreparerApprover,
SodConflictType::RequesterApprover,
],
Some(BusinessProcess::R2R) => vec![
SodConflictType::PreparerApprover,
SodConflictType::ReconcilerPoster,
SodConflictType::JournalEntryPoster,
],
Some(BusinessProcess::H2R) => vec![
SodConflictType::RequesterApprover,
SodConflictType::PreparerApprover,
],
Some(BusinessProcess::A2R) => vec![SodConflictType::PreparerApprover],
Some(BusinessProcess::Intercompany) => vec![
SodConflictType::PreparerApprover,
SodConflictType::ReconcilerPoster,
],
Some(BusinessProcess::S2C) => vec![
SodConflictType::RequesterApprover,
SodConflictType::MasterDataMaintainer,
],
Some(BusinessProcess::Mfg) => vec![
SodConflictType::PreparerApprover,
SodConflictType::RequesterApprover,
],
Some(BusinessProcess::Bank) => vec![
SodConflictType::PaymentReleaser,
SodConflictType::PreparerApprover,
],
Some(BusinessProcess::Audit) => vec![SodConflictType::PreparerApprover],
Some(BusinessProcess::Treasury) | Some(BusinessProcess::Tax) => vec![
SodConflictType::PreparerApprover,
SodConflictType::PaymentReleaser,
],
Some(BusinessProcess::ProjectAccounting) => vec![
SodConflictType::PreparerApprover,
SodConflictType::RequesterApprover,
],
Some(BusinessProcess::Esg) => vec![SodConflictType::PreparerApprover],
None => vec![
SodConflictType::PreparerApprover,
SodConflictType::SystemAccessConflict,
],
};
likely_conflicts
.choose(&mut self.rng)
.copied()
.unwrap_or(SodConflictType::PreparerApprover)
}
pub fn create_violation_record(
&self,
entry: &JournalEntry,
conflict_type: SodConflictType,
) -> SodViolation {
let description = match conflict_type {
SodConflictType::PreparerApprover => {
format!(
"User {} both prepared and approved journal entry {}",
entry.header.created_by, entry.header.document_id
)
}
SodConflictType::RequesterApprover => {
format!(
"User {} approved their own request in transaction {}",
entry.header.created_by, entry.header.document_id
)
}
SodConflictType::ReconcilerPoster => {
format!(
"User {} both reconciled and posted adjustments in {}",
entry.header.created_by, entry.header.document_id
)
}
SodConflictType::MasterDataMaintainer => {
format!(
"User {} maintains master data and processed payment {}",
entry.header.created_by, entry.header.document_id
)
}
SodConflictType::PaymentReleaser => {
format!(
"User {} both created and released payment {}",
entry.header.created_by, entry.header.document_id
)
}
SodConflictType::JournalEntryPoster => {
format!(
"User {} posted to sensitive accounts without review in {}",
entry.header.created_by, entry.header.document_id
)
}
SodConflictType::SystemAccessConflict => {
format!(
"User {} has conflicting system access roles for {}",
entry.header.created_by, entry.header.document_id
)
}
};
let severity = self.determine_violation_severity(entry, conflict_type);
SodViolation::with_timestamp(
conflict_type,
&entry.header.created_by,
description,
severity,
entry.header.created_at,
)
}
fn determine_violation_severity(
&self,
entry: &JournalEntry,
conflict_type: SodConflictType,
) -> RiskLevel {
let amount = entry.total_debit();
let base_severity = match conflict_type {
SodConflictType::PaymentReleaser | SodConflictType::RequesterApprover => {
RiskLevel::Critical
}
SodConflictType::PreparerApprover | SodConflictType::MasterDataMaintainer => {
RiskLevel::High
}
SodConflictType::ReconcilerPoster | SodConflictType::JournalEntryPoster => {
RiskLevel::Medium
}
SodConflictType::SystemAccessConflict => RiskLevel::Low,
};
if amount >= Decimal::from(100000) {
match base_severity {
RiskLevel::Low => RiskLevel::Medium,
RiskLevel::Medium => RiskLevel::High,
RiskLevel::High | RiskLevel::Critical => RiskLevel::Critical,
}
} else {
base_severity
}
}
pub fn conflict_pairs(&self) -> &[SodConflictPair] {
&self.conflict_pairs
}
pub fn reset(&mut self) {
self.rng = seeded_rng(self.seed, 0);
}
}
pub trait ControlApplicationExt {
fn apply_controls(&mut self, generator: &mut ControlGenerator, coa: &ChartOfAccounts);
}
impl ControlApplicationExt for JournalEntry {
fn apply_controls(&mut self, generator: &mut ControlGenerator, coa: &ChartOfAccounts) {
generator.apply_controls(self, coa);
}
}
#[cfg(test)]
#[allow(clippy::unwrap_used)]
mod tests {
use super::*;
use chrono::NaiveDate;
use datasynth_core::models::{JournalEntryHeader, JournalEntryLine};
use uuid::Uuid;
fn create_test_entry() -> JournalEntry {
let mut header = JournalEntryHeader::new(
"1000".to_string(),
NaiveDate::from_ymd_opt(2024, 6, 15).unwrap(),
);
header.business_process = Some(BusinessProcess::R2R);
header.created_by = "USER001".to_string();
let mut entry = JournalEntry::new(header);
entry.add_line(JournalEntryLine::debit(
Uuid::new_v4(),
1,
"100000".to_string(),
Decimal::from(50000),
));
entry.add_line(JournalEntryLine::credit(
Uuid::new_v4(),
2,
"200000".to_string(),
Decimal::from(50000),
));
entry
}
fn create_test_coa() -> ChartOfAccounts {
ChartOfAccounts::new(
"TEST".to_string(),
"Test CoA".to_string(),
"US".to_string(),
datasynth_core::IndustrySector::Manufacturing,
datasynth_core::CoAComplexity::Small,
)
}
#[test]
fn test_control_generator_creation() {
let gen = ControlGenerator::new(42);
assert!(!gen.controls().is_empty());
}
#[test]
fn test_controls_enriched_with_test_history() {
use datasynth_core::models::internal_control::{ControlEffectiveness, TestResult};
let gen = ControlGenerator::new(42);
for ctrl in gen.controls() {
let level = ctrl.maturity_level.level();
if level >= 4 {
assert!(
ctrl.test_count >= 2,
"maturity {} should have test_count >= 2",
level
);
assert!(ctrl.last_tested_date.is_some());
assert_eq!(ctrl.test_result, TestResult::Pass);
assert_eq!(ctrl.effectiveness, ControlEffectiveness::Effective);
} else if level == 3 {
assert_eq!(ctrl.test_count, 1);
assert!(ctrl.last_tested_date.is_some());
assert_eq!(ctrl.test_result, TestResult::Partial);
assert_eq!(ctrl.effectiveness, ControlEffectiveness::PartiallyEffective);
} else {
assert_eq!(ctrl.test_count, 0);
assert!(ctrl.last_tested_date.is_none());
assert_eq!(ctrl.test_result, TestResult::NotTested);
assert_eq!(ctrl.effectiveness, ControlEffectiveness::NotTested);
}
assert!(
!ctrl.covers_account_classes.is_empty(),
"control {} should have non-empty covers_account_classes",
ctrl.control_id
);
}
}
#[test]
fn test_controls_account_classes_from_assertion() {
let gen = ControlGenerator::new(42);
let c001 = gen
.controls()
.iter()
.find(|c| c.control_id == "C001")
.unwrap();
assert_eq!(c001.covers_account_classes, vec!["Assets"]);
let c020 = gen
.controls()
.iter()
.find(|c| c.control_id == "C020")
.unwrap();
assert_eq!(
c020.covers_account_classes,
vec!["Assets", "Liabilities", "Equity", "Revenue", "Expenses"]
);
let c010 = gen
.controls()
.iter()
.find(|c| c.control_id == "C010")
.unwrap();
assert_eq!(c010.covers_account_classes, vec!["Revenue", "Liabilities"]);
}
#[test]
fn test_apply_controls() {
let mut gen = ControlGenerator::new(42);
let mut entry = create_test_entry();
let coa = create_test_coa();
gen.apply_controls(&mut entry, &coa);
assert!(matches!(
entry.header.control_status,
ControlStatus::Effective | ControlStatus::Exception | ControlStatus::NotTested
));
}
#[test]
fn test_sox_relevance_high_amount() {
let config = ControlGeneratorConfig {
sox_materiality_threshold: Decimal::from(10000),
..Default::default()
};
let mut gen = ControlGenerator::with_config(42, config);
let mut entry = create_test_entry();
let coa = create_test_coa();
gen.apply_controls(&mut entry, &coa);
assert!(entry.header.sox_relevant);
}
#[test]
fn test_sod_checker() {
let mut checker = SodChecker::new(42, 1.0); let entry = create_test_entry();
let (has_violation, conflict_type) = checker.check_entry(&entry);
assert!(has_violation);
assert!(conflict_type.is_some());
}
#[test]
fn test_sod_violation_record() {
let checker = SodChecker::new(42, 1.0);
let entry = create_test_entry();
let violation = checker.create_violation_record(&entry, SodConflictType::PreparerApprover);
assert_eq!(violation.actor_id, "USER001");
assert_eq!(violation.conflict_type, SodConflictType::PreparerApprover);
}
#[test]
fn test_deterministic_generation() {
let mut gen1 = ControlGenerator::new(42);
let mut gen2 = ControlGenerator::new(42);
let mut entry1 = create_test_entry();
let mut entry2 = create_test_entry();
let coa = create_test_coa();
gen1.apply_controls(&mut entry1, &coa);
gen2.apply_controls(&mut entry2, &coa);
assert_eq!(entry1.header.control_status, entry2.header.control_status);
assert_eq!(entry1.header.sod_violation, entry2.header.sod_violation);
}
#[test]
fn test_reset() {
let mut gen = ControlGenerator::new(42);
let coa = create_test_coa();
for _ in 0..10 {
let mut entry = create_test_entry();
gen.apply_controls(&mut entry, &coa);
}
gen.reset();
let mut entry1 = create_test_entry();
gen.apply_controls(&mut entry1, &coa);
gen.reset();
let mut entry2 = create_test_entry();
gen.apply_controls(&mut entry2, &coa);
assert_eq!(entry1.header.control_status, entry2.header.control_status);
}
}