1use 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#[derive(Debug, Clone)]
20pub struct ControlGeneratorConfig {
21 pub exception_rate: f64,
23 pub sod_violation_rate: f64,
25 pub enable_sox_marking: bool,
27 pub sox_materiality_threshold: Decimal,
29 pub assessed_date: NaiveDate,
32}
33
34impl Default for ControlGeneratorConfig {
35 fn default() -> Self {
36 Self {
37 exception_rate: 0.02, sod_violation_rate: 0.01, 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
46pub 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 pub fn new(seed: u64) -> Self {
59 Self::with_config(seed, ControlGeneratorConfig::default())
60 }
61
62 pub fn with_config(seed: u64, config: ControlGeneratorConfig) -> Self {
64 let mut controls = InternalControl::standard_controls();
65
66 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 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 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 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 all_control_ids.sort();
123 all_control_ids.dedup();
124 entry.header.control_ids = all_control_ids;
125
126 entry.header.sox_relevant = self.determine_sox_relevance(entry);
128
129 entry.header.control_status = self.determine_control_status(entry);
131
132 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 fn determine_sox_relevance(&self, entry: &JournalEntry) -> bool {
140 if !self.config.enable_sox_marking {
141 return false;
142 }
143
144 let total_amount = entry.total_debit();
147 if total_amount >= self.config.sox_materiality_threshold {
148 return true;
149 }
150
151 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 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 fn determine_control_status(&mut self, entry: &JournalEntry) -> ControlStatus {
174 if entry.header.control_ids.is_empty() {
176 return ControlStatus::NotTested;
177 }
178
179 if self.rng.random::<f64>() < self.config.exception_rate {
181 ControlStatus::Exception
182 } else {
183 ControlStatus::Effective
184 }
185 }
186
187 pub fn controls(&self) -> &[InternalControl] {
189 &self.controls
190 }
191
192 pub fn registry(&self) -> &ControlMappingRegistry {
194 &self.registry
195 }
196
197 pub fn reset(&mut self) {
199 self.rng = seeded_rng(self.seed, 0);
200 self.sod_checker.reset();
201 }
202}
203
204pub struct SodChecker {
206 rng: ChaCha8Rng,
207 seed: u64,
208 violation_rate: f64,
209 conflict_pairs: Vec<SodConflictPair>,
210}
211
212impl SodChecker {
213 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 pub fn check_entry(&mut self, entry: &JournalEntry) -> (bool, Option<SodConflictType>) {
227 if self.rng.random::<f64>() >= self.violation_rate {
229 return (false, None);
230 }
231
232 let conflict_type = self.select_conflict_type(entry);
234
235 (true, Some(conflict_type))
236 }
237
238 fn select_conflict_type(&mut self, entry: &JournalEntry) -> SodConflictType {
240 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 likely_conflicts
295 .choose(&mut self.rng)
296 .copied()
297 .unwrap_or(SodConflictType::PreparerApprover)
298 }
299
300 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 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 fn determine_violation_severity(
365 &self,
366 entry: &JournalEntry,
367 conflict_type: SodConflictType,
368 ) -> RiskLevel {
369 let amount = entry.total_debit();
370
371 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 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 pub fn conflict_pairs(&self) -> &[SodConflictPair] {
399 &self.conflict_pairs
400 }
401
402 pub fn reset(&mut self) {
404 self.rng = seeded_rng(self.seed, 0);
405 }
406}
407
408pub trait ControlApplicationExt {
410 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 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 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 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 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 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 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 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 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 assert!(entry.header.sox_relevant);
571 }
572
573 #[test]
574 fn test_sod_checker() {
575 let mut checker = SodChecker::new(42, 1.0); 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 for _ in 0..10 {
618 let mut entry = create_test_entry();
619 gen.apply_controls(&mut entry, &coa);
620 }
621
622 gen.reset();
624
625 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}