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