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)]
421#[allow(clippy::unwrap_used)]
422mod tests {
423    use super::*;
424    use chrono::NaiveDate;
425    use datasynth_core::models::{JournalEntryHeader, JournalEntryLine};
426    use uuid::Uuid;
427
428    fn create_test_entry() -> JournalEntry {
429        let mut header = JournalEntryHeader::new(
430            "1000".to_string(),
431            NaiveDate::from_ymd_opt(2024, 6, 15).unwrap(),
432        );
433        header.business_process = Some(BusinessProcess::R2R);
434        header.created_by = "USER001".to_string();
435
436        let mut entry = JournalEntry::new(header);
437        entry.add_line(JournalEntryLine::debit(
438            Uuid::new_v4(),
439            1,
440            "100000".to_string(),
441            Decimal::from(50000),
442        ));
443        entry.add_line(JournalEntryLine::credit(
444            Uuid::new_v4(),
445            2,
446            "200000".to_string(),
447            Decimal::from(50000),
448        ));
449
450        entry
451    }
452
453    fn create_test_coa() -> ChartOfAccounts {
454        ChartOfAccounts::new(
455            "TEST".to_string(),
456            "Test CoA".to_string(),
457            "US".to_string(),
458            datasynth_core::IndustrySector::Manufacturing,
459            datasynth_core::CoAComplexity::Small,
460        )
461    }
462
463    #[test]
464    fn test_control_generator_creation() {
465        let gen = ControlGenerator::new(42);
466        assert!(!gen.controls().is_empty());
467    }
468
469    #[test]
470    fn test_controls_enriched_with_test_history() {
471        use datasynth_core::models::internal_control::{ControlEffectiveness, TestResult};
472
473        let gen = ControlGenerator::new(42);
474
475        for ctrl in gen.controls() {
476            let level = ctrl.maturity_level.level();
477
478            if level >= 4 {
479                // Managed or Optimized: should be tested and effective
480                assert!(
481                    ctrl.test_count >= 2,
482                    "maturity {} should have test_count >= 2",
483                    level
484                );
485                assert!(ctrl.last_tested_date.is_some());
486                assert_eq!(ctrl.test_result, TestResult::Pass);
487                assert_eq!(ctrl.effectiveness, ControlEffectiveness::Effective);
488            } else if level == 3 {
489                // Defined: tested once, partial
490                assert_eq!(ctrl.test_count, 1);
491                assert!(ctrl.last_tested_date.is_some());
492                assert_eq!(ctrl.test_result, TestResult::Partial);
493                assert_eq!(ctrl.effectiveness, ControlEffectiveness::PartiallyEffective);
494            } else {
495                // Low maturity: not tested
496                assert_eq!(ctrl.test_count, 0);
497                assert!(ctrl.last_tested_date.is_none());
498                assert_eq!(ctrl.test_result, TestResult::NotTested);
499                assert_eq!(ctrl.effectiveness, ControlEffectiveness::NotTested);
500            }
501
502            // All controls should have account classes derived
503            assert!(
504                !ctrl.covers_account_classes.is_empty(),
505                "control {} should have non-empty covers_account_classes",
506                ctrl.control_id
507            );
508        }
509    }
510
511    #[test]
512    fn test_controls_account_classes_from_assertion() {
513        let gen = ControlGenerator::new(42);
514
515        // Find a control with Existence assertion (e.g., C001)
516        let c001 = gen
517            .controls()
518            .iter()
519            .find(|c| c.control_id == "C001")
520            .unwrap();
521        assert_eq!(c001.covers_account_classes, vec!["Assets"]);
522
523        // Find a control with Valuation assertion (e.g., C020)
524        let c020 = gen
525            .controls()
526            .iter()
527            .find(|c| c.control_id == "C020")
528            .unwrap();
529        assert_eq!(
530            c020.covers_account_classes,
531            vec!["Assets", "Liabilities", "Equity", "Revenue", "Expenses"]
532        );
533
534        // Find a control with Completeness assertion (e.g., C010)
535        let c010 = gen
536            .controls()
537            .iter()
538            .find(|c| c.control_id == "C010")
539            .unwrap();
540        assert_eq!(c010.covers_account_classes, vec!["Revenue", "Liabilities"]);
541    }
542
543    #[test]
544    fn test_apply_controls() {
545        let mut gen = ControlGenerator::new(42);
546        let mut entry = create_test_entry();
547        let coa = create_test_coa();
548
549        gen.apply_controls(&mut entry, &coa);
550
551        // After applying controls, entry should have control metadata
552        assert!(matches!(
553            entry.header.control_status,
554            ControlStatus::Effective | ControlStatus::Exception | ControlStatus::NotTested
555        ));
556    }
557
558    #[test]
559    fn test_sox_relevance_high_amount() {
560        let config = ControlGeneratorConfig {
561            sox_materiality_threshold: Decimal::from(10000),
562            ..Default::default()
563        };
564        let mut gen = ControlGenerator::with_config(42, config);
565        let mut entry = create_test_entry();
566        let coa = create_test_coa();
567
568        gen.apply_controls(&mut entry, &coa);
569
570        // Entry with 50,000 amount should be SOX-relevant
571        assert!(entry.header.sox_relevant);
572    }
573
574    #[test]
575    fn test_sod_checker() {
576        let mut checker = SodChecker::new(42, 1.0); // 100% violation rate for testing
577        let entry = create_test_entry();
578
579        let (has_violation, conflict_type) = checker.check_entry(&entry);
580
581        assert!(has_violation);
582        assert!(conflict_type.is_some());
583    }
584
585    #[test]
586    fn test_sod_violation_record() {
587        let checker = SodChecker::new(42, 1.0);
588        let entry = create_test_entry();
589
590        let violation = checker.create_violation_record(&entry, SodConflictType::PreparerApprover);
591
592        assert_eq!(violation.actor_id, "USER001");
593        assert_eq!(violation.conflict_type, SodConflictType::PreparerApprover);
594    }
595
596    #[test]
597    fn test_deterministic_generation() {
598        let mut gen1 = ControlGenerator::new(42);
599        let mut gen2 = ControlGenerator::new(42);
600
601        let mut entry1 = create_test_entry();
602        let mut entry2 = create_test_entry();
603        let coa = create_test_coa();
604
605        gen1.apply_controls(&mut entry1, &coa);
606        gen2.apply_controls(&mut entry2, &coa);
607
608        assert_eq!(entry1.header.control_status, entry2.header.control_status);
609        assert_eq!(entry1.header.sod_violation, entry2.header.sod_violation);
610    }
611
612    #[test]
613    fn test_reset() {
614        let mut gen = ControlGenerator::new(42);
615        let coa = create_test_coa();
616
617        // Generate some entries
618        for _ in 0..10 {
619            let mut entry = create_test_entry();
620            gen.apply_controls(&mut entry, &coa);
621        }
622
623        // Reset
624        gen.reset();
625
626        // Generate again - should produce same results
627        let mut entry1 = create_test_entry();
628        gen.apply_controls(&mut entry1, &coa);
629
630        gen.reset();
631
632        let mut entry2 = create_test_entry();
633        gen.apply_controls(&mut entry2, &coa);
634
635        assert_eq!(entry1.header.control_status, entry2.header.control_status);
636    }
637}