Skip to main content

datasynth_core/models/audit/
risk.rs

1//! Risk assessment models per ISA 315 and ISA 330.
2//!
3//! Risk assessment is the foundation of a risk-based audit approach,
4//! identifying risks of material misstatement at both the financial
5//! statement level and assertion level.
6
7use std::hash::{Hash, Hasher};
8
9use chrono::{DateTime, NaiveDate, Utc};
10use serde::{Deserialize, Serialize};
11use uuid::Uuid;
12
13use super::engagement::RiskLevel;
14use super::workpaper::Assertion;
15
16/// Risk lifecycle status — tracks whether the risk is active, mitigated, etc.
17///
18/// Distinct from [`RiskReviewStatus`] which tracks the *review workflow*
19/// (Draft/PendingReview/Approved). `RiskStatus` tracks the *risk lifecycle state*.
20#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Hash, Serialize, Deserialize)]
21#[serde(rename_all = "snake_case")]
22pub enum RiskStatus {
23    /// Risk is active and requires monitoring/mitigation
24    #[default]
25    Active,
26    /// Risk has been mitigated by controls
27    Mitigated,
28    /// Risk has been accepted (residual risk within tolerance)
29    Accepted,
30    /// Risk is closed (no longer applicable)
31    Closed,
32}
33
34/// Derive a continuous score in `[lo, hi]` from a [`RiskLevel`] enum,
35/// using deterministic jitter seeded from `risk_id`.
36///
37/// The jitter is derived by hashing `risk_id` to get a stable fraction in `[0, 1)`,
38/// then mapping it into the `[lo, hi]` range for the given level:
39///   - Low:         `[0.15, 0.35]`
40///   - Medium:      `[0.35, 0.55]`
41///   - High:        `[0.55, 0.80]`
42///   - Significant: `[0.80, 0.95]`
43fn continuous_score(level: &RiskLevel, risk_id: &Uuid, discriminator: u8) -> f64 {
44    let (lo, hi) = match level {
45        RiskLevel::Low => (0.15, 0.35),
46        RiskLevel::Medium => (0.35, 0.55),
47        RiskLevel::High => (0.55, 0.80),
48        RiskLevel::Significant => (0.80, 0.95),
49    };
50
51    // Deterministic jitter: hash risk_id + discriminator
52    let mut hasher = std::collections::hash_map::DefaultHasher::new();
53    risk_id.hash(&mut hasher);
54    discriminator.hash(&mut hasher);
55    let hash = hasher.finish();
56
57    // Map hash to [0, 1) fraction, then scale to [lo, hi]
58    let frac = (hash as f64) / (u64::MAX as f64);
59    lo + frac * (hi - lo)
60}
61
62/// Risk assessment for an account or process.
63#[derive(Debug, Clone, Serialize, Deserialize)]
64pub struct RiskAssessment {
65    /// Unique risk ID
66    pub risk_id: Uuid,
67    /// External reference
68    pub risk_ref: String,
69    /// Engagement ID
70    pub engagement_id: Uuid,
71    /// Risk category
72    pub risk_category: RiskCategory,
73    /// Account or process being assessed
74    pub account_or_process: String,
75    /// Specific assertion if applicable
76    pub assertion: Option<Assertion>,
77    /// Risk description
78    pub description: String,
79
80    // === Risk Assessment ===
81    /// Inherent risk assessment
82    pub inherent_risk: RiskLevel,
83    /// Control risk assessment
84    pub control_risk: RiskLevel,
85    /// Combined risk of material misstatement
86    pub risk_of_material_misstatement: RiskLevel,
87    /// Is this a significant risk per ISA 315?
88    pub is_significant_risk: bool,
89    /// Rationale for significant risk designation
90    pub significant_risk_rationale: Option<String>,
91
92    // === Continuous Risk Scores (for heatmap placement) ===
93    /// Inherent impact score (0.0-1.0), derived from `inherent_risk` level
94    pub inherent_impact: f64,
95    /// Inherent likelihood score (0.0-1.0), derived from `inherent_risk` level
96    pub inherent_likelihood: f64,
97    /// Residual impact score (0.0-1.0), derived from `control_risk` level
98    pub residual_impact: f64,
99    /// Residual likelihood score (0.0-1.0), derived from `control_risk` level
100    pub residual_likelihood: f64,
101    /// Composite risk score: `inherent_impact * inherent_likelihood * 100`
102    pub risk_score: f64,
103
104    // === Display ===
105    /// Human-readable risk name (e.g. "Revenue Recognition Risk [High]")
106    pub risk_name: String,
107
108    // === Control Linkage ===
109    /// Number of mitigating controls linked to this risk
110    pub mitigating_control_count: u32,
111    /// Number of effective (passing) controls among mitigating controls
112    pub effective_control_count: u32,
113
114    // === Lifecycle Status ===
115    /// Risk lifecycle status (Active, Mitigated, Accepted, Closed)
116    pub status: RiskStatus,
117
118    // === Fraud Risk ===
119    /// Fraud risk factors identified
120    pub fraud_risk_factors: Vec<FraudRiskFactor>,
121    /// Presumed fraud risk in revenue recognition?
122    pub presumed_revenue_fraud_risk: bool,
123    /// Presumed management override risk?
124    pub presumed_management_override: bool,
125
126    // === Response ===
127    /// Planned audit response
128    pub planned_response: Vec<PlannedResponse>,
129    /// Nature of procedures (substantive, control, combined)
130    pub response_nature: ResponseNature,
131    /// Extent (sample size considerations)
132    pub response_extent: String,
133    /// Timing (interim, year-end, subsequent)
134    pub response_timing: ResponseTiming,
135
136    // === Assessment Details ===
137    /// Assessed by user ID
138    pub assessed_by: String,
139    /// Assessment date
140    pub assessed_date: NaiveDate,
141    /// Review status
142    pub review_status: RiskReviewStatus,
143    /// Reviewer ID
144    pub reviewer_id: Option<String>,
145    /// Review date
146    pub review_date: Option<NaiveDate>,
147
148    // === Cross-References ===
149    /// Related workpaper IDs
150    pub workpaper_refs: Vec<Uuid>,
151    /// Related control IDs
152    pub related_controls: Vec<String>,
153
154    pub created_at: DateTime<Utc>,
155    pub updated_at: DateTime<Utc>,
156}
157
158impl RiskAssessment {
159    /// Create a new risk assessment.
160    pub fn new(
161        engagement_id: Uuid,
162        risk_category: RiskCategory,
163        account_or_process: &str,
164        description: &str,
165    ) -> Self {
166        let now = Utc::now();
167        let risk_id = Uuid::new_v4();
168        let default_level = RiskLevel::Medium;
169        let inherent_impact = continuous_score(&default_level, &risk_id, 0);
170        let inherent_likelihood = continuous_score(&default_level, &risk_id, 1);
171        let residual_impact = continuous_score(&default_level, &risk_id, 2);
172        let residual_likelihood = continuous_score(&default_level, &risk_id, 3);
173        let risk_score = inherent_impact * inherent_likelihood * 100.0;
174        let risk_name = format!("{} Risk [{:?}]", account_or_process, default_level);
175
176        Self {
177            risk_id,
178            risk_ref: format!(
179                "RISK-{}",
180                Uuid::new_v4().simple().to_string()[..8].to_uppercase()
181            ),
182            engagement_id,
183            risk_category,
184            account_or_process: account_or_process.into(),
185            assertion: None,
186            description: description.into(),
187            inherent_risk: default_level,
188            control_risk: default_level,
189            risk_of_material_misstatement: default_level,
190            is_significant_risk: false,
191            significant_risk_rationale: None,
192            inherent_impact,
193            inherent_likelihood,
194            residual_impact,
195            residual_likelihood,
196            risk_score,
197            risk_name,
198            mitigating_control_count: 0,
199            effective_control_count: 0,
200            status: RiskStatus::Active,
201            fraud_risk_factors: Vec::new(),
202            presumed_revenue_fraud_risk: false,
203            presumed_management_override: true,
204            planned_response: Vec::new(),
205            response_nature: ResponseNature::Combined,
206            response_extent: String::new(),
207            response_timing: ResponseTiming::YearEnd,
208            assessed_by: String::new(),
209            assessed_date: now.date_naive(),
210            review_status: RiskReviewStatus::Draft,
211            reviewer_id: None,
212            review_date: None,
213            workpaper_refs: Vec::new(),
214            related_controls: Vec::new(),
215            created_at: now,
216            updated_at: now,
217        }
218    }
219
220    /// Set the assertion being assessed.
221    pub fn with_assertion(mut self, assertion: Assertion) -> Self {
222        self.assertion = Some(assertion);
223        self
224    }
225
226    /// Set risk levels and recompute continuous scores.
227    pub fn with_risk_levels(mut self, inherent: RiskLevel, control: RiskLevel) -> Self {
228        self.inherent_risk = inherent;
229        self.control_risk = control;
230        self.risk_of_material_misstatement = self.calculate_romm();
231        self.recompute_continuous_scores();
232        self
233    }
234
235    /// Mark as significant risk.
236    pub fn mark_significant(mut self, rationale: &str) -> Self {
237        self.is_significant_risk = true;
238        self.significant_risk_rationale = Some(rationale.into());
239        self
240    }
241
242    /// Add a fraud risk factor.
243    pub fn add_fraud_factor(&mut self, factor: FraudRiskFactor) {
244        self.fraud_risk_factors.push(factor);
245        self.updated_at = Utc::now();
246    }
247
248    /// Add a planned response.
249    pub fn add_response(&mut self, response: PlannedResponse) {
250        self.planned_response.push(response);
251        self.updated_at = Utc::now();
252    }
253
254    /// Set who assessed this risk.
255    pub fn with_assessed_by(mut self, user_id: &str, date: NaiveDate) -> Self {
256        self.assessed_by = user_id.into();
257        self.assessed_date = date;
258        self
259    }
260
261    /// Calculate risk of material misstatement from IR and CR.
262    fn calculate_romm(&self) -> RiskLevel {
263        let ir_score = self.inherent_risk.score();
264        let cr_score = self.control_risk.score();
265        let combined = (ir_score + cr_score) / 2;
266        RiskLevel::from_score(combined)
267    }
268
269    /// Recompute continuous scores and risk_name from current enum levels.
270    fn recompute_continuous_scores(&mut self) {
271        self.inherent_impact = continuous_score(&self.inherent_risk, &self.risk_id, 0);
272        self.inherent_likelihood = continuous_score(&self.inherent_risk, &self.risk_id, 1);
273        self.residual_impact = continuous_score(&self.control_risk, &self.risk_id, 2);
274        self.residual_likelihood = continuous_score(&self.control_risk, &self.risk_id, 3);
275        self.risk_score = self.inherent_impact * self.inherent_likelihood * 100.0;
276        self.risk_name = format!(
277            "{} Risk [{:?}]",
278            self.account_or_process, self.inherent_risk
279        );
280    }
281
282    /// Get the detection risk needed to achieve acceptable audit risk.
283    pub fn required_detection_risk(&self) -> DetectionRisk {
284        match self.risk_of_material_misstatement {
285            RiskLevel::Low => DetectionRisk::High,
286            RiskLevel::Medium => DetectionRisk::Medium,
287            RiskLevel::High | RiskLevel::Significant => DetectionRisk::Low,
288        }
289    }
290
291    /// Check if this risk requires special audit consideration.
292    pub fn requires_special_consideration(&self) -> bool {
293        self.is_significant_risk
294            || matches!(
295                self.risk_of_material_misstatement,
296                RiskLevel::High | RiskLevel::Significant
297            )
298            || !self.fraud_risk_factors.is_empty()
299    }
300}
301
302/// Risk category per ISA 315.
303#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize, Default)]
304#[serde(rename_all = "snake_case")]
305pub enum RiskCategory {
306    /// Risk at the financial statement level
307    FinancialStatementLevel,
308    /// Risk at the assertion level
309    #[default]
310    AssertionLevel,
311    /// Fraud risk
312    FraudRisk,
313    /// Going concern risk
314    GoingConcern,
315    /// Related party risk
316    RelatedParty,
317    /// Accounting estimate risk
318    EstimateRisk,
319    /// IT general control risk
320    ItGeneralControl,
321    /// Regulatory compliance risk
322    RegulatoryCompliance,
323}
324
325/// Fraud risk factor per the fraud triangle.
326#[derive(Debug, Clone, Serialize, Deserialize)]
327pub struct FraudRiskFactor {
328    /// Factor ID
329    pub factor_id: Uuid,
330    /// Element of fraud triangle
331    pub factor_type: FraudTriangleElement,
332    /// Specific indicator description
333    pub indicator: String,
334    /// Risk score (0-100)
335    pub score: u8,
336    /// Trend direction
337    pub trend: Trend,
338    /// Source of information
339    pub source: String,
340    /// Date identified
341    pub identified_date: NaiveDate,
342}
343
344impl FraudRiskFactor {
345    /// Create a new fraud risk factor.
346    pub fn new(
347        factor_type: FraudTriangleElement,
348        indicator: &str,
349        score: u8,
350        source: &str,
351    ) -> Self {
352        Self {
353            factor_id: Uuid::new_v4(),
354            factor_type,
355            indicator: indicator.into(),
356            score: score.min(100),
357            trend: Trend::Stable,
358            source: source.into(),
359            identified_date: Utc::now().date_naive(),
360        }
361    }
362
363    /// Set the trend.
364    pub fn with_trend(mut self, trend: Trend) -> Self {
365        self.trend = trend;
366        self
367    }
368}
369
370/// Elements of the fraud triangle.
371#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
372#[serde(rename_all = "snake_case")]
373pub enum FraudTriangleElement {
374    /// Opportunity to commit fraud
375    Opportunity,
376    /// Incentive/pressure to commit fraud
377    Pressure,
378    /// Rationalization/attitude
379    Rationalization,
380}
381
382impl FraudTriangleElement {
383    /// Get a description.
384    pub fn description(&self) -> &'static str {
385        match self {
386            Self::Opportunity => "Circumstances providing opportunity to commit fraud",
387            Self::Pressure => "Incentives or pressures to commit fraud",
388            Self::Rationalization => "Attitude or rationalization to justify fraud",
389        }
390    }
391}
392
393/// Trend direction.
394#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize, Default)]
395#[serde(rename_all = "snake_case")]
396pub enum Trend {
397    /// Increasing
398    Increasing,
399    /// Stable
400    #[default]
401    Stable,
402    /// Decreasing
403    Decreasing,
404}
405
406/// Planned audit response to identified risk.
407#[derive(Debug, Clone, Serialize, Deserialize)]
408pub struct PlannedResponse {
409    /// Response ID
410    pub response_id: Uuid,
411    /// Procedure description
412    pub procedure: String,
413    /// Procedure type
414    pub procedure_type: ResponseProcedureType,
415    /// Assertion addressed
416    pub assertion_addressed: Assertion,
417    /// Assigned to user ID
418    pub assigned_to: String,
419    /// Target completion date
420    pub target_date: NaiveDate,
421    /// Status
422    pub status: ResponseStatus,
423    /// Workpaper reference when complete
424    pub workpaper_ref: Option<Uuid>,
425}
426
427impl PlannedResponse {
428    /// Create a new planned response.
429    pub fn new(
430        procedure: &str,
431        procedure_type: ResponseProcedureType,
432        assertion: Assertion,
433        assigned_to: &str,
434        target_date: NaiveDate,
435    ) -> Self {
436        Self {
437            response_id: Uuid::new_v4(),
438            procedure: procedure.into(),
439            procedure_type,
440            assertion_addressed: assertion,
441            assigned_to: assigned_to.into(),
442            target_date,
443            status: ResponseStatus::Planned,
444            workpaper_ref: None,
445        }
446    }
447}
448
449/// Type of response procedure.
450#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize, Default)]
451#[serde(rename_all = "snake_case")]
452pub enum ResponseProcedureType {
453    /// Test of controls
454    TestOfControls,
455    /// Substantive analytical procedure
456    AnalyticalProcedure,
457    /// Substantive test of details
458    #[default]
459    TestOfDetails,
460    /// External confirmation
461    Confirmation,
462    /// Physical inspection
463    PhysicalInspection,
464    /// Inquiry
465    Inquiry,
466}
467
468/// Nature of audit response.
469#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize, Default)]
470#[serde(rename_all = "snake_case")]
471pub enum ResponseNature {
472    /// Substantive procedures only
473    SubstantiveOnly,
474    /// Controls reliance with reduced substantive
475    ControlsReliance,
476    /// Combined approach
477    #[default]
478    Combined,
479}
480
481/// Timing of audit response.
482#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize, Default)]
483#[serde(rename_all = "snake_case")]
484pub enum ResponseTiming {
485    /// Interim testing
486    Interim,
487    /// Year-end testing
488    #[default]
489    YearEnd,
490    /// Roll-forward from interim
491    RollForward,
492    /// Subsequent events testing
493    Subsequent,
494}
495
496/// Status of planned response.
497#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize, Default)]
498#[serde(rename_all = "snake_case")]
499pub enum ResponseStatus {
500    /// Planned but not started
501    #[default]
502    Planned,
503    /// In progress
504    InProgress,
505    /// Complete
506    Complete,
507    /// Deferred
508    Deferred,
509    /// Not required
510    NotRequired,
511}
512
513/// Risk review status.
514#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize, Default)]
515#[serde(rename_all = "snake_case")]
516pub enum RiskReviewStatus {
517    /// Draft assessment
518    #[default]
519    Draft,
520    /// Pending review
521    PendingReview,
522    /// Reviewed and approved
523    Approved,
524    /// Requires revision
525    RequiresRevision,
526}
527
528/// Detection risk level (inverse of ROMM for achieving acceptable AR).
529#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
530#[serde(rename_all = "snake_case")]
531pub enum DetectionRisk {
532    /// Can accept high detection risk (less testing)
533    High,
534    /// Medium detection risk
535    Medium,
536    /// Low detection risk required (more testing)
537    Low,
538}
539
540#[cfg(test)]
541#[allow(clippy::unwrap_used)]
542mod tests {
543    use super::*;
544
545    #[test]
546    fn test_risk_assessment_creation() {
547        let risk = RiskAssessment::new(
548            Uuid::new_v4(),
549            RiskCategory::AssertionLevel,
550            "Revenue",
551            "Risk of fictitious revenue recognition",
552        )
553        .with_assertion(Assertion::Occurrence)
554        .with_risk_levels(RiskLevel::High, RiskLevel::Medium);
555
556        assert!(risk.inherent_risk == RiskLevel::High);
557        assert!(
558            risk.requires_special_consideration()
559                || risk.risk_of_material_misstatement != RiskLevel::Low
560        );
561    }
562
563    #[test]
564    fn test_significant_risk() {
565        let risk = RiskAssessment::new(
566            Uuid::new_v4(),
567            RiskCategory::FraudRisk,
568            "Revenue",
569            "Fraud risk in revenue recognition",
570        )
571        .mark_significant("Presumed fraud risk per ISA 240");
572
573        assert!(risk.is_significant_risk);
574        assert!(risk.requires_special_consideration());
575    }
576
577    #[test]
578    fn test_fraud_risk_factor() {
579        let factor = FraudRiskFactor::new(
580            FraudTriangleElement::Pressure,
581            "Management bonus tied to revenue targets",
582            75,
583            "Bonus plan review",
584        )
585        .with_trend(Trend::Increasing);
586
587        assert_eq!(factor.factor_type, FraudTriangleElement::Pressure);
588        assert_eq!(factor.score, 75);
589    }
590
591    #[test]
592    fn test_detection_risk() {
593        let risk = RiskAssessment::new(
594            Uuid::new_v4(),
595            RiskCategory::AssertionLevel,
596            "Cash",
597            "Low risk account",
598        )
599        .with_risk_levels(RiskLevel::Low, RiskLevel::Low);
600
601        assert_eq!(risk.required_detection_risk(), DetectionRisk::High);
602    }
603}