1use std::collections::HashMap;
17
18use datasynth_core::models::audit::risk_assessment_cra::{
19 AuditAssertion, CombinedRiskAssessment, RiskRating,
20};
21use datasynth_core::utils::seeded_rng;
22use rand::Rng;
23use rand_chacha::ChaCha8Rng;
24use tracing::{debug, info};
25
26#[derive(Debug, Clone)]
32struct AccountAreaSpec {
33 name: &'static str,
35 default_ir: RiskRating,
37 assertions: &'static [AuditAssertion],
39 always_significant_occurrence: bool,
41}
42
43static ACCOUNT_AREAS: &[AccountAreaSpec] = &[
45 AccountAreaSpec {
46 name: "Revenue",
47 default_ir: RiskRating::High,
48 assertions: &[
49 AuditAssertion::Occurrence,
50 AuditAssertion::Cutoff,
51 AuditAssertion::Accuracy,
52 ],
53 always_significant_occurrence: true,
54 },
55 AccountAreaSpec {
56 name: "Cost of Sales",
57 default_ir: RiskRating::Medium,
58 assertions: &[AuditAssertion::Occurrence, AuditAssertion::Accuracy],
59 always_significant_occurrence: false,
60 },
61 AccountAreaSpec {
62 name: "Trade Receivables",
63 default_ir: RiskRating::High,
64 assertions: &[
65 AuditAssertion::Existence,
66 AuditAssertion::ValuationAndAllocation,
67 ],
68 always_significant_occurrence: false,
69 },
70 AccountAreaSpec {
71 name: "Inventory",
72 default_ir: RiskRating::High,
73 assertions: &[
74 AuditAssertion::Existence,
75 AuditAssertion::ValuationAndAllocation,
76 ],
77 always_significant_occurrence: false,
78 },
79 AccountAreaSpec {
80 name: "Fixed Assets",
81 default_ir: RiskRating::Medium,
82 assertions: &[
83 AuditAssertion::Existence,
84 AuditAssertion::ValuationAndAllocation,
85 ],
86 always_significant_occurrence: false,
87 },
88 AccountAreaSpec {
89 name: "Trade Payables",
90 default_ir: RiskRating::Low,
91 assertions: &[
92 AuditAssertion::CompletenessBalance,
93 AuditAssertion::Accuracy,
94 ],
95 always_significant_occurrence: false,
96 },
97 AccountAreaSpec {
98 name: "Accruals",
99 default_ir: RiskRating::Medium,
100 assertions: &[
101 AuditAssertion::CompletenessBalance,
102 AuditAssertion::ValuationAndAllocation,
103 ],
104 always_significant_occurrence: false,
105 },
106 AccountAreaSpec {
107 name: "Cash",
108 default_ir: RiskRating::Low,
109 assertions: &[
110 AuditAssertion::Existence,
111 AuditAssertion::CompletenessBalance,
112 ],
113 always_significant_occurrence: false,
114 },
115 AccountAreaSpec {
116 name: "Tax",
117 default_ir: RiskRating::Medium,
118 assertions: &[
119 AuditAssertion::Accuracy,
120 AuditAssertion::ValuationAndAllocation,
121 ],
122 always_significant_occurrence: false,
123 },
124 AccountAreaSpec {
125 name: "Equity",
126 default_ir: RiskRating::Low,
127 assertions: &[
128 AuditAssertion::Existence,
129 AuditAssertion::PresentationAndDisclosure,
130 ],
131 always_significant_occurrence: false,
132 },
133 AccountAreaSpec {
134 name: "Provisions",
135 default_ir: RiskRating::High,
136 assertions: &[
137 AuditAssertion::CompletenessBalance,
138 AuditAssertion::ValuationAndAllocation,
139 ],
140 always_significant_occurrence: false,
141 },
142 AccountAreaSpec {
143 name: "Related Parties",
144 default_ir: RiskRating::High,
145 assertions: &[AuditAssertion::Occurrence, AuditAssertion::Completeness],
146 always_significant_occurrence: true,
147 },
148];
149
150fn risk_factors_for(area: &str, assertion: AuditAssertion) -> Vec<String> {
155 let mut factors: Vec<String> = Vec::new();
156
157 match area {
158 "Revenue" => {
159 factors.push(
160 "Revenue recognition involves judgment in identifying performance obligations"
161 .into(),
162 );
163 if assertion == AuditAssertion::Occurrence {
164 factors.push(
165 "Presumed fraud risk per ISA 240 — incentive to overstate revenue".into(),
166 );
167 }
168 if assertion == AuditAssertion::Cutoff {
169 factors.push(
170 "Cut-off risk heightened near period-end due to shipping arrangements".into(),
171 );
172 }
173 }
174 "Trade Receivables" => {
175 factors
176 .push("Collectability assessment involves significant management judgment".into());
177 if assertion == AuditAssertion::ValuationAndAllocation {
178 factors.push(
179 "ECL provisioning methodology may be complex under IFRS 9 / ASC 310".into(),
180 );
181 }
182 }
183 "Inventory" => {
184 factors.push("Physical quantities require verification through observation".into());
185 if assertion == AuditAssertion::ValuationAndAllocation {
186 factors
187 .push("NRV impairment requires management's forward-looking estimates".into());
188 }
189 }
190 "Fixed Assets" => {
191 factors
192 .push("Capitalisation vs. expensing judgments affect reported asset values".into());
193 if assertion == AuditAssertion::ValuationAndAllocation {
194 factors
195 .push("Depreciation method and useful life estimates involve judgment".into());
196 }
197 }
198 "Provisions" => {
199 factors.push("Provisions are inherently uncertain and require estimation".into());
200 factors.push("Completeness depends on management identifying all obligations".into());
201 }
202 "Related Parties" => {
203 factors.push("Related party transactions may not be conducted at arm's length".into());
204 factors.push(
205 "Completeness depends on management disclosing all related party relationships"
206 .into(),
207 );
208 }
209 "Accruals" => {
210 factors.push(
211 "Accrual completeness relies on management's identification of liabilities".into(),
212 );
213 }
214 "Tax" => {
215 factors
216 .push("Tax provisions involve complex legislation and management judgment".into());
217 factors.push(
218 "Deferred tax calculation depends on timing difference identification".into(),
219 );
220 }
221 _ => {
222 factors.push(format!("{area} — standard inherent risk factors apply"));
223 }
224 }
225
226 factors
227}
228
229fn account_area_to_gl_prefixes(area: &str) -> Vec<&'static str> {
237 match area {
238 "Revenue" => vec!["4"],
239 "Cost of Sales" => vec!["5", "6"],
240 "Trade Receivables" => vec!["11"],
241 "Inventory" => vec!["12", "13"],
242 "Fixed Assets" => vec!["14", "15", "16"],
243 "Trade Payables" => vec!["20"],
244 "Accruals" => vec!["21", "22"],
245 "Cash" => vec!["10"],
246 "Tax" => vec!["17", "25"],
247 "Equity" => vec!["3"],
248 "Provisions" => vec!["26"],
249 "Related Parties" => vec![], _ => vec![],
251 }
252}
253
254fn bump_risk_up(rating: RiskRating) -> RiskRating {
257 match rating {
258 RiskRating::Low => RiskRating::Medium,
259 RiskRating::Medium => RiskRating::High,
260 RiskRating::High => RiskRating::High,
261 }
262}
263
264fn bump_risk_down(rating: RiskRating) -> RiskRating {
267 match rating {
268 RiskRating::Low => RiskRating::Low,
269 RiskRating::Medium => RiskRating::Low,
270 RiskRating::High => RiskRating::Medium,
271 }
272}
273
274#[derive(Debug, Clone)]
280pub struct CraGeneratorConfig {
281 pub effective_controls_probability: f64,
283 pub partial_controls_probability: f64,
285 }
287
288impl Default for CraGeneratorConfig {
289 fn default() -> Self {
290 Self {
291 effective_controls_probability: 0.40,
292 partial_controls_probability: 0.45,
293 }
294 }
295}
296
297pub struct CraGenerator {
303 rng: ChaCha8Rng,
304 config: CraGeneratorConfig,
305}
306
307impl CraGenerator {
308 pub fn new(seed: u64) -> Self {
310 Self {
311 rng: seeded_rng(seed, 0x315), config: CraGeneratorConfig::default(),
313 }
314 }
315
316 pub fn with_config(seed: u64, config: CraGeneratorConfig) -> Self {
318 Self {
319 rng: seeded_rng(seed, 0x315),
320 config,
321 }
322 }
323
324 pub fn generate_for_entity(
332 &mut self,
333 entity_code: &str,
334 control_effectiveness: Option<&std::collections::HashMap<String, RiskRating>>,
335 ) -> Vec<CombinedRiskAssessment> {
336 info!("Generating CRAs for entity {}", entity_code);
337 let mut results = Vec::new();
338
339 for spec in ACCOUNT_AREAS {
340 for &assertion in spec.assertions {
341 let ir = self.jitter_inherent_risk(spec.default_ir);
342 let cr = self.assess_control_risk(spec.name, control_effectiveness);
343
344 let is_significant = self.is_significant_risk(spec, assertion, ir, cr);
346
347 debug!(
348 "CRA: {} {:?} -> IR={:?} CR={:?} significant={}",
349 spec.name, assertion, ir, cr, is_significant
350 );
351
352 let risk_factors = risk_factors_for(spec.name, assertion);
353
354 let cra = CombinedRiskAssessment::new(
355 entity_code,
356 spec.name,
357 assertion,
358 ir,
359 cr,
360 is_significant,
361 risk_factors,
362 );
363
364 results.push(cra);
365 }
366 }
367
368 info!(
369 "Generated {} CRAs for entity {}",
370 results.len(),
371 entity_code
372 );
373 results
374 }
375
376 pub fn generate_for_entity_with_balances(
389 &mut self,
390 entity_code: &str,
391 control_effectiveness: Option<&HashMap<String, RiskRating>>,
392 account_balances: &HashMap<String, f64>,
393 ) -> Vec<CombinedRiskAssessment> {
394 info!(
395 "Generating balance-weighted CRAs for entity {} ({} accounts)",
396 entity_code,
397 account_balances.len()
398 );
399
400 let total_balance: f64 = account_balances.values().map(|b| b.abs()).sum();
401 let mut results = Vec::new();
402
403 for spec in ACCOUNT_AREAS {
404 let prefixes = account_area_to_gl_prefixes(spec.name);
406 let area_balance: f64 = if prefixes.is_empty() {
407 0.0
408 } else {
409 account_balances
410 .iter()
411 .filter(|(code, _)| prefixes.iter().any(|p| code.starts_with(p)))
412 .map(|(_, bal)| bal.abs())
413 .sum()
414 };
415 let proportion = if total_balance > 0.0 {
416 area_balance / total_balance
417 } else {
418 0.0
419 };
420
421 for &assertion in spec.assertions {
422 let mut ir = self.jitter_inherent_risk(spec.default_ir);
423
424 if proportion > 0.15 {
426 ir = bump_risk_up(ir);
427 debug!(
428 "CRA balance bump-up: {} proportion={:.2} -> IR={:?}",
429 spec.name, proportion, ir
430 );
431 } else if proportion > 0.0 && proportion < 0.02 {
432 ir = bump_risk_down(ir);
433 debug!(
434 "CRA balance bump-down: {} proportion={:.2} -> IR={:?}",
435 spec.name, proportion, ir
436 );
437 }
438
439 let cr = self.assess_control_risk(spec.name, control_effectiveness);
440 let is_significant = self.is_significant_risk(spec, assertion, ir, cr);
441
442 debug!(
443 "CRA: {} {:?} -> IR={:?} CR={:?} significant={} (proportion={:.3})",
444 spec.name, assertion, ir, cr, is_significant, proportion
445 );
446
447 let risk_factors = risk_factors_for(spec.name, assertion);
448
449 let cra = CombinedRiskAssessment::new(
450 entity_code,
451 spec.name,
452 assertion,
453 ir,
454 cr,
455 is_significant,
456 risk_factors,
457 );
458
459 results.push(cra);
460 }
461 }
462
463 info!(
464 "Generated {} balance-weighted CRAs for entity {}",
465 results.len(),
466 entity_code
467 );
468 results
469 }
470
471 fn jitter_inherent_risk(&mut self, default: RiskRating) -> RiskRating {
477 let roll: f64 = self.rng.random();
478 match default {
479 RiskRating::Low => {
480 if roll > 0.85 {
481 RiskRating::Medium
482 } else {
483 RiskRating::Low
484 }
485 }
486 RiskRating::Medium => {
487 if roll < 0.10 {
488 RiskRating::Low
489 } else if roll > 0.85 {
490 RiskRating::High
491 } else {
492 RiskRating::Medium
493 }
494 }
495 RiskRating::High => {
496 if roll > 0.85 {
497 RiskRating::Medium
498 } else {
499 RiskRating::High
500 }
501 }
502 }
503 }
504
505 fn assess_control_risk(
510 &mut self,
511 area: &str,
512 overrides: Option<&std::collections::HashMap<String, RiskRating>>,
513 ) -> RiskRating {
514 if let Some(map) = overrides {
515 if let Some(&cr) = map.get(area) {
516 return cr;
517 }
518 }
519 let roll: f64 = self.rng.random();
520 if roll < self.config.effective_controls_probability {
521 RiskRating::Low
522 } else if roll
523 < self.config.effective_controls_probability + self.config.partial_controls_probability
524 {
525 RiskRating::Medium
526 } else {
527 RiskRating::High
528 }
529 }
530
531 fn is_significant_risk(
533 &self,
534 spec: &AccountAreaSpec,
535 assertion: AuditAssertion,
536 ir: RiskRating,
537 _cr: RiskRating,
538 ) -> bool {
539 if spec.always_significant_occurrence && assertion == AuditAssertion::Occurrence {
541 return true;
542 }
543 if spec.name == "Inventory"
546 && assertion == AuditAssertion::Existence
547 && ir == RiskRating::High
548 {
549 return true;
550 }
551 if ir == RiskRating::High
553 && matches!(
554 spec.name,
555 "Provisions" | "Accruals" | "Trade Receivables" | "Inventory"
556 )
557 && assertion == AuditAssertion::ValuationAndAllocation
558 {
559 return true;
560 }
561 false
562 }
563}
564
565#[cfg(test)]
570#[allow(clippy::unwrap_used)]
571mod tests {
572 use super::*;
573
574 #[test]
575 fn generates_cras_for_entity() {
576 let mut gen = CraGenerator::new(42);
577 let cras = gen.generate_for_entity("C001", None);
578 assert!(!cras.is_empty());
580 assert!(cras.len() >= 12);
581 }
582
583 #[test]
584 fn revenue_occurrence_always_significant() {
585 let mut gen = CraGenerator::new(42);
586 let cras = gen.generate_for_entity("C001", None);
587 let rev_occurrence = cras
588 .iter()
589 .find(|c| c.account_area == "Revenue" && c.assertion == AuditAssertion::Occurrence);
590 assert!(
591 rev_occurrence.is_some(),
592 "Revenue/Occurrence CRA should exist"
593 );
594 assert!(
595 rev_occurrence.unwrap().significant_risk,
596 "Revenue/Occurrence must always be significant per ISA 240"
597 );
598 }
599
600 #[test]
601 fn related_party_occurrence_is_significant() {
602 let mut gen = CraGenerator::new(42);
603 let cras = gen.generate_for_entity("C001", None);
604 let rp = cras.iter().find(|c| {
605 c.account_area == "Related Parties" && c.assertion == AuditAssertion::Occurrence
606 });
607 assert!(rp.is_some());
608 assert!(rp.unwrap().significant_risk);
609 }
610
611 #[test]
612 fn cra_ids_are_unique() {
613 let mut gen = CraGenerator::new(42);
614 let cras = gen.generate_for_entity("C001", None);
615 let ids: std::collections::HashSet<&str> = cras.iter().map(|c| c.id.as_str()).collect();
616 assert_eq!(ids.len(), cras.len(), "CRA IDs should be unique");
617 }
618
619 #[test]
620 fn control_override_respected() {
621 let mut overrides = std::collections::HashMap::new();
622 overrides.insert("Cash".into(), RiskRating::Low);
623 let mut gen = CraGenerator::new(42);
624 let cras = gen.generate_for_entity("C001", Some(&overrides));
625 let cash_cras: Vec<_> = cras.iter().filter(|c| c.account_area == "Cash").collect();
626 for c in &cash_cras {
627 assert_eq!(
628 c.control_risk,
629 RiskRating::Low,
630 "Control override should apply"
631 );
632 }
633 }
634
635 #[test]
636 fn balance_weighted_bumps_high_proportion_areas() {
637 let balances = HashMap::from([
640 ("4000".into(), 8_000_000.0), ("1100".into(), 500_000.0), ("1010".into(), 50_000.0), ]);
644
645 let mut gen = CraGenerator::new(42);
646 let cras = gen.generate_for_entity_with_balances("C001", None, &balances);
647
648 assert!(!cras.is_empty());
650 assert!(cras.len() >= 12);
651
652 let rev = cras
654 .iter()
655 .filter(|c| c.account_area == "Revenue")
656 .collect::<Vec<_>>();
657 for c in &rev {
658 assert_eq!(
659 c.inherent_risk,
660 RiskRating::High,
661 "Revenue with huge balance should have High IR"
662 );
663 }
664
665 let cash = cras
668 .iter()
669 .filter(|c| c.account_area == "Cash")
670 .collect::<Vec<_>>();
671 for c in &cash {
672 assert_eq!(
673 c.inherent_risk,
674 RiskRating::Low,
675 "Cash with tiny balance should have Low IR"
676 );
677 }
678 }
679
680 #[test]
681 fn balance_weighted_same_count_as_unweighted() {
682 let balances = HashMap::from([("4000".into(), 5_000_000.0), ("1100".into(), 1_250_000.0)]);
683 let mut gen1 = CraGenerator::new(99);
684 let cras_unweighted = gen1.generate_for_entity("C001", None);
685
686 let mut gen2 = CraGenerator::new(99);
687 let cras_weighted = gen2.generate_for_entity_with_balances("C001", None, &balances);
688
689 assert_eq!(
690 cras_unweighted.len(),
691 cras_weighted.len(),
692 "Weighted and unweighted should produce the same number of CRAs"
693 );
694 }
695
696 #[test]
697 fn balance_weighted_empty_balances_same_as_unweighted() {
698 let empty: HashMap<String, f64> = HashMap::new();
699 let mut gen1 = CraGenerator::new(55);
700 let cras_unweighted = gen1.generate_for_entity("C001", None);
701
702 let mut gen2 = CraGenerator::new(55);
703 let cras_weighted = gen2.generate_for_entity_with_balances("C001", None, &empty);
704
705 assert_eq!(cras_unweighted.len(), cras_weighted.len());
708 for (a, b) in cras_unweighted.iter().zip(cras_weighted.iter()) {
709 assert_eq!(a.account_area, b.account_area);
710 assert_eq!(a.assertion, b.assertion);
711 assert_eq!(
712 a.inherent_risk, b.inherent_risk,
713 "With empty balances, IR should match unweighted for {}//{:?}",
714 a.account_area, a.assertion
715 );
716 }
717 }
718}