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