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    #[serde(with = "crate::serde_timestamp::utc")]
155    pub created_at: DateTime<Utc>,
156    #[serde(with = "crate::serde_timestamp::utc")]
157    pub updated_at: DateTime<Utc>,
158}
159
160impl RiskAssessment {
161    /// Create a new risk assessment.
162    pub fn new(
163        engagement_id: Uuid,
164        risk_category: RiskCategory,
165        account_or_process: &str,
166        description: &str,
167    ) -> Self {
168        let now = Utc::now();
169        let risk_id = Uuid::new_v4();
170        let default_level = RiskLevel::Medium;
171        let inherent_impact = continuous_score(&default_level, &risk_id, 0);
172        let inherent_likelihood = continuous_score(&default_level, &risk_id, 1);
173        let residual_impact = continuous_score(&default_level, &risk_id, 2);
174        let residual_likelihood = continuous_score(&default_level, &risk_id, 3);
175        let risk_score = inherent_impact * inherent_likelihood * 100.0;
176        let risk_name = format!("{} Risk [{:?}]", account_or_process, default_level);
177
178        Self {
179            risk_id,
180            risk_ref: format!(
181                "RISK-{}",
182                Uuid::new_v4().simple().to_string()[..8].to_uppercase()
183            ),
184            engagement_id,
185            risk_category,
186            account_or_process: account_or_process.into(),
187            assertion: None,
188            description: description.into(),
189            inherent_risk: default_level,
190            control_risk: default_level,
191            risk_of_material_misstatement: default_level,
192            is_significant_risk: false,
193            significant_risk_rationale: None,
194            inherent_impact,
195            inherent_likelihood,
196            residual_impact,
197            residual_likelihood,
198            risk_score,
199            risk_name,
200            mitigating_control_count: 0,
201            effective_control_count: 0,
202            status: RiskStatus::Active,
203            fraud_risk_factors: Vec::new(),
204            presumed_revenue_fraud_risk: false,
205            presumed_management_override: true,
206            planned_response: Vec::new(),
207            response_nature: ResponseNature::Combined,
208            response_extent: String::new(),
209            response_timing: ResponseTiming::YearEnd,
210            assessed_by: String::new(),
211            assessed_date: now.date_naive(),
212            review_status: RiskReviewStatus::Draft,
213            reviewer_id: None,
214            review_date: None,
215            workpaper_refs: Vec::new(),
216            related_controls: Vec::new(),
217            created_at: now,
218            updated_at: now,
219        }
220    }
221
222    /// Set the assertion being assessed.
223    pub fn with_assertion(mut self, assertion: Assertion) -> Self {
224        self.assertion = Some(assertion);
225        self
226    }
227
228    /// Set risk levels and recompute continuous scores.
229    pub fn with_risk_levels(mut self, inherent: RiskLevel, control: RiskLevel) -> Self {
230        self.inherent_risk = inherent;
231        self.control_risk = control;
232        self.risk_of_material_misstatement = self.calculate_romm();
233        self.recompute_continuous_scores();
234        self
235    }
236
237    /// Mark as significant risk.
238    pub fn mark_significant(mut self, rationale: &str) -> Self {
239        self.is_significant_risk = true;
240        self.significant_risk_rationale = Some(rationale.into());
241        self
242    }
243
244    /// Add a fraud risk factor.
245    pub fn add_fraud_factor(&mut self, factor: FraudRiskFactor) {
246        self.fraud_risk_factors.push(factor);
247        self.updated_at = Utc::now();
248    }
249
250    /// Add a planned response.
251    pub fn add_response(&mut self, response: PlannedResponse) {
252        self.planned_response.push(response);
253        self.updated_at = Utc::now();
254    }
255
256    /// Set who assessed this risk.
257    pub fn with_assessed_by(mut self, user_id: &str, date: NaiveDate) -> Self {
258        self.assessed_by = user_id.into();
259        self.assessed_date = date;
260        self
261    }
262
263    /// Calculate risk of material misstatement from IR and CR.
264    fn calculate_romm(&self) -> RiskLevel {
265        let ir_score = self.inherent_risk.score();
266        let cr_score = self.control_risk.score();
267        let combined = (ir_score + cr_score) / 2;
268        RiskLevel::from_score(combined)
269    }
270
271    /// Recompute continuous scores and risk_name from current enum levels.
272    fn recompute_continuous_scores(&mut self) {
273        self.inherent_impact = continuous_score(&self.inherent_risk, &self.risk_id, 0);
274        self.inherent_likelihood = continuous_score(&self.inherent_risk, &self.risk_id, 1);
275        self.residual_impact = continuous_score(&self.control_risk, &self.risk_id, 2);
276        self.residual_likelihood = continuous_score(&self.control_risk, &self.risk_id, 3);
277        self.risk_score = self.inherent_impact * self.inherent_likelihood * 100.0;
278        self.risk_name = format!(
279            "{} Risk [{:?}]",
280            self.account_or_process, self.inherent_risk
281        );
282    }
283
284    /// Get the detection risk needed to achieve acceptable audit risk.
285    pub fn required_detection_risk(&self) -> DetectionRisk {
286        match self.risk_of_material_misstatement {
287            RiskLevel::Low => DetectionRisk::High,
288            RiskLevel::Medium => DetectionRisk::Medium,
289            RiskLevel::High | RiskLevel::Significant => DetectionRisk::Low,
290        }
291    }
292
293    /// Check if this risk requires special audit consideration.
294    pub fn requires_special_consideration(&self) -> bool {
295        self.is_significant_risk
296            || matches!(
297                self.risk_of_material_misstatement,
298                RiskLevel::High | RiskLevel::Significant
299            )
300            || !self.fraud_risk_factors.is_empty()
301    }
302}
303
304/// Risk category per ISA 315.
305#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize, Default)]
306#[serde(rename_all = "snake_case")]
307pub enum RiskCategory {
308    /// Risk at the financial statement level
309    FinancialStatementLevel,
310    /// Risk at the assertion level
311    #[default]
312    AssertionLevel,
313    /// Fraud risk
314    FraudRisk,
315    /// Going concern risk
316    GoingConcern,
317    /// Related party risk
318    RelatedParty,
319    /// Accounting estimate risk
320    EstimateRisk,
321    /// IT general control risk
322    ItGeneralControl,
323    /// Regulatory compliance risk
324    RegulatoryCompliance,
325}
326
327/// Fraud risk factor per the fraud triangle.
328#[derive(Debug, Clone, Serialize, Deserialize)]
329pub struct FraudRiskFactor {
330    /// Factor ID
331    pub factor_id: Uuid,
332    /// Element of fraud triangle
333    pub factor_type: FraudTriangleElement,
334    /// Specific indicator description
335    pub indicator: String,
336    /// Risk score (0-100)
337    pub score: u8,
338    /// Trend direction
339    pub trend: Trend,
340    /// Source of information
341    pub source: String,
342    /// Date identified
343    pub identified_date: NaiveDate,
344}
345
346impl FraudRiskFactor {
347    /// Create a new fraud risk factor.
348    pub fn new(
349        factor_type: FraudTriangleElement,
350        indicator: &str,
351        score: u8,
352        source: &str,
353    ) -> Self {
354        Self {
355            factor_id: Uuid::new_v4(),
356            factor_type,
357            indicator: indicator.into(),
358            score: score.min(100),
359            trend: Trend::Stable,
360            source: source.into(),
361            identified_date: Utc::now().date_naive(),
362        }
363    }
364
365    /// Set the trend.
366    pub fn with_trend(mut self, trend: Trend) -> Self {
367        self.trend = trend;
368        self
369    }
370}
371
372/// Elements of the fraud triangle.
373#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
374#[serde(rename_all = "snake_case")]
375pub enum FraudTriangleElement {
376    /// Opportunity to commit fraud
377    Opportunity,
378    /// Incentive/pressure to commit fraud
379    Pressure,
380    /// Rationalization/attitude
381    Rationalization,
382}
383
384impl FraudTriangleElement {
385    /// Get a description.
386    pub fn description(&self) -> &'static str {
387        match self {
388            Self::Opportunity => "Circumstances providing opportunity to commit fraud",
389            Self::Pressure => "Incentives or pressures to commit fraud",
390            Self::Rationalization => "Attitude or rationalization to justify fraud",
391        }
392    }
393}
394
395/// Trend direction.
396#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize, Default)]
397#[serde(rename_all = "snake_case")]
398pub enum Trend {
399    /// Increasing
400    Increasing,
401    /// Stable
402    #[default]
403    Stable,
404    /// Decreasing
405    Decreasing,
406}
407
408/// Planned audit response to identified risk.
409#[derive(Debug, Clone, Serialize, Deserialize)]
410pub struct PlannedResponse {
411    /// Response ID
412    pub response_id: Uuid,
413    /// Procedure description
414    pub procedure: String,
415    /// Procedure type
416    pub procedure_type: ResponseProcedureType,
417    /// Assertion addressed
418    pub assertion_addressed: Assertion,
419    /// Assigned to user ID
420    pub assigned_to: String,
421    /// Target completion date
422    pub target_date: NaiveDate,
423    /// Status
424    pub status: ResponseStatus,
425    /// Workpaper reference when complete
426    pub workpaper_ref: Option<Uuid>,
427}
428
429impl PlannedResponse {
430    /// Create a new planned response.
431    pub fn new(
432        procedure: &str,
433        procedure_type: ResponseProcedureType,
434        assertion: Assertion,
435        assigned_to: &str,
436        target_date: NaiveDate,
437    ) -> Self {
438        Self {
439            response_id: Uuid::new_v4(),
440            procedure: procedure.into(),
441            procedure_type,
442            assertion_addressed: assertion,
443            assigned_to: assigned_to.into(),
444            target_date,
445            status: ResponseStatus::Planned,
446            workpaper_ref: None,
447        }
448    }
449}
450
451/// Type of response procedure.
452#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize, Default)]
453#[serde(rename_all = "snake_case")]
454pub enum ResponseProcedureType {
455    /// Test of controls
456    TestOfControls,
457    /// Substantive analytical procedure
458    AnalyticalProcedure,
459    /// Substantive test of details
460    #[default]
461    TestOfDetails,
462    /// External confirmation
463    Confirmation,
464    /// Physical inspection
465    PhysicalInspection,
466    /// Inquiry
467    Inquiry,
468}
469
470/// Nature of audit response.
471#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize, Default)]
472#[serde(rename_all = "snake_case")]
473pub enum ResponseNature {
474    /// Substantive procedures only
475    SubstantiveOnly,
476    /// Controls reliance with reduced substantive
477    ControlsReliance,
478    /// Combined approach
479    #[default]
480    Combined,
481}
482
483/// Timing of audit response.
484#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize, Default)]
485#[serde(rename_all = "snake_case")]
486pub enum ResponseTiming {
487    /// Interim testing
488    Interim,
489    /// Year-end testing
490    #[default]
491    YearEnd,
492    /// Roll-forward from interim
493    RollForward,
494    /// Subsequent events testing
495    Subsequent,
496}
497
498/// Status of planned response.
499#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize, Default)]
500#[serde(rename_all = "snake_case")]
501pub enum ResponseStatus {
502    /// Planned but not started
503    #[default]
504    Planned,
505    /// In progress
506    InProgress,
507    /// Complete
508    Complete,
509    /// Deferred
510    Deferred,
511    /// Not required
512    NotRequired,
513}
514
515/// Risk review status.
516#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize, Default)]
517#[serde(rename_all = "snake_case")]
518pub enum RiskReviewStatus {
519    /// Draft assessment
520    #[default]
521    Draft,
522    /// Pending review
523    PendingReview,
524    /// Reviewed and approved
525    Approved,
526    /// Requires revision
527    RequiresRevision,
528}
529
530/// Detection risk level (inverse of ROMM for achieving acceptable AR).
531#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
532#[serde(rename_all = "snake_case")]
533pub enum DetectionRisk {
534    /// Can accept high detection risk (less testing)
535    High,
536    /// Medium detection risk
537    Medium,
538    /// Low detection risk required (more testing)
539    Low,
540}
541
542#[cfg(test)]
543#[allow(clippy::unwrap_used)]
544mod tests {
545    use super::*;
546
547    #[test]
548    fn test_risk_assessment_creation() {
549        let risk = RiskAssessment::new(
550            Uuid::new_v4(),
551            RiskCategory::AssertionLevel,
552            "Revenue",
553            "Risk of fictitious revenue recognition",
554        )
555        .with_assertion(Assertion::Occurrence)
556        .with_risk_levels(RiskLevel::High, RiskLevel::Medium);
557
558        assert!(risk.inherent_risk == RiskLevel::High);
559        assert!(
560            risk.requires_special_consideration()
561                || risk.risk_of_material_misstatement != RiskLevel::Low
562        );
563    }
564
565    #[test]
566    fn test_significant_risk() {
567        let risk = RiskAssessment::new(
568            Uuid::new_v4(),
569            RiskCategory::FraudRisk,
570            "Revenue",
571            "Fraud risk in revenue recognition",
572        )
573        .mark_significant("Presumed fraud risk per ISA 240");
574
575        assert!(risk.is_significant_risk);
576        assert!(risk.requires_special_consideration());
577    }
578
579    #[test]
580    fn test_fraud_risk_factor() {
581        let factor = FraudRiskFactor::new(
582            FraudTriangleElement::Pressure,
583            "Management bonus tied to revenue targets",
584            75,
585            "Bonus plan review",
586        )
587        .with_trend(Trend::Increasing);
588
589        assert_eq!(factor.factor_type, FraudTriangleElement::Pressure);
590        assert_eq!(factor.score, 75);
591    }
592
593    #[test]
594    fn test_detection_risk() {
595        let risk = RiskAssessment::new(
596            Uuid::new_v4(),
597            RiskCategory::AssertionLevel,
598            "Cash",
599            "Low risk account",
600        )
601        .with_risk_levels(RiskLevel::Low, RiskLevel::Low);
602
603        assert_eq!(risk.required_detection_risk(), DetectionRisk::High);
604    }
605}