1use datasynth_core::utils::seeded_rng;
7use rand::prelude::*;
8use rand_chacha::ChaCha8Rng;
9use rust_decimal::Decimal;
10use tracing::debug;
11
12use datasynth_core::models::{
13 BusinessProcess, ChartOfAccounts, ControlMappingRegistry, ControlStatus, InternalControl,
14 JournalEntry, RiskLevel, SodConflictPair, SodConflictType, SodViolation,
15};
16
17#[derive(Debug, Clone)]
19pub struct ControlGeneratorConfig {
20 pub exception_rate: f64,
22 pub sod_violation_rate: f64,
24 pub enable_sox_marking: bool,
26 pub sox_materiality_threshold: Decimal,
28}
29
30impl Default for ControlGeneratorConfig {
31 fn default() -> Self {
32 Self {
33 exception_rate: 0.02, sod_violation_rate: 0.01, enable_sox_marking: true,
36 sox_materiality_threshold: Decimal::from(10000),
37 }
38 }
39}
40
41pub struct ControlGenerator {
43 rng: ChaCha8Rng,
44 seed: u64,
45 config: ControlGeneratorConfig,
46 registry: ControlMappingRegistry,
47 controls: Vec<InternalControl>,
48 sod_checker: SodChecker,
49}
50
51impl ControlGenerator {
52 pub fn new(seed: u64) -> Self {
54 Self::with_config(seed, ControlGeneratorConfig::default())
55 }
56
57 pub fn with_config(seed: u64, config: ControlGeneratorConfig) -> Self {
59 Self {
60 rng: seeded_rng(seed, 0),
61 seed,
62 config: config.clone(),
63 registry: ControlMappingRegistry::standard(),
64 controls: InternalControl::standard_controls(),
65 sod_checker: SodChecker::new(seed + 1, config.sod_violation_rate),
66 }
67 }
68
69 pub fn apply_controls(&mut self, entry: &mut JournalEntry, coa: &ChartOfAccounts) {
77 debug!(
78 document_id = %entry.header.document_id,
79 company_code = %entry.header.company_code,
80 exception_rate = self.config.exception_rate,
81 "Applying controls to journal entry"
82 );
83
84 let mut all_control_ids = Vec::new();
86
87 for line in &entry.lines {
88 let amount = if line.debit_amount > Decimal::ZERO {
89 line.debit_amount
90 } else {
91 line.credit_amount
92 };
93
94 let account_sub_type = coa.get_account(&line.gl_account).map(|acc| acc.sub_type);
96
97 let control_ids = self.registry.get_applicable_controls(
98 &line.gl_account,
99 account_sub_type.as_ref(),
100 entry.header.business_process.as_ref(),
101 amount,
102 Some(&entry.header.document_type),
103 );
104
105 all_control_ids.extend(control_ids);
106 }
107
108 all_control_ids.sort();
110 all_control_ids.dedup();
111 entry.header.control_ids = all_control_ids;
112
113 entry.header.sox_relevant = self.determine_sox_relevance(entry);
115
116 entry.header.control_status = self.determine_control_status(entry);
118
119 let (sod_violation, sod_conflict_type) = self.sod_checker.check_entry(entry);
121 entry.header.sod_violation = sod_violation;
122 entry.header.sod_conflict_type = sod_conflict_type;
123 }
124
125 fn determine_sox_relevance(&self, entry: &JournalEntry) -> bool {
127 if !self.config.enable_sox_marking {
128 return false;
129 }
130
131 let total_amount = entry.total_debit();
134 if total_amount >= self.config.sox_materiality_threshold {
135 return true;
136 }
137
138 let has_key_control = entry.header.control_ids.iter().any(|cid| {
140 self.controls
141 .iter()
142 .any(|c| c.control_id == *cid && c.is_key_control)
143 });
144 if has_key_control {
145 return true;
146 }
147
148 if let Some(bp) = &entry.header.business_process {
150 matches!(
151 bp,
152 BusinessProcess::R2R | BusinessProcess::P2P | BusinessProcess::O2C
153 )
154 } else {
155 false
156 }
157 }
158
159 fn determine_control_status(&mut self, entry: &JournalEntry) -> ControlStatus {
161 if entry.header.control_ids.is_empty() {
163 return ControlStatus::NotTested;
164 }
165
166 if self.rng.random::<f64>() < self.config.exception_rate {
168 ControlStatus::Exception
169 } else {
170 ControlStatus::Effective
171 }
172 }
173
174 pub fn controls(&self) -> &[InternalControl] {
176 &self.controls
177 }
178
179 pub fn registry(&self) -> &ControlMappingRegistry {
181 &self.registry
182 }
183
184 pub fn reset(&mut self) {
186 self.rng = seeded_rng(self.seed, 0);
187 self.sod_checker.reset();
188 }
189}
190
191pub struct SodChecker {
193 rng: ChaCha8Rng,
194 seed: u64,
195 violation_rate: f64,
196 conflict_pairs: Vec<SodConflictPair>,
197}
198
199impl SodChecker {
200 pub fn new(seed: u64, violation_rate: f64) -> Self {
202 Self {
203 rng: seeded_rng(seed, 0),
204 seed,
205 violation_rate,
206 conflict_pairs: SodConflictPair::standard_conflicts(),
207 }
208 }
209
210 pub fn check_entry(&mut self, entry: &JournalEntry) -> (bool, Option<SodConflictType>) {
214 if self.rng.random::<f64>() >= self.violation_rate {
216 return (false, None);
217 }
218
219 let conflict_type = self.select_conflict_type(entry);
221
222 (true, Some(conflict_type))
223 }
224
225 fn select_conflict_type(&mut self, entry: &JournalEntry) -> SodConflictType {
227 let likely_conflicts: Vec<SodConflictType> = match entry.header.business_process {
229 Some(BusinessProcess::P2P) => vec![
230 SodConflictType::PaymentReleaser,
231 SodConflictType::MasterDataMaintainer,
232 SodConflictType::PreparerApprover,
233 ],
234 Some(BusinessProcess::O2C) => vec![
235 SodConflictType::PreparerApprover,
236 SodConflictType::RequesterApprover,
237 ],
238 Some(BusinessProcess::R2R) => vec![
239 SodConflictType::PreparerApprover,
240 SodConflictType::ReconcilerPoster,
241 SodConflictType::JournalEntryPoster,
242 ],
243 Some(BusinessProcess::H2R) => vec![
244 SodConflictType::RequesterApprover,
245 SodConflictType::PreparerApprover,
246 ],
247 Some(BusinessProcess::A2R) => vec![SodConflictType::PreparerApprover],
248 Some(BusinessProcess::Intercompany) => vec![
249 SodConflictType::PreparerApprover,
250 SodConflictType::ReconcilerPoster,
251 ],
252 Some(BusinessProcess::S2C) => vec![
253 SodConflictType::RequesterApprover,
254 SodConflictType::MasterDataMaintainer,
255 ],
256 Some(BusinessProcess::Mfg) => vec![
257 SodConflictType::PreparerApprover,
258 SodConflictType::RequesterApprover,
259 ],
260 Some(BusinessProcess::Bank) => vec![
261 SodConflictType::PaymentReleaser,
262 SodConflictType::PreparerApprover,
263 ],
264 Some(BusinessProcess::Audit) => vec![SodConflictType::PreparerApprover],
265 Some(BusinessProcess::Treasury) | Some(BusinessProcess::Tax) => vec![
266 SodConflictType::PreparerApprover,
267 SodConflictType::PaymentReleaser,
268 ],
269 Some(BusinessProcess::ProjectAccounting) => vec![
270 SodConflictType::PreparerApprover,
271 SodConflictType::RequesterApprover,
272 ],
273 Some(BusinessProcess::Esg) => vec![SodConflictType::PreparerApprover],
274 None => vec![
275 SodConflictType::PreparerApprover,
276 SodConflictType::SystemAccessConflict,
277 ],
278 };
279
280 likely_conflicts
282 .choose(&mut self.rng)
283 .copied()
284 .unwrap_or(SodConflictType::PreparerApprover)
285 }
286
287 pub fn create_violation_record(
289 &self,
290 entry: &JournalEntry,
291 conflict_type: SodConflictType,
292 ) -> SodViolation {
293 let description = match conflict_type {
294 SodConflictType::PreparerApprover => {
295 format!(
296 "User {} both prepared and approved journal entry {}",
297 entry.header.created_by, entry.header.document_id
298 )
299 }
300 SodConflictType::RequesterApprover => {
301 format!(
302 "User {} approved their own request in transaction {}",
303 entry.header.created_by, entry.header.document_id
304 )
305 }
306 SodConflictType::ReconcilerPoster => {
307 format!(
308 "User {} both reconciled and posted adjustments in {}",
309 entry.header.created_by, entry.header.document_id
310 )
311 }
312 SodConflictType::MasterDataMaintainer => {
313 format!(
314 "User {} maintains master data and processed payment {}",
315 entry.header.created_by, entry.header.document_id
316 )
317 }
318 SodConflictType::PaymentReleaser => {
319 format!(
320 "User {} both created and released payment {}",
321 entry.header.created_by, entry.header.document_id
322 )
323 }
324 SodConflictType::JournalEntryPoster => {
325 format!(
326 "User {} posted to sensitive accounts without review in {}",
327 entry.header.created_by, entry.header.document_id
328 )
329 }
330 SodConflictType::SystemAccessConflict => {
331 format!(
332 "User {} has conflicting system access roles for {}",
333 entry.header.created_by, entry.header.document_id
334 )
335 }
336 };
337
338 let severity = self.determine_violation_severity(entry, conflict_type);
340
341 SodViolation::with_timestamp(
342 conflict_type,
343 &entry.header.created_by,
344 description,
345 severity,
346 entry.header.created_at,
347 )
348 }
349
350 fn determine_violation_severity(
352 &self,
353 entry: &JournalEntry,
354 conflict_type: SodConflictType,
355 ) -> RiskLevel {
356 let amount = entry.total_debit();
357
358 let base_severity = match conflict_type {
360 SodConflictType::PaymentReleaser | SodConflictType::RequesterApprover => {
361 RiskLevel::Critical
362 }
363 SodConflictType::PreparerApprover | SodConflictType::MasterDataMaintainer => {
364 RiskLevel::High
365 }
366 SodConflictType::ReconcilerPoster | SodConflictType::JournalEntryPoster => {
367 RiskLevel::Medium
368 }
369 SodConflictType::SystemAccessConflict => RiskLevel::Low,
370 };
371
372 if amount >= Decimal::from(100000) {
374 match base_severity {
375 RiskLevel::Low => RiskLevel::Medium,
376 RiskLevel::Medium => RiskLevel::High,
377 RiskLevel::High | RiskLevel::Critical => RiskLevel::Critical,
378 }
379 } else {
380 base_severity
381 }
382 }
383
384 pub fn conflict_pairs(&self) -> &[SodConflictPair] {
386 &self.conflict_pairs
387 }
388
389 pub fn reset(&mut self) {
391 self.rng = seeded_rng(self.seed, 0);
392 }
393}
394
395pub trait ControlApplicationExt {
397 fn apply_controls(&mut self, generator: &mut ControlGenerator, coa: &ChartOfAccounts);
399}
400
401impl ControlApplicationExt for JournalEntry {
402 fn apply_controls(&mut self, generator: &mut ControlGenerator, coa: &ChartOfAccounts) {
403 generator.apply_controls(self, coa);
404 }
405}
406
407#[cfg(test)]
408#[allow(clippy::unwrap_used)]
409mod tests {
410 use super::*;
411 use chrono::NaiveDate;
412 use datasynth_core::models::{JournalEntryHeader, JournalEntryLine};
413 use uuid::Uuid;
414
415 fn create_test_entry() -> JournalEntry {
416 let mut header = JournalEntryHeader::new(
417 "1000".to_string(),
418 NaiveDate::from_ymd_opt(2024, 6, 15).unwrap(),
419 );
420 header.business_process = Some(BusinessProcess::R2R);
421 header.created_by = "USER001".to_string();
422
423 let mut entry = JournalEntry::new(header);
424 entry.add_line(JournalEntryLine::debit(
425 Uuid::new_v4(),
426 1,
427 "100000".to_string(),
428 Decimal::from(50000),
429 ));
430 entry.add_line(JournalEntryLine::credit(
431 Uuid::new_v4(),
432 2,
433 "200000".to_string(),
434 Decimal::from(50000),
435 ));
436
437 entry
438 }
439
440 fn create_test_coa() -> ChartOfAccounts {
441 ChartOfAccounts::new(
442 "TEST".to_string(),
443 "Test CoA".to_string(),
444 "US".to_string(),
445 datasynth_core::IndustrySector::Manufacturing,
446 datasynth_core::CoAComplexity::Small,
447 )
448 }
449
450 #[test]
451 fn test_control_generator_creation() {
452 let gen = ControlGenerator::new(42);
453 assert!(!gen.controls().is_empty());
454 }
455
456 #[test]
457 fn test_apply_controls() {
458 let mut gen = ControlGenerator::new(42);
459 let mut entry = create_test_entry();
460 let coa = create_test_coa();
461
462 gen.apply_controls(&mut entry, &coa);
463
464 assert!(matches!(
466 entry.header.control_status,
467 ControlStatus::Effective | ControlStatus::Exception | ControlStatus::NotTested
468 ));
469 }
470
471 #[test]
472 fn test_sox_relevance_high_amount() {
473 let config = ControlGeneratorConfig {
474 sox_materiality_threshold: Decimal::from(10000),
475 ..Default::default()
476 };
477 let mut gen = ControlGenerator::with_config(42, config);
478 let mut entry = create_test_entry();
479 let coa = create_test_coa();
480
481 gen.apply_controls(&mut entry, &coa);
482
483 assert!(entry.header.sox_relevant);
485 }
486
487 #[test]
488 fn test_sod_checker() {
489 let mut checker = SodChecker::new(42, 1.0); let entry = create_test_entry();
491
492 let (has_violation, conflict_type) = checker.check_entry(&entry);
493
494 assert!(has_violation);
495 assert!(conflict_type.is_some());
496 }
497
498 #[test]
499 fn test_sod_violation_record() {
500 let checker = SodChecker::new(42, 1.0);
501 let entry = create_test_entry();
502
503 let violation = checker.create_violation_record(&entry, SodConflictType::PreparerApprover);
504
505 assert_eq!(violation.actor_id, "USER001");
506 assert_eq!(violation.conflict_type, SodConflictType::PreparerApprover);
507 }
508
509 #[test]
510 fn test_deterministic_generation() {
511 let mut gen1 = ControlGenerator::new(42);
512 let mut gen2 = ControlGenerator::new(42);
513
514 let mut entry1 = create_test_entry();
515 let mut entry2 = create_test_entry();
516 let coa = create_test_coa();
517
518 gen1.apply_controls(&mut entry1, &coa);
519 gen2.apply_controls(&mut entry2, &coa);
520
521 assert_eq!(entry1.header.control_status, entry2.header.control_status);
522 assert_eq!(entry1.header.sod_violation, entry2.header.sod_violation);
523 }
524
525 #[test]
526 fn test_reset() {
527 let mut gen = ControlGenerator::new(42);
528 let coa = create_test_coa();
529
530 for _ in 0..10 {
532 let mut entry = create_test_entry();
533 gen.apply_controls(&mut entry, &coa);
534 }
535
536 gen.reset();
538
539 let mut entry1 = create_test_entry();
541 gen.apply_controls(&mut entry1, &coa);
542
543 gen.reset();
544
545 let mut entry2 = create_test_entry();
546 gen.apply_controls(&mut entry2, &coa);
547
548 assert_eq!(entry1.header.control_status, entry2.header.control_status);
549 }
550}