datasynth_generators/audit/
cra_generator.rs1use datasynth_core::models::audit::risk_assessment_cra::{
17 AuditAssertion, CombinedRiskAssessment, RiskRating,
18};
19use datasynth_core::utils::seeded_rng;
20use rand::Rng;
21use rand_chacha::ChaCha8Rng;
22use tracing::{debug, info};
23
24#[derive(Debug, Clone)]
30struct AccountAreaSpec {
31 name: &'static str,
33 default_ir: RiskRating,
35 assertions: &'static [AuditAssertion],
37 always_significant_occurrence: bool,
39}
40
41static ACCOUNT_AREAS: &[AccountAreaSpec] = &[
43 AccountAreaSpec {
44 name: "Revenue",
45 default_ir: RiskRating::High,
46 assertions: &[
47 AuditAssertion::Occurrence,
48 AuditAssertion::Cutoff,
49 AuditAssertion::Accuracy,
50 ],
51 always_significant_occurrence: true,
52 },
53 AccountAreaSpec {
54 name: "Cost of Sales",
55 default_ir: RiskRating::Medium,
56 assertions: &[AuditAssertion::Occurrence, AuditAssertion::Accuracy],
57 always_significant_occurrence: false,
58 },
59 AccountAreaSpec {
60 name: "Trade Receivables",
61 default_ir: RiskRating::High,
62 assertions: &[
63 AuditAssertion::Existence,
64 AuditAssertion::ValuationAndAllocation,
65 ],
66 always_significant_occurrence: false,
67 },
68 AccountAreaSpec {
69 name: "Inventory",
70 default_ir: RiskRating::High,
71 assertions: &[
72 AuditAssertion::Existence,
73 AuditAssertion::ValuationAndAllocation,
74 ],
75 always_significant_occurrence: false,
76 },
77 AccountAreaSpec {
78 name: "Fixed Assets",
79 default_ir: RiskRating::Medium,
80 assertions: &[
81 AuditAssertion::Existence,
82 AuditAssertion::ValuationAndAllocation,
83 ],
84 always_significant_occurrence: false,
85 },
86 AccountAreaSpec {
87 name: "Trade Payables",
88 default_ir: RiskRating::Low,
89 assertions: &[
90 AuditAssertion::CompletenessBalance,
91 AuditAssertion::Accuracy,
92 ],
93 always_significant_occurrence: false,
94 },
95 AccountAreaSpec {
96 name: "Accruals",
97 default_ir: RiskRating::Medium,
98 assertions: &[
99 AuditAssertion::CompletenessBalance,
100 AuditAssertion::ValuationAndAllocation,
101 ],
102 always_significant_occurrence: false,
103 },
104 AccountAreaSpec {
105 name: "Cash",
106 default_ir: RiskRating::Low,
107 assertions: &[
108 AuditAssertion::Existence,
109 AuditAssertion::CompletenessBalance,
110 ],
111 always_significant_occurrence: false,
112 },
113 AccountAreaSpec {
114 name: "Tax",
115 default_ir: RiskRating::Medium,
116 assertions: &[
117 AuditAssertion::Accuracy,
118 AuditAssertion::ValuationAndAllocation,
119 ],
120 always_significant_occurrence: false,
121 },
122 AccountAreaSpec {
123 name: "Equity",
124 default_ir: RiskRating::Low,
125 assertions: &[
126 AuditAssertion::Existence,
127 AuditAssertion::PresentationAndDisclosure,
128 ],
129 always_significant_occurrence: false,
130 },
131 AccountAreaSpec {
132 name: "Provisions",
133 default_ir: RiskRating::High,
134 assertions: &[
135 AuditAssertion::CompletenessBalance,
136 AuditAssertion::ValuationAndAllocation,
137 ],
138 always_significant_occurrence: false,
139 },
140 AccountAreaSpec {
141 name: "Related Parties",
142 default_ir: RiskRating::High,
143 assertions: &[AuditAssertion::Occurrence, AuditAssertion::Completeness],
144 always_significant_occurrence: true,
145 },
146];
147
148fn risk_factors_for(area: &str, assertion: AuditAssertion) -> Vec<String> {
153 let mut factors: Vec<String> = Vec::new();
154
155 match area {
156 "Revenue" => {
157 factors.push(
158 "Revenue recognition involves judgment in identifying performance obligations"
159 .into(),
160 );
161 if assertion == AuditAssertion::Occurrence {
162 factors.push(
163 "Presumed fraud risk per ISA 240 — incentive to overstate revenue".into(),
164 );
165 }
166 if assertion == AuditAssertion::Cutoff {
167 factors.push(
168 "Cut-off risk heightened near period-end due to shipping arrangements".into(),
169 );
170 }
171 }
172 "Trade Receivables" => {
173 factors
174 .push("Collectability assessment involves significant management judgment".into());
175 if assertion == AuditAssertion::ValuationAndAllocation {
176 factors.push(
177 "ECL provisioning methodology may be complex under IFRS 9 / ASC 310".into(),
178 );
179 }
180 }
181 "Inventory" => {
182 factors.push("Physical quantities require verification through observation".into());
183 if assertion == AuditAssertion::ValuationAndAllocation {
184 factors
185 .push("NRV impairment requires management's forward-looking estimates".into());
186 }
187 }
188 "Fixed Assets" => {
189 factors
190 .push("Capitalisation vs. expensing judgments affect reported asset values".into());
191 if assertion == AuditAssertion::ValuationAndAllocation {
192 factors
193 .push("Depreciation method and useful life estimates involve judgment".into());
194 }
195 }
196 "Provisions" => {
197 factors.push("Provisions are inherently uncertain and require estimation".into());
198 factors.push("Completeness depends on management identifying all obligations".into());
199 }
200 "Related Parties" => {
201 factors.push("Related party transactions may not be conducted at arm's length".into());
202 factors.push(
203 "Completeness depends on management disclosing all related party relationships"
204 .into(),
205 );
206 }
207 "Accruals" => {
208 factors.push(
209 "Accrual completeness relies on management's identification of liabilities".into(),
210 );
211 }
212 "Tax" => {
213 factors
214 .push("Tax provisions involve complex legislation and management judgment".into());
215 factors.push(
216 "Deferred tax calculation depends on timing difference identification".into(),
217 );
218 }
219 _ => {
220 factors.push(format!("{area} — standard inherent risk factors apply"));
221 }
222 }
223
224 factors
225}
226
227#[derive(Debug, Clone)]
233pub struct CraGeneratorConfig {
234 pub effective_controls_probability: f64,
236 pub partial_controls_probability: f64,
238 }
240
241impl Default for CraGeneratorConfig {
242 fn default() -> Self {
243 Self {
244 effective_controls_probability: 0.40,
245 partial_controls_probability: 0.45,
246 }
247 }
248}
249
250pub struct CraGenerator {
256 rng: ChaCha8Rng,
257 config: CraGeneratorConfig,
258}
259
260impl CraGenerator {
261 pub fn new(seed: u64) -> Self {
263 Self {
264 rng: seeded_rng(seed, 0x315), config: CraGeneratorConfig::default(),
266 }
267 }
268
269 pub fn with_config(seed: u64, config: CraGeneratorConfig) -> Self {
271 Self {
272 rng: seeded_rng(seed, 0x315),
273 config,
274 }
275 }
276
277 pub fn generate_for_entity(
285 &mut self,
286 entity_code: &str,
287 control_effectiveness: Option<&std::collections::HashMap<String, RiskRating>>,
288 ) -> Vec<CombinedRiskAssessment> {
289 info!("Generating CRAs for entity {}", entity_code);
290 let mut results = Vec::new();
291
292 for spec in ACCOUNT_AREAS {
293 for &assertion in spec.assertions {
294 let ir = self.jitter_inherent_risk(spec.default_ir);
295 let cr = self.assess_control_risk(spec.name, control_effectiveness);
296
297 let is_significant = self.is_significant_risk(spec, assertion, ir, cr);
299
300 debug!(
301 "CRA: {} {:?} -> IR={:?} CR={:?} significant={}",
302 spec.name, assertion, ir, cr, is_significant
303 );
304
305 let risk_factors = risk_factors_for(spec.name, assertion);
306
307 let cra = CombinedRiskAssessment::new(
308 entity_code,
309 spec.name,
310 assertion,
311 ir,
312 cr,
313 is_significant,
314 risk_factors,
315 );
316
317 results.push(cra);
318 }
319 }
320
321 info!(
322 "Generated {} CRAs for entity {}",
323 results.len(),
324 entity_code
325 );
326 results
327 }
328
329 fn jitter_inherent_risk(&mut self, default: RiskRating) -> RiskRating {
335 let roll: f64 = self.rng.random();
336 match default {
337 RiskRating::Low => {
338 if roll > 0.85 {
339 RiskRating::Medium
340 } else {
341 RiskRating::Low
342 }
343 }
344 RiskRating::Medium => {
345 if roll < 0.10 {
346 RiskRating::Low
347 } else if roll > 0.85 {
348 RiskRating::High
349 } else {
350 RiskRating::Medium
351 }
352 }
353 RiskRating::High => {
354 if roll > 0.85 {
355 RiskRating::Medium
356 } else {
357 RiskRating::High
358 }
359 }
360 }
361 }
362
363 fn assess_control_risk(
368 &mut self,
369 area: &str,
370 overrides: Option<&std::collections::HashMap<String, RiskRating>>,
371 ) -> RiskRating {
372 if let Some(map) = overrides {
373 if let Some(&cr) = map.get(area) {
374 return cr;
375 }
376 }
377 let roll: f64 = self.rng.random();
378 if roll < self.config.effective_controls_probability {
379 RiskRating::Low
380 } else if roll
381 < self.config.effective_controls_probability + self.config.partial_controls_probability
382 {
383 RiskRating::Medium
384 } else {
385 RiskRating::High
386 }
387 }
388
389 fn is_significant_risk(
391 &self,
392 spec: &AccountAreaSpec,
393 assertion: AuditAssertion,
394 ir: RiskRating,
395 _cr: RiskRating,
396 ) -> bool {
397 if spec.always_significant_occurrence && assertion == AuditAssertion::Occurrence {
399 return true;
400 }
401 if spec.name == "Inventory"
404 && assertion == AuditAssertion::Existence
405 && ir == RiskRating::High
406 {
407 return true;
408 }
409 if ir == RiskRating::High
411 && matches!(
412 spec.name,
413 "Provisions" | "Accruals" | "Trade Receivables" | "Inventory"
414 )
415 && assertion == AuditAssertion::ValuationAndAllocation
416 {
417 return true;
418 }
419 false
420 }
421}
422
423#[cfg(test)]
428#[allow(clippy::unwrap_used)]
429mod tests {
430 use super::*;
431
432 #[test]
433 fn generates_cras_for_entity() {
434 let mut gen = CraGenerator::new(42);
435 let cras = gen.generate_for_entity("C001", None);
436 assert!(!cras.is_empty());
438 assert!(cras.len() >= 12);
439 }
440
441 #[test]
442 fn revenue_occurrence_always_significant() {
443 let mut gen = CraGenerator::new(42);
444 let cras = gen.generate_for_entity("C001", None);
445 let rev_occurrence = cras
446 .iter()
447 .find(|c| c.account_area == "Revenue" && c.assertion == AuditAssertion::Occurrence);
448 assert!(
449 rev_occurrence.is_some(),
450 "Revenue/Occurrence CRA should exist"
451 );
452 assert!(
453 rev_occurrence.unwrap().significant_risk,
454 "Revenue/Occurrence must always be significant per ISA 240"
455 );
456 }
457
458 #[test]
459 fn related_party_occurrence_is_significant() {
460 let mut gen = CraGenerator::new(42);
461 let cras = gen.generate_for_entity("C001", None);
462 let rp = cras.iter().find(|c| {
463 c.account_area == "Related Parties" && c.assertion == AuditAssertion::Occurrence
464 });
465 assert!(rp.is_some());
466 assert!(rp.unwrap().significant_risk);
467 }
468
469 #[test]
470 fn cra_ids_are_unique() {
471 let mut gen = CraGenerator::new(42);
472 let cras = gen.generate_for_entity("C001", None);
473 let ids: std::collections::HashSet<&str> = cras.iter().map(|c| c.id.as_str()).collect();
474 assert_eq!(ids.len(), cras.len(), "CRA IDs should be unique");
475 }
476
477 #[test]
478 fn control_override_respected() {
479 let mut overrides = std::collections::HashMap::new();
480 overrides.insert("Cash".into(), RiskRating::Low);
481 let mut gen = CraGenerator::new(42);
482 let cras = gen.generate_for_entity("C001", Some(&overrides));
483 let cash_cras: Vec<_> = cras.iter().filter(|c| c.account_area == "Cash").collect();
484 for c in &cash_cras {
485 assert_eq!(
486 c.control_risk,
487 RiskRating::Low,
488 "Control override should apply"
489 );
490 }
491 }
492}