Skip to main content

datasynth_generators/audit/
cra_generator.rs

1//! Combined Risk Assessment (CRA) generator per ISA 315.
2//!
3//! For each entity the generator produces one `CombinedRiskAssessment` per
4//! (account area, assertion) combination drawn from a set of 12 standard
5//! account areas.  Inherent risk is driven by the economic nature of each
6//! account area; control risk can be overridden from external control-
7//! effectiveness data (e.g. from `InternalControl` records).
8//!
9//! # Significant risk rules (ISA 315.28 / ISA 240)
10//!
11//! The following are always flagged as significant risks, regardless of CRA level:
12//! - Revenue / Occurrence (presumed fraud risk per ISA 240.26)
13//! - Related Party / Occurrence (related-party transactions)
14//! - Accounting Estimates / Valuation (high estimation uncertainty)
15
16use 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// ---------------------------------------------------------------------------
25// Account area definition
26// ---------------------------------------------------------------------------
27
28/// An account area with its default inherent risk and the assertions to assess.
29#[derive(Debug, Clone)]
30struct AccountAreaSpec {
31    /// Human-readable name (e.g. "Revenue").
32    name: &'static str,
33    /// Default inherent risk when no other information is available.
34    default_ir: RiskRating,
35    /// Assertions to generate CRAs for.
36    assertions: &'static [AuditAssertion],
37    /// Whether Revenue/Occurrence significant-risk rule applies.
38    always_significant_occurrence: bool,
39}
40
41/// Standard account areas per ISA 315 / typical audit scope.
42static 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
148// ---------------------------------------------------------------------------
149// Risk factors by account area
150// ---------------------------------------------------------------------------
151
152fn 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// ---------------------------------------------------------------------------
228// Configuration
229// ---------------------------------------------------------------------------
230
231/// Configuration for the CRA generator.
232#[derive(Debug, Clone)]
233pub struct CraGeneratorConfig {
234    /// Probability that control risk is Low (effective controls in place).
235    pub effective_controls_probability: f64,
236    /// Probability that control risk is Medium (partially effective).
237    pub partial_controls_probability: f64,
238    // Note: no_controls_probability = 1 - effective - partial
239}
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
250// ---------------------------------------------------------------------------
251// Generator
252// ---------------------------------------------------------------------------
253
254/// Generator for Combined Risk Assessments per ISA 315.
255pub struct CraGenerator {
256    rng: ChaCha8Rng,
257    config: CraGeneratorConfig,
258}
259
260impl CraGenerator {
261    /// Create a new generator with the given seed and default configuration.
262    pub fn new(seed: u64) -> Self {
263        Self {
264            rng: seeded_rng(seed, 0x315), // discriminator for ISA 315
265            config: CraGeneratorConfig::default(),
266        }
267    }
268
269    /// Create a new generator with custom configuration.
270    pub fn with_config(seed: u64, config: CraGeneratorConfig) -> Self {
271        Self {
272            rng: seeded_rng(seed, 0x315),
273            config,
274        }
275    }
276
277    /// Generate CRAs for all standard account areas for a single entity.
278    ///
279    /// # Arguments
280    /// * `entity_code` — The entity being assessed.
281    /// * `control_effectiveness` — Optional map from account area name to
282    ///   control risk override.  When `None` for an area the generator picks
283    ///   control risk randomly using the configured probabilities.
284    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                // Determine significant risk flag
298                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    /// Apply small random jitter to the default inherent risk so outputs vary.
330    ///
331    /// There is a 15% chance of moving one step up/down from the default,
332    /// ensuring most assessments reflect the expected risk profile while
333    /// allowing realistic variation.
334    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    /// Determine control risk for an account area.
364    ///
365    /// Uses the supplied override map if present, otherwise draws randomly
366    /// according to the configured probabilities.
367    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    /// Apply the significant risk rules per ISA 315.28, ISA 240, and ISA 501.
390    fn is_significant_risk(
391        &self,
392        spec: &AccountAreaSpec,
393        assertion: AuditAssertion,
394        ir: RiskRating,
395        _cr: RiskRating,
396    ) -> bool {
397        // Per ISA 240.26 — revenue occurrence is always presumed fraud risk
398        if spec.always_significant_occurrence && assertion == AuditAssertion::Occurrence {
399            return true;
400        }
401        // Per ISA 501 — inventory existence requires physical observation (always significant
402        // when inherent risk is High, as quantities cannot be confirmed by other means).
403        if spec.name == "Inventory"
404            && assertion == AuditAssertion::Existence
405            && ir == RiskRating::High
406        {
407            return true;
408        }
409        // High IR on high-judgment areas (Provisions, Estimates) is significant
410        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// ---------------------------------------------------------------------------
424// Tests
425// ---------------------------------------------------------------------------
426
427#[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        // Should produce at least 12 CRAs (2 assertions × 12 areas minimum)
437        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}