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::Treasury) | Some(BusinessProcess::Tax) => vec![
244 SodConflictType::PreparerApprover,
245 SodConflictType::PaymentReleaser,
246 ],
247 None => vec![
248 SodConflictType::PreparerApprover,
249 SodConflictType::SystemAccessConflict,
250 ],
251 };
252
253 likely_conflicts
255 .choose(&mut self.rng)
256 .copied()
257 .unwrap_or(SodConflictType::PreparerApprover)
258 }
259
260 pub fn create_violation_record(
262 &self,
263 entry: &JournalEntry,
264 conflict_type: SodConflictType,
265 ) -> SodViolation {
266 let description = match conflict_type {
267 SodConflictType::PreparerApprover => {
268 format!(
269 "User {} both prepared and approved journal entry {}",
270 entry.header.created_by, entry.header.document_id
271 )
272 }
273 SodConflictType::RequesterApprover => {
274 format!(
275 "User {} approved their own request in transaction {}",
276 entry.header.created_by, entry.header.document_id
277 )
278 }
279 SodConflictType::ReconcilerPoster => {
280 format!(
281 "User {} both reconciled and posted adjustments in {}",
282 entry.header.created_by, entry.header.document_id
283 )
284 }
285 SodConflictType::MasterDataMaintainer => {
286 format!(
287 "User {} maintains master data and processed payment {}",
288 entry.header.created_by, entry.header.document_id
289 )
290 }
291 SodConflictType::PaymentReleaser => {
292 format!(
293 "User {} both created and released payment {}",
294 entry.header.created_by, entry.header.document_id
295 )
296 }
297 SodConflictType::JournalEntryPoster => {
298 format!(
299 "User {} posted to sensitive accounts without review in {}",
300 entry.header.created_by, entry.header.document_id
301 )
302 }
303 SodConflictType::SystemAccessConflict => {
304 format!(
305 "User {} has conflicting system access roles for {}",
306 entry.header.created_by, entry.header.document_id
307 )
308 }
309 };
310
311 let severity = self.determine_violation_severity(entry, conflict_type);
313
314 SodViolation::with_timestamp(
315 conflict_type,
316 &entry.header.created_by,
317 description,
318 severity,
319 entry.header.created_at,
320 )
321 }
322
323 fn determine_violation_severity(
325 &self,
326 entry: &JournalEntry,
327 conflict_type: SodConflictType,
328 ) -> RiskLevel {
329 let amount = entry.total_debit();
330
331 let base_severity = match conflict_type {
333 SodConflictType::PaymentReleaser | SodConflictType::RequesterApprover => {
334 RiskLevel::Critical
335 }
336 SodConflictType::PreparerApprover | SodConflictType::MasterDataMaintainer => {
337 RiskLevel::High
338 }
339 SodConflictType::ReconcilerPoster | SodConflictType::JournalEntryPoster => {
340 RiskLevel::Medium
341 }
342 SodConflictType::SystemAccessConflict => RiskLevel::Low,
343 };
344
345 if amount >= Decimal::from(100000) {
347 match base_severity {
348 RiskLevel::Low => RiskLevel::Medium,
349 RiskLevel::Medium => RiskLevel::High,
350 RiskLevel::High | RiskLevel::Critical => RiskLevel::Critical,
351 }
352 } else {
353 base_severity
354 }
355 }
356
357 pub fn conflict_pairs(&self) -> &[SodConflictPair] {
359 &self.conflict_pairs
360 }
361
362 pub fn reset(&mut self) {
364 self.rng = ChaCha8Rng::seed_from_u64(self.seed);
365 }
366}
367
368pub trait ControlApplicationExt {
370 fn apply_controls(&mut self, generator: &mut ControlGenerator, coa: &ChartOfAccounts);
372}
373
374impl ControlApplicationExt for JournalEntry {
375 fn apply_controls(&mut self, generator: &mut ControlGenerator, coa: &ChartOfAccounts) {
376 generator.apply_controls(self, coa);
377 }
378}
379
380#[cfg(test)]
381mod tests {
382 use super::*;
383 use chrono::NaiveDate;
384 use datasynth_core::models::{JournalEntryHeader, JournalEntryLine};
385 use uuid::Uuid;
386
387 fn create_test_entry() -> JournalEntry {
388 let mut header = JournalEntryHeader::new(
389 "1000".to_string(),
390 NaiveDate::from_ymd_opt(2024, 6, 15).unwrap(),
391 );
392 header.business_process = Some(BusinessProcess::R2R);
393 header.created_by = "USER001".to_string();
394
395 let mut entry = JournalEntry::new(header);
396 entry.add_line(JournalEntryLine::debit(
397 Uuid::new_v4(),
398 1,
399 "100000".to_string(),
400 Decimal::from(50000),
401 ));
402 entry.add_line(JournalEntryLine::credit(
403 Uuid::new_v4(),
404 2,
405 "200000".to_string(),
406 Decimal::from(50000),
407 ));
408
409 entry
410 }
411
412 fn create_test_coa() -> ChartOfAccounts {
413 ChartOfAccounts::new(
414 "TEST".to_string(),
415 "Test CoA".to_string(),
416 "US".to_string(),
417 datasynth_core::IndustrySector::Manufacturing,
418 datasynth_core::CoAComplexity::Small,
419 )
420 }
421
422 #[test]
423 fn test_control_generator_creation() {
424 let gen = ControlGenerator::new(42);
425 assert!(!gen.controls().is_empty());
426 }
427
428 #[test]
429 fn test_apply_controls() {
430 let mut gen = ControlGenerator::new(42);
431 let mut entry = create_test_entry();
432 let coa = create_test_coa();
433
434 gen.apply_controls(&mut entry, &coa);
435
436 assert!(matches!(
438 entry.header.control_status,
439 ControlStatus::Effective | ControlStatus::Exception | ControlStatus::NotTested
440 ));
441 }
442
443 #[test]
444 fn test_sox_relevance_high_amount() {
445 let config = ControlGeneratorConfig {
446 sox_materiality_threshold: Decimal::from(10000),
447 ..Default::default()
448 };
449 let mut gen = ControlGenerator::with_config(42, config);
450 let mut entry = create_test_entry();
451 let coa = create_test_coa();
452
453 gen.apply_controls(&mut entry, &coa);
454
455 assert!(entry.header.sox_relevant);
457 }
458
459 #[test]
460 fn test_sod_checker() {
461 let mut checker = SodChecker::new(42, 1.0); let entry = create_test_entry();
463
464 let (has_violation, conflict_type) = checker.check_entry(&entry);
465
466 assert!(has_violation);
467 assert!(conflict_type.is_some());
468 }
469
470 #[test]
471 fn test_sod_violation_record() {
472 let checker = SodChecker::new(42, 1.0);
473 let entry = create_test_entry();
474
475 let violation = checker.create_violation_record(&entry, SodConflictType::PreparerApprover);
476
477 assert_eq!(violation.actor_id, "USER001");
478 assert_eq!(violation.conflict_type, SodConflictType::PreparerApprover);
479 }
480
481 #[test]
482 fn test_deterministic_generation() {
483 let mut gen1 = ControlGenerator::new(42);
484 let mut gen2 = ControlGenerator::new(42);
485
486 let mut entry1 = create_test_entry();
487 let mut entry2 = create_test_entry();
488 let coa = create_test_coa();
489
490 gen1.apply_controls(&mut entry1, &coa);
491 gen2.apply_controls(&mut entry2, &coa);
492
493 assert_eq!(entry1.header.control_status, entry2.header.control_status);
494 assert_eq!(entry1.header.sod_violation, entry2.header.sod_violation);
495 }
496
497 #[test]
498 fn test_reset() {
499 let mut gen = ControlGenerator::new(42);
500 let coa = create_test_coa();
501
502 for _ in 0..10 {
504 let mut entry = create_test_entry();
505 gen.apply_controls(&mut entry, &coa);
506 }
507
508 gen.reset();
510
511 let mut entry1 = create_test_entry();
513 gen.apply_controls(&mut entry1, &coa);
514
515 gen.reset();
516
517 let mut entry2 = create_test_entry();
518 gen.apply_controls(&mut entry2, &coa);
519
520 assert_eq!(entry1.header.control_status, entry2.header.control_status);
521 }
522}