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)]
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 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 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 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 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 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 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 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 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 assert!(entry.header.sox_relevant);
572 }
573
574 #[test]
575 fn test_sod_checker() {
576 let mut checker = SodChecker::new(42, 1.0); 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 for _ in 0..10 {
619 let mut entry = create_test_entry();
620 gen.apply_controls(&mut entry, &coa);
621 }
622
623 gen.reset();
625
626 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}