use std::hash::{Hash, Hasher};
use chrono::{DateTime, NaiveDate, Utc};
use serde::{Deserialize, Serialize};
use uuid::Uuid;
use super::engagement::RiskLevel;
use super::workpaper::Assertion;
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Hash, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum RiskStatus {
#[default]
Active,
Mitigated,
Accepted,
Closed,
}
fn continuous_score(level: &RiskLevel, risk_id: &Uuid, discriminator: u8) -> f64 {
let (lo, hi) = match level {
RiskLevel::Low => (0.15, 0.35),
RiskLevel::Medium => (0.35, 0.55),
RiskLevel::High => (0.55, 0.80),
RiskLevel::Significant => (0.80, 0.95),
};
let mut hasher = std::collections::hash_map::DefaultHasher::new();
risk_id.hash(&mut hasher);
discriminator.hash(&mut hasher);
let hash = hasher.finish();
let frac = (hash as f64) / (u64::MAX as f64);
lo + frac * (hi - lo)
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct RiskAssessment {
pub risk_id: Uuid,
pub risk_ref: String,
pub engagement_id: Uuid,
pub risk_category: RiskCategory,
pub account_or_process: String,
pub assertion: Option<Assertion>,
pub description: String,
pub inherent_risk: RiskLevel,
pub control_risk: RiskLevel,
pub risk_of_material_misstatement: RiskLevel,
pub is_significant_risk: bool,
pub significant_risk_rationale: Option<String>,
pub inherent_impact: f64,
pub inherent_likelihood: f64,
pub residual_impact: f64,
pub residual_likelihood: f64,
pub risk_score: f64,
pub risk_name: String,
pub mitigating_control_count: u32,
pub effective_control_count: u32,
pub status: RiskStatus,
pub fraud_risk_factors: Vec<FraudRiskFactor>,
pub presumed_revenue_fraud_risk: bool,
pub presumed_management_override: bool,
pub planned_response: Vec<PlannedResponse>,
pub response_nature: ResponseNature,
pub response_extent: String,
pub response_timing: ResponseTiming,
pub assessed_by: String,
pub assessed_date: NaiveDate,
pub review_status: RiskReviewStatus,
pub reviewer_id: Option<String>,
pub review_date: Option<NaiveDate>,
pub workpaper_refs: Vec<Uuid>,
pub related_controls: Vec<String>,
#[serde(with = "crate::serde_timestamp::utc")]
pub created_at: DateTime<Utc>,
#[serde(with = "crate::serde_timestamp::utc")]
pub updated_at: DateTime<Utc>,
}
impl RiskAssessment {
pub fn new(
engagement_id: Uuid,
risk_category: RiskCategory,
account_or_process: &str,
description: &str,
) -> Self {
let now = Utc::now();
let risk_id = Uuid::new_v4();
let default_level = RiskLevel::Medium;
let inherent_impact = continuous_score(&default_level, &risk_id, 0);
let inherent_likelihood = continuous_score(&default_level, &risk_id, 1);
let residual_impact = continuous_score(&default_level, &risk_id, 2);
let residual_likelihood = continuous_score(&default_level, &risk_id, 3);
let risk_score = inherent_impact * inherent_likelihood * 100.0;
let risk_name = format!("{} Risk [{:?}]", account_or_process, default_level);
Self {
risk_id,
risk_ref: format!(
"RISK-{}",
Uuid::new_v4().simple().to_string()[..8].to_uppercase()
),
engagement_id,
risk_category,
account_or_process: account_or_process.into(),
assertion: None,
description: description.into(),
inherent_risk: default_level,
control_risk: default_level,
risk_of_material_misstatement: default_level,
is_significant_risk: false,
significant_risk_rationale: None,
inherent_impact,
inherent_likelihood,
residual_impact,
residual_likelihood,
risk_score,
risk_name,
mitigating_control_count: 0,
effective_control_count: 0,
status: RiskStatus::Active,
fraud_risk_factors: Vec::new(),
presumed_revenue_fraud_risk: false,
presumed_management_override: true,
planned_response: Vec::new(),
response_nature: ResponseNature::Combined,
response_extent: String::new(),
response_timing: ResponseTiming::YearEnd,
assessed_by: String::new(),
assessed_date: now.date_naive(),
review_status: RiskReviewStatus::Draft,
reviewer_id: None,
review_date: None,
workpaper_refs: Vec::new(),
related_controls: Vec::new(),
created_at: now,
updated_at: now,
}
}
pub fn with_assertion(mut self, assertion: Assertion) -> Self {
self.assertion = Some(assertion);
self
}
pub fn with_risk_levels(mut self, inherent: RiskLevel, control: RiskLevel) -> Self {
self.inherent_risk = inherent;
self.control_risk = control;
self.risk_of_material_misstatement = self.calculate_romm();
self.recompute_continuous_scores();
self
}
pub fn mark_significant(mut self, rationale: &str) -> Self {
self.is_significant_risk = true;
self.significant_risk_rationale = Some(rationale.into());
self
}
pub fn add_fraud_factor(&mut self, factor: FraudRiskFactor) {
self.fraud_risk_factors.push(factor);
self.updated_at = Utc::now();
}
pub fn add_response(&mut self, response: PlannedResponse) {
self.planned_response.push(response);
self.updated_at = Utc::now();
}
pub fn with_assessed_by(mut self, user_id: &str, date: NaiveDate) -> Self {
self.assessed_by = user_id.into();
self.assessed_date = date;
self
}
fn calculate_romm(&self) -> RiskLevel {
let ir_score = self.inherent_risk.score();
let cr_score = self.control_risk.score();
let combined = (ir_score + cr_score) / 2;
RiskLevel::from_score(combined)
}
fn recompute_continuous_scores(&mut self) {
self.inherent_impact = continuous_score(&self.inherent_risk, &self.risk_id, 0);
self.inherent_likelihood = continuous_score(&self.inherent_risk, &self.risk_id, 1);
self.residual_impact = continuous_score(&self.control_risk, &self.risk_id, 2);
self.residual_likelihood = continuous_score(&self.control_risk, &self.risk_id, 3);
self.risk_score = self.inherent_impact * self.inherent_likelihood * 100.0;
self.risk_name = format!(
"{} Risk [{:?}]",
self.account_or_process, self.inherent_risk
);
}
pub fn required_detection_risk(&self) -> DetectionRisk {
match self.risk_of_material_misstatement {
RiskLevel::Low => DetectionRisk::High,
RiskLevel::Medium => DetectionRisk::Medium,
RiskLevel::High | RiskLevel::Significant => DetectionRisk::Low,
}
}
pub fn requires_special_consideration(&self) -> bool {
self.is_significant_risk
|| matches!(
self.risk_of_material_misstatement,
RiskLevel::High | RiskLevel::Significant
)
|| !self.fraud_risk_factors.is_empty()
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize, Default)]
#[serde(rename_all = "snake_case")]
pub enum RiskCategory {
FinancialStatementLevel,
#[default]
AssertionLevel,
FraudRisk,
GoingConcern,
RelatedParty,
EstimateRisk,
ItGeneralControl,
RegulatoryCompliance,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct FraudRiskFactor {
pub factor_id: Uuid,
pub factor_type: FraudTriangleElement,
pub indicator: String,
pub score: u8,
pub trend: Trend,
pub source: String,
pub identified_date: NaiveDate,
}
impl FraudRiskFactor {
pub fn new(
factor_type: FraudTriangleElement,
indicator: &str,
score: u8,
source: &str,
) -> Self {
Self {
factor_id: Uuid::new_v4(),
factor_type,
indicator: indicator.into(),
score: score.min(100),
trend: Trend::Stable,
source: source.into(),
identified_date: Utc::now().date_naive(),
}
}
pub fn with_trend(mut self, trend: Trend) -> Self {
self.trend = trend;
self
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum FraudTriangleElement {
Opportunity,
Pressure,
Rationalization,
}
impl FraudTriangleElement {
pub fn description(&self) -> &'static str {
match self {
Self::Opportunity => "Circumstances providing opportunity to commit fraud",
Self::Pressure => "Incentives or pressures to commit fraud",
Self::Rationalization => "Attitude or rationalization to justify fraud",
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize, Default)]
#[serde(rename_all = "snake_case")]
pub enum Trend {
Increasing,
#[default]
Stable,
Decreasing,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct PlannedResponse {
pub response_id: Uuid,
pub procedure: String,
pub procedure_type: ResponseProcedureType,
pub assertion_addressed: Assertion,
pub assigned_to: String,
pub target_date: NaiveDate,
pub status: ResponseStatus,
pub workpaper_ref: Option<Uuid>,
}
impl PlannedResponse {
pub fn new(
procedure: &str,
procedure_type: ResponseProcedureType,
assertion: Assertion,
assigned_to: &str,
target_date: NaiveDate,
) -> Self {
Self {
response_id: Uuid::new_v4(),
procedure: procedure.into(),
procedure_type,
assertion_addressed: assertion,
assigned_to: assigned_to.into(),
target_date,
status: ResponseStatus::Planned,
workpaper_ref: None,
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize, Default)]
#[serde(rename_all = "snake_case")]
pub enum ResponseProcedureType {
TestOfControls,
AnalyticalProcedure,
#[default]
TestOfDetails,
Confirmation,
PhysicalInspection,
Inquiry,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize, Default)]
#[serde(rename_all = "snake_case")]
pub enum ResponseNature {
SubstantiveOnly,
ControlsReliance,
#[default]
Combined,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize, Default)]
#[serde(rename_all = "snake_case")]
pub enum ResponseTiming {
Interim,
#[default]
YearEnd,
RollForward,
Subsequent,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize, Default)]
#[serde(rename_all = "snake_case")]
pub enum ResponseStatus {
#[default]
Planned,
InProgress,
Complete,
Deferred,
NotRequired,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize, Default)]
#[serde(rename_all = "snake_case")]
pub enum RiskReviewStatus {
#[default]
Draft,
PendingReview,
Approved,
RequiresRevision,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum DetectionRisk {
High,
Medium,
Low,
}
#[cfg(test)]
#[allow(clippy::unwrap_used)]
mod tests {
use super::*;
#[test]
fn test_risk_assessment_creation() {
let risk = RiskAssessment::new(
Uuid::new_v4(),
RiskCategory::AssertionLevel,
"Revenue",
"Risk of fictitious revenue recognition",
)
.with_assertion(Assertion::Occurrence)
.with_risk_levels(RiskLevel::High, RiskLevel::Medium);
assert!(risk.inherent_risk == RiskLevel::High);
assert!(
risk.requires_special_consideration()
|| risk.risk_of_material_misstatement != RiskLevel::Low
);
}
#[test]
fn test_significant_risk() {
let risk = RiskAssessment::new(
Uuid::new_v4(),
RiskCategory::FraudRisk,
"Revenue",
"Fraud risk in revenue recognition",
)
.mark_significant("Presumed fraud risk per ISA 240");
assert!(risk.is_significant_risk);
assert!(risk.requires_special_consideration());
}
#[test]
fn test_fraud_risk_factor() {
let factor = FraudRiskFactor::new(
FraudTriangleElement::Pressure,
"Management bonus tied to revenue targets",
75,
"Bonus plan review",
)
.with_trend(Trend::Increasing);
assert_eq!(factor.factor_type, FraudTriangleElement::Pressure);
assert_eq!(factor.score, 75);
}
#[test]
fn test_detection_risk() {
let risk = RiskAssessment::new(
Uuid::new_v4(),
RiskCategory::AssertionLevel,
"Cash",
"Low risk account",
)
.with_risk_levels(RiskLevel::Low, RiskLevel::Low);
assert_eq!(risk.required_detection_risk(), DetectionRisk::High);
}
}