1use 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#[derive(Debug, Clone)]
17pub struct ControlGeneratorConfig {
18 pub exception_rate: f64,
20 pub sod_violation_rate: f64,
22 pub enable_sox_marking: bool,
24 pub sox_materiality_threshold: Decimal,
26}
27
28impl Default for ControlGeneratorConfig {
29 fn default() -> Self {
30 Self {
31 exception_rate: 0.02, sod_violation_rate: 0.01, enable_sox_marking: true,
34 sox_materiality_threshold: Decimal::from(10000),
35 }
36 }
37}
38
39pub 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 pub fn new(seed: u64) -> Self {
52 Self::with_config(seed, ControlGeneratorConfig::default())
53 }
54
55 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 pub fn apply_controls(&mut self, entry: &mut JournalEntry, coa: &ChartOfAccounts) {
75 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 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 all_control_ids.sort();
101 all_control_ids.dedup();
102 entry.header.control_ids = all_control_ids;
103
104 entry.header.sox_relevant = self.determine_sox_relevance(entry);
106
107 entry.header.control_status = self.determine_control_status(entry);
109
110 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 fn determine_sox_relevance(&self, entry: &JournalEntry) -> bool {
118 if !self.config.enable_sox_marking {
119 return false;
120 }
121
122 let total_amount = entry.total_debit();
125 if total_amount >= self.config.sox_materiality_threshold {
126 return true;
127 }
128
129 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 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 fn determine_control_status(&mut self, entry: &JournalEntry) -> ControlStatus {
152 if entry.header.control_ids.is_empty() {
154 return ControlStatus::NotTested;
155 }
156
157 if self.rng.gen::<f64>() < self.config.exception_rate {
159 ControlStatus::Exception
160 } else {
161 ControlStatus::Effective
162 }
163 }
164
165 pub fn controls(&self) -> &[InternalControl] {
167 &self.controls
168 }
169
170 pub fn registry(&self) -> &ControlMappingRegistry {
172 &self.registry
173 }
174
175 pub fn reset(&mut self) {
177 self.rng = ChaCha8Rng::seed_from_u64(self.seed);
178 self.sod_checker.reset();
179 }
180}
181
182pub struct SodChecker {
184 rng: ChaCha8Rng,
185 seed: u64,
186 violation_rate: f64,
187 conflict_pairs: Vec<SodConflictPair>,
188}
189
190impl SodChecker {
191 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 pub fn check_entry(&mut self, entry: &JournalEntry) -> (bool, Option<SodConflictType>) {
205 if self.rng.gen::<f64>() >= self.violation_rate {
207 return (false, None);
208 }
209
210 let conflict_type = self.select_conflict_type(entry);
212
213 (true, Some(conflict_type))
214 }
215
216 fn select_conflict_type(&mut self, entry: &JournalEntry) -> SodConflictType {
218 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 Some(BusinessProcess::ProjectAccounting) => vec![
261 SodConflictType::PreparerApprover,
262 SodConflictType::RequesterApprover,
263 ],
264 Some(BusinessProcess::Esg) => vec![SodConflictType::PreparerApprover],
265 None => vec![
266 SodConflictType::PreparerApprover,
267 SodConflictType::SystemAccessConflict,
268 ],
269 };
270
271 likely_conflicts
273 .choose(&mut self.rng)
274 .copied()
275 .unwrap_or(SodConflictType::PreparerApprover)
276 }
277
278 pub fn create_violation_record(
280 &self,
281 entry: &JournalEntry,
282 conflict_type: SodConflictType,
283 ) -> SodViolation {
284 let description = match conflict_type {
285 SodConflictType::PreparerApprover => {
286 format!(
287 "User {} both prepared and approved journal entry {}",
288 entry.header.created_by, entry.header.document_id
289 )
290 }
291 SodConflictType::RequesterApprover => {
292 format!(
293 "User {} approved their own request in transaction {}",
294 entry.header.created_by, entry.header.document_id
295 )
296 }
297 SodConflictType::ReconcilerPoster => {
298 format!(
299 "User {} both reconciled and posted adjustments in {}",
300 entry.header.created_by, entry.header.document_id
301 )
302 }
303 SodConflictType::MasterDataMaintainer => {
304 format!(
305 "User {} maintains master data and processed payment {}",
306 entry.header.created_by, entry.header.document_id
307 )
308 }
309 SodConflictType::PaymentReleaser => {
310 format!(
311 "User {} both created and released payment {}",
312 entry.header.created_by, entry.header.document_id
313 )
314 }
315 SodConflictType::JournalEntryPoster => {
316 format!(
317 "User {} posted to sensitive accounts without review in {}",
318 entry.header.created_by, entry.header.document_id
319 )
320 }
321 SodConflictType::SystemAccessConflict => {
322 format!(
323 "User {} has conflicting system access roles for {}",
324 entry.header.created_by, entry.header.document_id
325 )
326 }
327 };
328
329 let severity = self.determine_violation_severity(entry, conflict_type);
331
332 SodViolation::with_timestamp(
333 conflict_type,
334 &entry.header.created_by,
335 description,
336 severity,
337 entry.header.created_at,
338 )
339 }
340
341 fn determine_violation_severity(
343 &self,
344 entry: &JournalEntry,
345 conflict_type: SodConflictType,
346 ) -> RiskLevel {
347 let amount = entry.total_debit();
348
349 let base_severity = match conflict_type {
351 SodConflictType::PaymentReleaser | SodConflictType::RequesterApprover => {
352 RiskLevel::Critical
353 }
354 SodConflictType::PreparerApprover | SodConflictType::MasterDataMaintainer => {
355 RiskLevel::High
356 }
357 SodConflictType::ReconcilerPoster | SodConflictType::JournalEntryPoster => {
358 RiskLevel::Medium
359 }
360 SodConflictType::SystemAccessConflict => RiskLevel::Low,
361 };
362
363 if amount >= Decimal::from(100000) {
365 match base_severity {
366 RiskLevel::Low => RiskLevel::Medium,
367 RiskLevel::Medium => RiskLevel::High,
368 RiskLevel::High | RiskLevel::Critical => RiskLevel::Critical,
369 }
370 } else {
371 base_severity
372 }
373 }
374
375 pub fn conflict_pairs(&self) -> &[SodConflictPair] {
377 &self.conflict_pairs
378 }
379
380 pub fn reset(&mut self) {
382 self.rng = ChaCha8Rng::seed_from_u64(self.seed);
383 }
384}
385
386pub trait ControlApplicationExt {
388 fn apply_controls(&mut self, generator: &mut ControlGenerator, coa: &ChartOfAccounts);
390}
391
392impl ControlApplicationExt for JournalEntry {
393 fn apply_controls(&mut self, generator: &mut ControlGenerator, coa: &ChartOfAccounts) {
394 generator.apply_controls(self, coa);
395 }
396}
397
398#[cfg(test)]
399#[allow(clippy::unwrap_used)]
400mod tests {
401 use super::*;
402 use chrono::NaiveDate;
403 use datasynth_core::models::{JournalEntryHeader, JournalEntryLine};
404 use uuid::Uuid;
405
406 fn create_test_entry() -> JournalEntry {
407 let mut header = JournalEntryHeader::new(
408 "1000".to_string(),
409 NaiveDate::from_ymd_opt(2024, 6, 15).unwrap(),
410 );
411 header.business_process = Some(BusinessProcess::R2R);
412 header.created_by = "USER001".to_string();
413
414 let mut entry = JournalEntry::new(header);
415 entry.add_line(JournalEntryLine::debit(
416 Uuid::new_v4(),
417 1,
418 "100000".to_string(),
419 Decimal::from(50000),
420 ));
421 entry.add_line(JournalEntryLine::credit(
422 Uuid::new_v4(),
423 2,
424 "200000".to_string(),
425 Decimal::from(50000),
426 ));
427
428 entry
429 }
430
431 fn create_test_coa() -> ChartOfAccounts {
432 ChartOfAccounts::new(
433 "TEST".to_string(),
434 "Test CoA".to_string(),
435 "US".to_string(),
436 datasynth_core::IndustrySector::Manufacturing,
437 datasynth_core::CoAComplexity::Small,
438 )
439 }
440
441 #[test]
442 fn test_control_generator_creation() {
443 let gen = ControlGenerator::new(42);
444 assert!(!gen.controls().is_empty());
445 }
446
447 #[test]
448 fn test_apply_controls() {
449 let mut gen = ControlGenerator::new(42);
450 let mut entry = create_test_entry();
451 let coa = create_test_coa();
452
453 gen.apply_controls(&mut entry, &coa);
454
455 assert!(matches!(
457 entry.header.control_status,
458 ControlStatus::Effective | ControlStatus::Exception | ControlStatus::NotTested
459 ));
460 }
461
462 #[test]
463 fn test_sox_relevance_high_amount() {
464 let config = ControlGeneratorConfig {
465 sox_materiality_threshold: Decimal::from(10000),
466 ..Default::default()
467 };
468 let mut gen = ControlGenerator::with_config(42, config);
469 let mut entry = create_test_entry();
470 let coa = create_test_coa();
471
472 gen.apply_controls(&mut entry, &coa);
473
474 assert!(entry.header.sox_relevant);
476 }
477
478 #[test]
479 fn test_sod_checker() {
480 let mut checker = SodChecker::new(42, 1.0); let entry = create_test_entry();
482
483 let (has_violation, conflict_type) = checker.check_entry(&entry);
484
485 assert!(has_violation);
486 assert!(conflict_type.is_some());
487 }
488
489 #[test]
490 fn test_sod_violation_record() {
491 let checker = SodChecker::new(42, 1.0);
492 let entry = create_test_entry();
493
494 let violation = checker.create_violation_record(&entry, SodConflictType::PreparerApprover);
495
496 assert_eq!(violation.actor_id, "USER001");
497 assert_eq!(violation.conflict_type, SodConflictType::PreparerApprover);
498 }
499
500 #[test]
501 fn test_deterministic_generation() {
502 let mut gen1 = ControlGenerator::new(42);
503 let mut gen2 = ControlGenerator::new(42);
504
505 let mut entry1 = create_test_entry();
506 let mut entry2 = create_test_entry();
507 let coa = create_test_coa();
508
509 gen1.apply_controls(&mut entry1, &coa);
510 gen2.apply_controls(&mut entry2, &coa);
511
512 assert_eq!(entry1.header.control_status, entry2.header.control_status);
513 assert_eq!(entry1.header.sod_violation, entry2.header.sod_violation);
514 }
515
516 #[test]
517 fn test_reset() {
518 let mut gen = ControlGenerator::new(42);
519 let coa = create_test_coa();
520
521 for _ in 0..10 {
523 let mut entry = create_test_entry();
524 gen.apply_controls(&mut entry, &coa);
525 }
526
527 gen.reset();
529
530 let mut entry1 = create_test_entry();
532 gen.apply_controls(&mut entry1, &coa);
533
534 gen.reset();
535
536 let mut entry2 = create_test_entry();
537 gen.apply_controls(&mut entry2, &coa);
538
539 assert_eq!(entry1.header.control_status, entry2.header.control_status);
540 }
541}