Skip to main content

datasynth_generators/
control_generator.rs

1//! Control generator for applying Internal Controls System (ICS) to transactions.
2//!
3//! Implements control application, SOX relevance determination, and
4//! Segregation of Duties (SoD) violation detection.
5
6use datasynth_core::utils::seeded_rng;
7use rand::prelude::*;
8use rand_chacha::ChaCha8Rng;
9use rust_decimal::Decimal;
10use tracing::debug;
11
12use datasynth_core::models::{
13    BusinessProcess, ChartOfAccounts, ControlMappingRegistry, ControlStatus, InternalControl,
14    JournalEntry, RiskLevel, SodConflictPair, SodConflictType, SodViolation,
15};
16
17/// Configuration for the control generator.
18#[derive(Debug, Clone)]
19pub struct ControlGeneratorConfig {
20    /// Rate at which controls result in exceptions (0.0 - 1.0).
21    pub exception_rate: f64,
22    /// Rate at which SoD violations occur (0.0 - 1.0).
23    pub sod_violation_rate: f64,
24    /// Whether to mark SOX-relevant transactions.
25    pub enable_sox_marking: bool,
26    /// Amount threshold above which transactions are SOX-relevant.
27    pub sox_materiality_threshold: Decimal,
28}
29
30impl Default for ControlGeneratorConfig {
31    fn default() -> Self {
32        Self {
33            exception_rate: 0.02,     // 2% exception rate
34            sod_violation_rate: 0.01, // 1% SoD violation rate
35            enable_sox_marking: true,
36            sox_materiality_threshold: Decimal::from(10000),
37        }
38    }
39}
40
41/// Generator that applies internal controls to journal entries.
42pub struct ControlGenerator {
43    rng: ChaCha8Rng,
44    seed: u64,
45    config: ControlGeneratorConfig,
46    registry: ControlMappingRegistry,
47    controls: Vec<InternalControl>,
48    sod_checker: SodChecker,
49}
50
51impl ControlGenerator {
52    /// Create a new control generator with default configuration.
53    pub fn new(seed: u64) -> Self {
54        Self::with_config(seed, ControlGeneratorConfig::default())
55    }
56
57    /// Create a new control generator with custom configuration.
58    pub fn with_config(seed: u64, config: ControlGeneratorConfig) -> Self {
59        Self {
60            rng: seeded_rng(seed, 0),
61            seed,
62            config: config.clone(),
63            registry: ControlMappingRegistry::standard(),
64            controls: InternalControl::standard_controls(),
65            sod_checker: SodChecker::new(seed + 1, config.sod_violation_rate),
66        }
67    }
68
69    /// Apply controls to a journal entry.
70    ///
71    /// This modifies the journal entry header to include:
72    /// - Applicable control IDs
73    /// - SOX relevance flag
74    /// - Control status (effective, exception, not tested)
75    /// - SoD violation flag and conflict type
76    pub fn apply_controls(&mut self, entry: &mut JournalEntry, coa: &ChartOfAccounts) {
77        debug!(
78            document_id = %entry.header.document_id,
79            company_code = %entry.header.company_code,
80            exception_rate = self.config.exception_rate,
81            "Applying controls to journal entry"
82        );
83
84        // Determine applicable controls from all line items
85        let mut all_control_ids = Vec::new();
86
87        for line in &entry.lines {
88            let amount = if line.debit_amount > Decimal::ZERO {
89                line.debit_amount
90            } else {
91                line.credit_amount
92            };
93
94            // Get account sub-type from CoA
95            let account_sub_type = coa.get_account(&line.gl_account).map(|acc| acc.sub_type);
96
97            let control_ids = self.registry.get_applicable_controls(
98                &line.gl_account,
99                account_sub_type.as_ref(),
100                entry.header.business_process.as_ref(),
101                amount,
102                Some(&entry.header.document_type),
103            );
104
105            all_control_ids.extend(control_ids);
106        }
107
108        // Deduplicate and sort control IDs
109        all_control_ids.sort();
110        all_control_ids.dedup();
111        entry.header.control_ids = all_control_ids;
112
113        // Determine SOX relevance
114        entry.header.sox_relevant = self.determine_sox_relevance(entry);
115
116        // Determine control status
117        entry.header.control_status = self.determine_control_status(entry);
118
119        // Check for SoD violations
120        let (sod_violation, sod_conflict_type) = self.sod_checker.check_entry(entry);
121        entry.header.sod_violation = sod_violation;
122        entry.header.sod_conflict_type = sod_conflict_type;
123    }
124
125    /// Determine if a transaction is SOX-relevant.
126    fn determine_sox_relevance(&self, entry: &JournalEntry) -> bool {
127        if !self.config.enable_sox_marking {
128            return false;
129        }
130
131        // SOX-relevant if:
132        // 1. Amount exceeds materiality threshold
133        let total_amount = entry.total_debit();
134        if total_amount >= self.config.sox_materiality_threshold {
135            return true;
136        }
137
138        // 2. Has key controls applied
139        let has_key_control = entry.header.control_ids.iter().any(|cid| {
140            self.controls
141                .iter()
142                .any(|c| c.control_id == *cid && c.is_key_control)
143        });
144        if has_key_control {
145            return true;
146        }
147
148        // 3. Involves critical business processes
149        if let Some(bp) = &entry.header.business_process {
150            matches!(
151                bp,
152                BusinessProcess::R2R | BusinessProcess::P2P | BusinessProcess::O2C
153            )
154        } else {
155            false
156        }
157    }
158
159    /// Determine the control status for a transaction.
160    fn determine_control_status(&mut self, entry: &JournalEntry) -> ControlStatus {
161        // If no controls apply, mark as not tested
162        if entry.header.control_ids.is_empty() {
163            return ControlStatus::NotTested;
164        }
165
166        // Roll for exception based on exception rate
167        if self.rng.gen::<f64>() < self.config.exception_rate {
168            ControlStatus::Exception
169        } else {
170            ControlStatus::Effective
171        }
172    }
173
174    /// Get the current control definitions.
175    pub fn controls(&self) -> &[InternalControl] {
176        &self.controls
177    }
178
179    /// Get the control mapping registry.
180    pub fn registry(&self) -> &ControlMappingRegistry {
181        &self.registry
182    }
183
184    /// Reset the generator to its initial state.
185    pub fn reset(&mut self) {
186        self.rng = seeded_rng(self.seed, 0);
187        self.sod_checker.reset();
188    }
189}
190
191/// Checker for Segregation of Duties (SoD) violations.
192pub struct SodChecker {
193    rng: ChaCha8Rng,
194    seed: u64,
195    violation_rate: f64,
196    conflict_pairs: Vec<SodConflictPair>,
197}
198
199impl SodChecker {
200    /// Create a new SoD checker.
201    pub fn new(seed: u64, violation_rate: f64) -> Self {
202        Self {
203            rng: seeded_rng(seed, 0),
204            seed,
205            violation_rate,
206            conflict_pairs: SodConflictPair::standard_conflicts(),
207        }
208    }
209
210    /// Check a journal entry for SoD violations.
211    ///
212    /// Returns a tuple of (has_violation, conflict_type).
213    pub fn check_entry(&mut self, entry: &JournalEntry) -> (bool, Option<SodConflictType>) {
214        // Roll for violation based on violation rate
215        if self.rng.gen::<f64>() >= self.violation_rate {
216            return (false, None);
217        }
218
219        // Select an appropriate conflict type based on transaction characteristics
220        let conflict_type = self.select_conflict_type(entry);
221
222        (true, Some(conflict_type))
223    }
224
225    /// Select a conflict type based on transaction characteristics.
226    fn select_conflict_type(&mut self, entry: &JournalEntry) -> SodConflictType {
227        // Map business process to likely conflict types
228        let likely_conflicts: Vec<SodConflictType> = match entry.header.business_process {
229            Some(BusinessProcess::P2P) => vec![
230                SodConflictType::PaymentReleaser,
231                SodConflictType::MasterDataMaintainer,
232                SodConflictType::PreparerApprover,
233            ],
234            Some(BusinessProcess::O2C) => vec![
235                SodConflictType::PreparerApprover,
236                SodConflictType::RequesterApprover,
237            ],
238            Some(BusinessProcess::R2R) => vec![
239                SodConflictType::PreparerApprover,
240                SodConflictType::ReconcilerPoster,
241                SodConflictType::JournalEntryPoster,
242            ],
243            Some(BusinessProcess::H2R) => vec![
244                SodConflictType::RequesterApprover,
245                SodConflictType::PreparerApprover,
246            ],
247            Some(BusinessProcess::A2R) => vec![SodConflictType::PreparerApprover],
248            Some(BusinessProcess::Intercompany) => vec![
249                SodConflictType::PreparerApprover,
250                SodConflictType::ReconcilerPoster,
251            ],
252            Some(BusinessProcess::S2C) => vec![
253                SodConflictType::RequesterApprover,
254                SodConflictType::MasterDataMaintainer,
255            ],
256            Some(BusinessProcess::Mfg) => vec![
257                SodConflictType::PreparerApprover,
258                SodConflictType::RequesterApprover,
259            ],
260            Some(BusinessProcess::Bank) => vec![
261                SodConflictType::PaymentReleaser,
262                SodConflictType::PreparerApprover,
263            ],
264            Some(BusinessProcess::Audit) => vec![SodConflictType::PreparerApprover],
265            Some(BusinessProcess::Treasury) | Some(BusinessProcess::Tax) => vec![
266                SodConflictType::PreparerApprover,
267                SodConflictType::PaymentReleaser,
268            ],
269            Some(BusinessProcess::ProjectAccounting) => vec![
270                SodConflictType::PreparerApprover,
271                SodConflictType::RequesterApprover,
272            ],
273            Some(BusinessProcess::Esg) => vec![SodConflictType::PreparerApprover],
274            None => vec![
275                SodConflictType::PreparerApprover,
276                SodConflictType::SystemAccessConflict,
277            ],
278        };
279
280        // Randomly select from likely conflicts
281        likely_conflicts
282            .choose(&mut self.rng)
283            .copied()
284            .unwrap_or(SodConflictType::PreparerApprover)
285    }
286
287    /// Create a SoD violation record from an entry.
288    pub fn create_violation_record(
289        &self,
290        entry: &JournalEntry,
291        conflict_type: SodConflictType,
292    ) -> SodViolation {
293        let description = match conflict_type {
294            SodConflictType::PreparerApprover => {
295                format!(
296                    "User {} both prepared and approved journal entry {}",
297                    entry.header.created_by, entry.header.document_id
298                )
299            }
300            SodConflictType::RequesterApprover => {
301                format!(
302                    "User {} approved their own request in transaction {}",
303                    entry.header.created_by, entry.header.document_id
304                )
305            }
306            SodConflictType::ReconcilerPoster => {
307                format!(
308                    "User {} both reconciled and posted adjustments in {}",
309                    entry.header.created_by, entry.header.document_id
310                )
311            }
312            SodConflictType::MasterDataMaintainer => {
313                format!(
314                    "User {} maintains master data and processed payment {}",
315                    entry.header.created_by, entry.header.document_id
316                )
317            }
318            SodConflictType::PaymentReleaser => {
319                format!(
320                    "User {} both created and released payment {}",
321                    entry.header.created_by, entry.header.document_id
322                )
323            }
324            SodConflictType::JournalEntryPoster => {
325                format!(
326                    "User {} posted to sensitive accounts without review in {}",
327                    entry.header.created_by, entry.header.document_id
328                )
329            }
330            SodConflictType::SystemAccessConflict => {
331                format!(
332                    "User {} has conflicting system access roles for {}",
333                    entry.header.created_by, entry.header.document_id
334                )
335            }
336        };
337
338        // Determine severity based on conflict type and amount
339        let severity = self.determine_violation_severity(entry, conflict_type);
340
341        SodViolation::with_timestamp(
342            conflict_type,
343            &entry.header.created_by,
344            description,
345            severity,
346            entry.header.created_at,
347        )
348    }
349
350    /// Determine the severity of a violation.
351    fn determine_violation_severity(
352        &self,
353        entry: &JournalEntry,
354        conflict_type: SodConflictType,
355    ) -> RiskLevel {
356        let amount = entry.total_debit();
357
358        // Base severity from conflict type
359        let base_severity = match conflict_type {
360            SodConflictType::PaymentReleaser | SodConflictType::RequesterApprover => {
361                RiskLevel::Critical
362            }
363            SodConflictType::PreparerApprover | SodConflictType::MasterDataMaintainer => {
364                RiskLevel::High
365            }
366            SodConflictType::ReconcilerPoster | SodConflictType::JournalEntryPoster => {
367                RiskLevel::Medium
368            }
369            SodConflictType::SystemAccessConflict => RiskLevel::Low,
370        };
371
372        // Escalate based on amount
373        if amount >= Decimal::from(100000) {
374            match base_severity {
375                RiskLevel::Low => RiskLevel::Medium,
376                RiskLevel::Medium => RiskLevel::High,
377                RiskLevel::High | RiskLevel::Critical => RiskLevel::Critical,
378            }
379        } else {
380            base_severity
381        }
382    }
383
384    /// Get the SoD conflict pairs.
385    pub fn conflict_pairs(&self) -> &[SodConflictPair] {
386        &self.conflict_pairs
387    }
388
389    /// Reset the checker to its initial state.
390    pub fn reset(&mut self) {
391        self.rng = seeded_rng(self.seed, 0);
392    }
393}
394
395/// Extension trait for applying controls to journal entries.
396pub trait ControlApplicationExt {
397    /// Apply controls using the given generator.
398    fn apply_controls(&mut self, generator: &mut ControlGenerator, coa: &ChartOfAccounts);
399}
400
401impl ControlApplicationExt for JournalEntry {
402    fn apply_controls(&mut self, generator: &mut ControlGenerator, coa: &ChartOfAccounts) {
403        generator.apply_controls(self, coa);
404    }
405}
406
407#[cfg(test)]
408#[allow(clippy::unwrap_used)]
409mod tests {
410    use super::*;
411    use chrono::NaiveDate;
412    use datasynth_core::models::{JournalEntryHeader, JournalEntryLine};
413    use uuid::Uuid;
414
415    fn create_test_entry() -> JournalEntry {
416        let mut header = JournalEntryHeader::new(
417            "1000".to_string(),
418            NaiveDate::from_ymd_opt(2024, 6, 15).unwrap(),
419        );
420        header.business_process = Some(BusinessProcess::R2R);
421        header.created_by = "USER001".to_string();
422
423        let mut entry = JournalEntry::new(header);
424        entry.add_line(JournalEntryLine::debit(
425            Uuid::new_v4(),
426            1,
427            "100000".to_string(),
428            Decimal::from(50000),
429        ));
430        entry.add_line(JournalEntryLine::credit(
431            Uuid::new_v4(),
432            2,
433            "200000".to_string(),
434            Decimal::from(50000),
435        ));
436
437        entry
438    }
439
440    fn create_test_coa() -> ChartOfAccounts {
441        ChartOfAccounts::new(
442            "TEST".to_string(),
443            "Test CoA".to_string(),
444            "US".to_string(),
445            datasynth_core::IndustrySector::Manufacturing,
446            datasynth_core::CoAComplexity::Small,
447        )
448    }
449
450    #[test]
451    fn test_control_generator_creation() {
452        let gen = ControlGenerator::new(42);
453        assert!(!gen.controls().is_empty());
454    }
455
456    #[test]
457    fn test_apply_controls() {
458        let mut gen = ControlGenerator::new(42);
459        let mut entry = create_test_entry();
460        let coa = create_test_coa();
461
462        gen.apply_controls(&mut entry, &coa);
463
464        // After applying controls, entry should have control metadata
465        assert!(matches!(
466            entry.header.control_status,
467            ControlStatus::Effective | ControlStatus::Exception | ControlStatus::NotTested
468        ));
469    }
470
471    #[test]
472    fn test_sox_relevance_high_amount() {
473        let config = ControlGeneratorConfig {
474            sox_materiality_threshold: Decimal::from(10000),
475            ..Default::default()
476        };
477        let mut gen = ControlGenerator::with_config(42, config);
478        let mut entry = create_test_entry();
479        let coa = create_test_coa();
480
481        gen.apply_controls(&mut entry, &coa);
482
483        // Entry with 50,000 amount should be SOX-relevant
484        assert!(entry.header.sox_relevant);
485    }
486
487    #[test]
488    fn test_sod_checker() {
489        let mut checker = SodChecker::new(42, 1.0); // 100% violation rate for testing
490        let entry = create_test_entry();
491
492        let (has_violation, conflict_type) = checker.check_entry(&entry);
493
494        assert!(has_violation);
495        assert!(conflict_type.is_some());
496    }
497
498    #[test]
499    fn test_sod_violation_record() {
500        let checker = SodChecker::new(42, 1.0);
501        let entry = create_test_entry();
502
503        let violation = checker.create_violation_record(&entry, SodConflictType::PreparerApprover);
504
505        assert_eq!(violation.actor_id, "USER001");
506        assert_eq!(violation.conflict_type, SodConflictType::PreparerApprover);
507    }
508
509    #[test]
510    fn test_deterministic_generation() {
511        let mut gen1 = ControlGenerator::new(42);
512        let mut gen2 = ControlGenerator::new(42);
513
514        let mut entry1 = create_test_entry();
515        let mut entry2 = create_test_entry();
516        let coa = create_test_coa();
517
518        gen1.apply_controls(&mut entry1, &coa);
519        gen2.apply_controls(&mut entry2, &coa);
520
521        assert_eq!(entry1.header.control_status, entry2.header.control_status);
522        assert_eq!(entry1.header.sod_violation, entry2.header.sod_violation);
523    }
524
525    #[test]
526    fn test_reset() {
527        let mut gen = ControlGenerator::new(42);
528        let coa = create_test_coa();
529
530        // Generate some entries
531        for _ in 0..10 {
532            let mut entry = create_test_entry();
533            gen.apply_controls(&mut entry, &coa);
534        }
535
536        // Reset
537        gen.reset();
538
539        // Generate again - should produce same results
540        let mut entry1 = create_test_entry();
541        gen.apply_controls(&mut entry1, &coa);
542
543        gen.reset();
544
545        let mut entry2 = create_test_entry();
546        gen.apply_controls(&mut entry2, &coa);
547
548        assert_eq!(entry1.header.control_status, entry2.header.control_status);
549    }
550}