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