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 None => vec![
261 SodConflictType::PreparerApprover,
262 SodConflictType::SystemAccessConflict,
263 ],
264 };
265
266 likely_conflicts
268 .choose(&mut self.rng)
269 .copied()
270 .unwrap_or(SodConflictType::PreparerApprover)
271 }
272
273 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 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 fn determine_violation_severity(
338 &self,
339 entry: &JournalEntry,
340 conflict_type: SodConflictType,
341 ) -> RiskLevel {
342 let amount = entry.total_debit();
343
344 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 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 pub fn conflict_pairs(&self) -> &[SodConflictPair] {
372 &self.conflict_pairs
373 }
374
375 pub fn reset(&mut self) {
377 self.rng = ChaCha8Rng::seed_from_u64(self.seed);
378 }
379}
380
381pub trait ControlApplicationExt {
383 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 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 assert!(entry.header.sox_relevant);
471 }
472
473 #[test]
474 fn test_sod_checker() {
475 let mut checker = SodChecker::new(42, 1.0); 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 for _ in 0..10 {
518 let mut entry = create_test_entry();
519 gen.apply_controls(&mut entry, &coa);
520 }
521
522 gen.reset();
524
525 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}