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::S2C) => vec![
244                SodConflictType::RequesterApprover,
245                SodConflictType::MasterDataMaintainer,
246            ],
247            Some(BusinessProcess::Mfg) => vec![
248                SodConflictType::PreparerApprover,
249                SodConflictType::RequesterApprover,
250            ],
251            Some(BusinessProcess::Bank) => vec![
252                SodConflictType::PaymentReleaser,
253                SodConflictType::PreparerApprover,
254            ],
255            Some(BusinessProcess::Audit) => vec![SodConflictType::PreparerApprover],
256            Some(BusinessProcess::Treasury) | Some(BusinessProcess::Tax) => vec![
257                SodConflictType::PreparerApprover,
258                SodConflictType::PaymentReleaser,
259            ],
260            None => vec![
261                SodConflictType::PreparerApprover,
262                SodConflictType::SystemAccessConflict,
263            ],
264        };
265
266        // Randomly select from likely conflicts
267        likely_conflicts
268            .choose(&mut self.rng)
269            .copied()
270            .unwrap_or(SodConflictType::PreparerApprover)
271    }
272
273    /// Create a SoD violation record from an entry.
274    pub fn create_violation_record(
275        &self,
276        entry: &JournalEntry,
277        conflict_type: SodConflictType,
278    ) -> SodViolation {
279        let description = match conflict_type {
280            SodConflictType::PreparerApprover => {
281                format!(
282                    "User {} both prepared and approved journal entry {}",
283                    entry.header.created_by, entry.header.document_id
284                )
285            }
286            SodConflictType::RequesterApprover => {
287                format!(
288                    "User {} approved their own request in transaction {}",
289                    entry.header.created_by, entry.header.document_id
290                )
291            }
292            SodConflictType::ReconcilerPoster => {
293                format!(
294                    "User {} both reconciled and posted adjustments in {}",
295                    entry.header.created_by, entry.header.document_id
296                )
297            }
298            SodConflictType::MasterDataMaintainer => {
299                format!(
300                    "User {} maintains master data and processed payment {}",
301                    entry.header.created_by, entry.header.document_id
302                )
303            }
304            SodConflictType::PaymentReleaser => {
305                format!(
306                    "User {} both created and released payment {}",
307                    entry.header.created_by, entry.header.document_id
308                )
309            }
310            SodConflictType::JournalEntryPoster => {
311                format!(
312                    "User {} posted to sensitive accounts without review in {}",
313                    entry.header.created_by, entry.header.document_id
314                )
315            }
316            SodConflictType::SystemAccessConflict => {
317                format!(
318                    "User {} has conflicting system access roles for {}",
319                    entry.header.created_by, entry.header.document_id
320                )
321            }
322        };
323
324        // Determine severity based on conflict type and amount
325        let severity = self.determine_violation_severity(entry, conflict_type);
326
327        SodViolation::with_timestamp(
328            conflict_type,
329            &entry.header.created_by,
330            description,
331            severity,
332            entry.header.created_at,
333        )
334    }
335
336    /// Determine the severity of a violation.
337    fn determine_violation_severity(
338        &self,
339        entry: &JournalEntry,
340        conflict_type: SodConflictType,
341    ) -> RiskLevel {
342        let amount = entry.total_debit();
343
344        // Base severity from conflict type
345        let base_severity = match conflict_type {
346            SodConflictType::PaymentReleaser | SodConflictType::RequesterApprover => {
347                RiskLevel::Critical
348            }
349            SodConflictType::PreparerApprover | SodConflictType::MasterDataMaintainer => {
350                RiskLevel::High
351            }
352            SodConflictType::ReconcilerPoster | SodConflictType::JournalEntryPoster => {
353                RiskLevel::Medium
354            }
355            SodConflictType::SystemAccessConflict => RiskLevel::Low,
356        };
357
358        // Escalate based on amount
359        if amount >= Decimal::from(100000) {
360            match base_severity {
361                RiskLevel::Low => RiskLevel::Medium,
362                RiskLevel::Medium => RiskLevel::High,
363                RiskLevel::High | RiskLevel::Critical => RiskLevel::Critical,
364            }
365        } else {
366            base_severity
367        }
368    }
369
370    /// Get the SoD conflict pairs.
371    pub fn conflict_pairs(&self) -> &[SodConflictPair] {
372        &self.conflict_pairs
373    }
374
375    /// Reset the checker to its initial state.
376    pub fn reset(&mut self) {
377        self.rng = ChaCha8Rng::seed_from_u64(self.seed);
378    }
379}
380
381/// Extension trait for applying controls to journal entries.
382pub trait ControlApplicationExt {
383    /// Apply controls using the given generator.
384    fn apply_controls(&mut self, generator: &mut ControlGenerator, coa: &ChartOfAccounts);
385}
386
387impl ControlApplicationExt for JournalEntry {
388    fn apply_controls(&mut self, generator: &mut ControlGenerator, coa: &ChartOfAccounts) {
389        generator.apply_controls(self, coa);
390    }
391}
392
393#[cfg(test)]
394#[allow(clippy::unwrap_used)]
395mod tests {
396    use super::*;
397    use chrono::NaiveDate;
398    use datasynth_core::models::{JournalEntryHeader, JournalEntryLine};
399    use uuid::Uuid;
400
401    fn create_test_entry() -> JournalEntry {
402        let mut header = JournalEntryHeader::new(
403            "1000".to_string(),
404            NaiveDate::from_ymd_opt(2024, 6, 15).unwrap(),
405        );
406        header.business_process = Some(BusinessProcess::R2R);
407        header.created_by = "USER001".to_string();
408
409        let mut entry = JournalEntry::new(header);
410        entry.add_line(JournalEntryLine::debit(
411            Uuid::new_v4(),
412            1,
413            "100000".to_string(),
414            Decimal::from(50000),
415        ));
416        entry.add_line(JournalEntryLine::credit(
417            Uuid::new_v4(),
418            2,
419            "200000".to_string(),
420            Decimal::from(50000),
421        ));
422
423        entry
424    }
425
426    fn create_test_coa() -> ChartOfAccounts {
427        ChartOfAccounts::new(
428            "TEST".to_string(),
429            "Test CoA".to_string(),
430            "US".to_string(),
431            datasynth_core::IndustrySector::Manufacturing,
432            datasynth_core::CoAComplexity::Small,
433        )
434    }
435
436    #[test]
437    fn test_control_generator_creation() {
438        let gen = ControlGenerator::new(42);
439        assert!(!gen.controls().is_empty());
440    }
441
442    #[test]
443    fn test_apply_controls() {
444        let mut gen = ControlGenerator::new(42);
445        let mut entry = create_test_entry();
446        let coa = create_test_coa();
447
448        gen.apply_controls(&mut entry, &coa);
449
450        // After applying controls, entry should have control metadata
451        assert!(matches!(
452            entry.header.control_status,
453            ControlStatus::Effective | ControlStatus::Exception | ControlStatus::NotTested
454        ));
455    }
456
457    #[test]
458    fn test_sox_relevance_high_amount() {
459        let config = ControlGeneratorConfig {
460            sox_materiality_threshold: Decimal::from(10000),
461            ..Default::default()
462        };
463        let mut gen = ControlGenerator::with_config(42, config);
464        let mut entry = create_test_entry();
465        let coa = create_test_coa();
466
467        gen.apply_controls(&mut entry, &coa);
468
469        // Entry with 50,000 amount should be SOX-relevant
470        assert!(entry.header.sox_relevant);
471    }
472
473    #[test]
474    fn test_sod_checker() {
475        let mut checker = SodChecker::new(42, 1.0); // 100% violation rate for testing
476        let entry = create_test_entry();
477
478        let (has_violation, conflict_type) = checker.check_entry(&entry);
479
480        assert!(has_violation);
481        assert!(conflict_type.is_some());
482    }
483
484    #[test]
485    fn test_sod_violation_record() {
486        let checker = SodChecker::new(42, 1.0);
487        let entry = create_test_entry();
488
489        let violation = checker.create_violation_record(&entry, SodConflictType::PreparerApprover);
490
491        assert_eq!(violation.actor_id, "USER001");
492        assert_eq!(violation.conflict_type, SodConflictType::PreparerApprover);
493    }
494
495    #[test]
496    fn test_deterministic_generation() {
497        let mut gen1 = ControlGenerator::new(42);
498        let mut gen2 = ControlGenerator::new(42);
499
500        let mut entry1 = create_test_entry();
501        let mut entry2 = create_test_entry();
502        let coa = create_test_coa();
503
504        gen1.apply_controls(&mut entry1, &coa);
505        gen2.apply_controls(&mut entry2, &coa);
506
507        assert_eq!(entry1.header.control_status, entry2.header.control_status);
508        assert_eq!(entry1.header.sod_violation, entry2.header.sod_violation);
509    }
510
511    #[test]
512    fn test_reset() {
513        let mut gen = ControlGenerator::new(42);
514        let coa = create_test_coa();
515
516        // Generate some entries
517        for _ in 0..10 {
518            let mut entry = create_test_entry();
519            gen.apply_controls(&mut entry, &coa);
520        }
521
522        // Reset
523        gen.reset();
524
525        // Generate again - should produce same results
526        let mut entry1 = create_test_entry();
527        gen.apply_controls(&mut entry1, &coa);
528
529        gen.reset();
530
531        let mut entry2 = create_test_entry();
532        gen.apply_controls(&mut entry2, &coa);
533
534        assert_eq!(entry1.header.control_status, entry2.header.control_status);
535    }
536}