Skip to main content

datasynth_core/models/audit/
risk_assessment_cra.rs

1//! Combined Risk Assessment (CRA) models per ISA 315.
2//!
3//! The CRA is the cornerstone of a risk-based audit.  For each account area and
4//! financial statement assertion the auditor combines inherent risk (IR) and
5//! control risk (CR) into a single CRA level that drives the nature, extent,
6//! and timing of planned audit procedures.
7//!
8//! References:
9//! - ISA 315 (Revised 2019) — Identifying and Assessing Risks of Material Misstatement
10//! - ISA 315.28 — Significant risks require special audit consideration
11//! - ISA 240 — Revenue occurrence is always presumed a significant fraud risk
12
13use serde::{Deserialize, Serialize};
14
15// ---------------------------------------------------------------------------
16// Core enums
17// ---------------------------------------------------------------------------
18
19/// Financial statement assertion being assessed (ISA 315.A129).
20#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
21#[serde(rename_all = "snake_case")]
22pub enum AuditAssertion {
23    // ---- Transaction-level assertions (ISA 315.A129a) ----
24    /// Recorded transactions and events have occurred and pertain to the entity.
25    Occurrence,
26    /// All transactions and events that should have been recorded have been.
27    Completeness,
28    /// Amounts and other data have been recorded appropriately.
29    Accuracy,
30    /// Transactions have been recorded in the correct accounting period.
31    Cutoff,
32    /// Transactions have been recorded in the proper accounts.
33    Classification,
34    // ---- Account-balance assertions (ISA 315.A129b) ----
35    /// Assets, liabilities and equity interests exist at the period end.
36    Existence,
37    /// The entity holds or controls the rights to assets; liabilities are obligations.
38    RightsAndObligations,
39    /// All assets, liabilities and equity interests that should have been recorded are.
40    CompletenessBalance,
41    /// Assets, liabilities and equity interests are included at appropriate amounts.
42    ValuationAndAllocation,
43    // ---- Presentation assertions (ISA 315.A129c) ----
44    /// All disclosures are appropriately described, disclosed and presented.
45    PresentationAndDisclosure,
46}
47
48impl std::fmt::Display for AuditAssertion {
49    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
50        let s = match self {
51            Self::Occurrence => "Occurrence",
52            Self::Completeness => "Completeness",
53            Self::Accuracy => "Accuracy",
54            Self::Cutoff => "Cutoff",
55            Self::Classification => "Classification",
56            Self::Existence => "Existence",
57            Self::RightsAndObligations => "Rights & Obligations",
58            Self::CompletenessBalance => "Completeness (Balance)",
59            Self::ValuationAndAllocation => "Valuation & Allocation",
60            Self::PresentationAndDisclosure => "Presentation & Disclosure",
61        };
62        write!(f, "{s}")
63    }
64}
65
66/// Individual risk rating — used separately for inherent risk and control risk.
67#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
68#[serde(rename_all = "snake_case")]
69pub enum RiskRating {
70    /// Well below the acceptable threshold.
71    Low,
72    /// Moderate — some risk present but not pervasive.
73    Medium,
74    /// Elevated — significant susceptibility to misstatement.
75    High,
76}
77
78impl std::fmt::Display for RiskRating {
79    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
80        let s = match self {
81            Self::Low => "Low",
82            Self::Medium => "Medium",
83            Self::High => "High",
84        };
85        write!(f, "{s}")
86    }
87}
88
89/// Combined Risk Assessment level — derived from the IR × CR matrix.
90///
91/// | IR \ CR  | Low      | Medium   | High  |
92/// |----------|----------|----------|-------|
93/// | Low      | Minimal  | Low      | Moderate |
94/// | Medium   | Low      | Moderate | High  |
95/// | High     | Moderate | High     | High  |
96#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
97#[serde(rename_all = "snake_case")]
98pub enum CraLevel {
99    /// Low IR + Low CR.
100    Minimal,
101    /// Low IR + Medium CR, or Medium IR + Low CR.
102    Low,
103    /// Medium IR + Medium CR, or High IR + Low CR.
104    Moderate,
105    /// High IR + Medium CR, or High IR + High CR, or Medium IR + High CR.
106    High,
107}
108
109impl CraLevel {
110    /// Compute the CRA level from individual IR and CR ratings.
111    ///
112    /// | IR \ CR  | Low     | Medium   | High     |
113    /// |----------|---------|----------|----------|
114    /// | Low      | Minimal | Low      | Moderate |
115    /// | Medium   | Low     | Moderate | High     |
116    /// | High     | Moderate| High     | High     |
117    pub fn from_ratings(ir: RiskRating, cr: RiskRating) -> Self {
118        match (ir, cr) {
119            (RiskRating::Low, RiskRating::Low) => Self::Minimal,
120            (RiskRating::Low, RiskRating::Medium) | (RiskRating::Medium, RiskRating::Low) => {
121                Self::Low
122            }
123            (RiskRating::Medium, RiskRating::Medium)
124            | (RiskRating::High, RiskRating::Low)
125            | (RiskRating::Low, RiskRating::High) => Self::Moderate,
126            _ => Self::High,
127        }
128    }
129}
130
131impl std::fmt::Display for CraLevel {
132    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
133        let s = match self {
134            Self::Minimal => "Minimal",
135            Self::Low => "Low",
136            Self::Moderate => "Moderate",
137            Self::High => "High",
138        };
139        write!(f, "{s}")
140    }
141}
142
143// ---------------------------------------------------------------------------
144// Planned response
145// ---------------------------------------------------------------------------
146
147/// Nature of planned audit procedures in response to the CRA.
148#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
149#[serde(rename_all = "snake_case")]
150pub enum ProcedureNature {
151    /// Substantive procedures only — no reliance on controls.
152    SubstantiveOnly,
153    /// Combined approach — tests of controls plus reduced substantive procedures.
154    Combined,
155    /// Controls reliance — extensive controls testing with minimal substantive.
156    ControlsReliance,
157}
158
159/// Planned extent of substantive testing (maps to relative sample sizes).
160#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
161#[serde(rename_all = "snake_case")]
162pub enum SamplingExtent {
163    /// Below-standard sample sizes applicable to low-risk areas.
164    Reduced,
165    /// Standard sample sizes.
166    Standard,
167    /// Above-standard sample sizes for high-risk or significant-risk areas.
168    Extended,
169}
170
171/// Timing of planned procedures relative to the period end.
172#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
173#[serde(rename_all = "snake_case")]
174pub enum ProcedureTiming {
175    /// Procedures performed at an interim date only.
176    Interim,
177    /// Procedures performed at or after the period end only.
178    YearEnd,
179    /// Procedures at both interim and year-end (roll-forward required).
180    Both,
181}
182
183/// Planned response design driven by the CRA level.
184#[derive(Debug, Clone, Serialize, Deserialize)]
185pub struct CraPlannedResponse {
186    /// Nature: substantive only, combined, or controls reliance.
187    pub nature: ProcedureNature,
188    /// Extent: reduced, standard, or extended.
189    pub extent: SamplingExtent,
190    /// Timing: interim, year-end, or both.
191    pub timing: ProcedureTiming,
192}
193
194impl CraPlannedResponse {
195    /// Derive a planned response from the CRA level per ISA 330 guidance.
196    ///
197    /// | CRA level | Nature           | Extent   | Timing   |
198    /// |-----------|------------------|----------|----------|
199    /// | Minimal   | SubstantiveOnly  | Reduced  | YearEnd  |
200    /// | Low       | SubstantiveOnly  | Reduced  | YearEnd  |
201    /// | Moderate  | Combined         | Standard | YearEnd  |
202    /// | High      | SubstantiveOnly  | Extended | Both     |
203    pub fn from_cra_level(level: CraLevel) -> Self {
204        match level {
205            CraLevel::Minimal | CraLevel::Low => Self {
206                nature: ProcedureNature::SubstantiveOnly,
207                extent: SamplingExtent::Reduced,
208                timing: ProcedureTiming::YearEnd,
209            },
210            CraLevel::Moderate => Self {
211                nature: ProcedureNature::Combined,
212                extent: SamplingExtent::Standard,
213                timing: ProcedureTiming::YearEnd,
214            },
215            CraLevel::High => Self {
216                nature: ProcedureNature::SubstantiveOnly,
217                extent: SamplingExtent::Extended,
218                timing: ProcedureTiming::Both,
219            },
220        }
221    }
222}
223
224// ---------------------------------------------------------------------------
225// Main struct
226// ---------------------------------------------------------------------------
227
228/// Combined Risk Assessment for a single account area / assertion pair.
229///
230/// One `CombinedRiskAssessment` is generated for each (account area, assertion)
231/// combination that the auditor scopes into the engagement.  The CRA drives the
232/// design of audit procedures (ISA 330) and the allocation of materiality.
233#[derive(Debug, Clone, Serialize, Deserialize)]
234pub struct CombinedRiskAssessment {
235    /// Unique identifier (deterministic slug).
236    pub id: String,
237    /// Entity / company code the assessment belongs to.
238    pub entity_code: String,
239    /// Audit scope this assessment belongs to (FK → AuditScope.id).
240    #[serde(default)]
241    pub scope_id: Option<String>,
242    /// Account area (e.g. "Revenue", "Trade Receivables", "Inventory").
243    pub account_area: String,
244    /// The specific assertion being assessed.
245    pub assertion: AuditAssertion,
246    /// Inherent risk rating.
247    pub inherent_risk: RiskRating,
248    /// Control risk rating (effectiveness of related internal controls).
249    pub control_risk: RiskRating,
250    /// Combined risk level derived from the IR × CR matrix.
251    pub combined_risk: CraLevel,
252    /// Whether this is a significant risk per ISA 315.28 requiring special consideration.
253    pub significant_risk: bool,
254    /// Descriptive risk factors supporting the assessment.
255    pub risk_factors: Vec<String>,
256    /// Planned audit response designed for this CRA.
257    pub planned_response: CraPlannedResponse,
258}
259
260impl CombinedRiskAssessment {
261    /// Build a `CombinedRiskAssessment` and derive the CRA level and response.
262    pub fn new(
263        entity_code: &str,
264        account_area: &str,
265        assertion: AuditAssertion,
266        inherent_risk: RiskRating,
267        control_risk: RiskRating,
268        significant_risk: bool,
269        risk_factors: Vec<String>,
270    ) -> Self {
271        let combined_risk = CraLevel::from_ratings(inherent_risk, control_risk);
272        let planned_response = CraPlannedResponse::from_cra_level(combined_risk);
273        let id = format!(
274            "CRA-{}-{}-{}",
275            entity_code,
276            account_area.replace(' ', "_").to_uppercase(),
277            format!("{assertion:?}").to_uppercase(),
278        );
279
280        Self {
281            id,
282            entity_code: entity_code.to_string(),
283            scope_id: None,
284            account_area: account_area.to_string(),
285            assertion,
286            inherent_risk,
287            control_risk,
288            combined_risk,
289            significant_risk,
290            risk_factors,
291            planned_response,
292        }
293    }
294}
295
296// ---------------------------------------------------------------------------
297// Tests
298// ---------------------------------------------------------------------------
299
300#[cfg(test)]
301#[allow(clippy::unwrap_used)]
302mod tests {
303    use super::*;
304
305    #[test]
306    fn cra_matrix_low_low_is_minimal() {
307        let level = CraLevel::from_ratings(RiskRating::Low, RiskRating::Low);
308        assert_eq!(level, CraLevel::Minimal);
309    }
310
311    #[test]
312    fn cra_matrix_high_high_is_high() {
313        let level = CraLevel::from_ratings(RiskRating::High, RiskRating::High);
314        assert_eq!(level, CraLevel::High);
315    }
316
317    #[test]
318    fn cra_matrix_medium_medium_is_moderate() {
319        let level = CraLevel::from_ratings(RiskRating::Medium, RiskRating::Medium);
320        assert_eq!(level, CraLevel::Moderate);
321    }
322
323    #[test]
324    fn cra_matrix_high_low_is_moderate() {
325        let level = CraLevel::from_ratings(RiskRating::High, RiskRating::Low);
326        assert_eq!(level, CraLevel::Moderate);
327    }
328
329    #[test]
330    fn cra_matrix_low_high_is_moderate() {
331        // Low IR caps the combined level even when controls provide no assurance.
332        let level = CraLevel::from_ratings(RiskRating::Low, RiskRating::High);
333        assert_eq!(level, CraLevel::Moderate);
334    }
335
336    #[test]
337    fn cra_matrix_medium_high_is_high() {
338        let level = CraLevel::from_ratings(RiskRating::Medium, RiskRating::High);
339        assert_eq!(level, CraLevel::High);
340    }
341
342    #[test]
343    fn planned_response_high_cra_is_extended_both() {
344        let resp = CraPlannedResponse::from_cra_level(CraLevel::High);
345        assert_eq!(resp.extent, SamplingExtent::Extended);
346        assert_eq!(resp.timing, ProcedureTiming::Both);
347        assert_eq!(resp.nature, ProcedureNature::SubstantiveOnly);
348    }
349
350    #[test]
351    fn planned_response_moderate_cra_is_combined_standard() {
352        let resp = CraPlannedResponse::from_cra_level(CraLevel::Moderate);
353        assert_eq!(resp.nature, ProcedureNature::Combined);
354        assert_eq!(resp.extent, SamplingExtent::Standard);
355    }
356
357    #[test]
358    fn cra_new_derives_combined_risk() {
359        let cra = CombinedRiskAssessment::new(
360            "C001",
361            "Revenue",
362            AuditAssertion::Occurrence,
363            RiskRating::High,
364            RiskRating::Medium,
365            true,
366            vec!["Presumed fraud risk per ISA 240".into()],
367        );
368        assert_eq!(cra.combined_risk, CraLevel::High);
369        assert!(cra.significant_risk);
370    }
371}