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;
22
23// ---------------------------------------------------------------------------
24// Account area definition
25// ---------------------------------------------------------------------------
26
27/// An account area with its default inherent risk and the assertions to assess.
28#[derive(Debug, Clone)]
29struct AccountAreaSpec {
30    /// Human-readable name (e.g. "Revenue").
31    name: &'static str,
32    /// Default inherent risk when no other information is available.
33    default_ir: RiskRating,
34    /// Assertions to generate CRAs for.
35    assertions: &'static [AuditAssertion],
36    /// Whether Revenue/Occurrence significant-risk rule applies.
37    always_significant_occurrence: bool,
38}
39
40/// Standard account areas per ISA 315 / typical audit scope.
41static 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
147// ---------------------------------------------------------------------------
148// Risk factors by account area
149// ---------------------------------------------------------------------------
150
151fn 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// ---------------------------------------------------------------------------
227// Configuration
228// ---------------------------------------------------------------------------
229
230/// Configuration for the CRA generator.
231#[derive(Debug, Clone)]
232pub struct CraGeneratorConfig {
233    /// Probability that control risk is Low (effective controls in place).
234    pub effective_controls_probability: f64,
235    /// Probability that control risk is Medium (partially effective).
236    pub partial_controls_probability: f64,
237    // Note: no_controls_probability = 1 - effective - partial
238}
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
249// ---------------------------------------------------------------------------
250// Generator
251// ---------------------------------------------------------------------------
252
253/// Generator for Combined Risk Assessments per ISA 315.
254pub struct CraGenerator {
255    rng: ChaCha8Rng,
256    config: CraGeneratorConfig,
257}
258
259impl CraGenerator {
260    /// Create a new generator with the given seed and default configuration.
261    pub fn new(seed: u64) -> Self {
262        Self {
263            rng: seeded_rng(seed, 0x315), // discriminator for ISA 315
264            config: CraGeneratorConfig::default(),
265        }
266    }
267
268    /// Create a new generator with custom configuration.
269    pub fn with_config(seed: u64, config: CraGeneratorConfig) -> Self {
270        Self {
271            rng: seeded_rng(seed, 0x315),
272            config,
273        }
274    }
275
276    /// Generate CRAs for all standard account areas for a single entity.
277    ///
278    /// # Arguments
279    /// * `entity_code` — The entity being assessed.
280    /// * `control_effectiveness` — Optional map from account area name to
281    ///   control risk override.  When `None` for an area the generator picks
282    ///   control risk randomly using the configured probabilities.
283    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                // Determine significant risk flag
296                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    /// Apply small random jitter to the default inherent risk so outputs vary.
318    ///
319    /// There is a 15% chance of moving one step up/down from the default,
320    /// ensuring most assessments reflect the expected risk profile while
321    /// allowing realistic variation.
322    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    /// Determine control risk for an account area.
352    ///
353    /// Uses the supplied override map if present, otherwise draws randomly
354    /// according to the configured probabilities.
355    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    /// Apply the significant risk rules per ISA 315.28, ISA 240, and ISA 501.
378    fn is_significant_risk(
379        &self,
380        spec: &AccountAreaSpec,
381        assertion: AuditAssertion,
382        ir: RiskRating,
383        _cr: RiskRating,
384    ) -> bool {
385        // Per ISA 240.26 — revenue occurrence is always presumed fraud risk
386        if spec.always_significant_occurrence && assertion == AuditAssertion::Occurrence {
387            return true;
388        }
389        // Per ISA 501 — inventory existence requires physical observation (always significant
390        // when inherent risk is High, as quantities cannot be confirmed by other means).
391        if spec.name == "Inventory"
392            && assertion == AuditAssertion::Existence
393            && ir == RiskRating::High
394        {
395            return true;
396        }
397        // High IR on high-judgment areas (Provisions, Estimates) is significant
398        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// ---------------------------------------------------------------------------
412// Tests
413// ---------------------------------------------------------------------------
414
415#[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        // Should produce at least 12 CRAs (2 assertions × 12 areas minimum)
425        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}