1use 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#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Hash, Serialize, Deserialize)]
21#[serde(rename_all = "snake_case")]
22pub enum RiskStatus {
23 #[default]
25 Active,
26 Mitigated,
28 Accepted,
30 Closed,
32}
33
34fn 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 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 let frac = (hash as f64) / (u64::MAX as f64);
59 lo + frac * (hi - lo)
60}
61
62#[derive(Debug, Clone, Serialize, Deserialize)]
64pub struct RiskAssessment {
65 pub risk_id: Uuid,
67 pub risk_ref: String,
69 pub engagement_id: Uuid,
71 pub risk_category: RiskCategory,
73 pub account_or_process: String,
75 pub assertion: Option<Assertion>,
77 pub description: String,
79
80 pub inherent_risk: RiskLevel,
83 pub control_risk: RiskLevel,
85 pub risk_of_material_misstatement: RiskLevel,
87 pub is_significant_risk: bool,
89 pub significant_risk_rationale: Option<String>,
91
92 pub inherent_impact: f64,
95 pub inherent_likelihood: f64,
97 pub residual_impact: f64,
99 pub residual_likelihood: f64,
101 pub risk_score: f64,
103
104 pub risk_name: String,
107
108 pub mitigating_control_count: u32,
111 pub effective_control_count: u32,
113
114 pub status: RiskStatus,
117
118 pub fraud_risk_factors: Vec<FraudRiskFactor>,
121 pub presumed_revenue_fraud_risk: bool,
123 pub presumed_management_override: bool,
125
126 pub planned_response: Vec<PlannedResponse>,
129 pub response_nature: ResponseNature,
131 pub response_extent: String,
133 pub response_timing: ResponseTiming,
135
136 pub assessed_by: String,
139 pub assessed_date: NaiveDate,
141 pub review_status: RiskReviewStatus,
143 pub reviewer_id: Option<String>,
145 pub review_date: Option<NaiveDate>,
147
148 pub workpaper_refs: Vec<Uuid>,
151 pub related_controls: Vec<String>,
153
154 pub created_at: DateTime<Utc>,
155 pub updated_at: DateTime<Utc>,
156}
157
158impl RiskAssessment {
159 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 pub fn with_assertion(mut self, assertion: Assertion) -> Self {
222 self.assertion = Some(assertion);
223 self
224 }
225
226 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 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 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 pub fn add_response(&mut self, response: PlannedResponse) {
250 self.planned_response.push(response);
251 self.updated_at = Utc::now();
252 }
253
254 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 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 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 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 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#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize, Default)]
304#[serde(rename_all = "snake_case")]
305pub enum RiskCategory {
306 FinancialStatementLevel,
308 #[default]
310 AssertionLevel,
311 FraudRisk,
313 GoingConcern,
315 RelatedParty,
317 EstimateRisk,
319 ItGeneralControl,
321 RegulatoryCompliance,
323}
324
325#[derive(Debug, Clone, Serialize, Deserialize)]
327pub struct FraudRiskFactor {
328 pub factor_id: Uuid,
330 pub factor_type: FraudTriangleElement,
332 pub indicator: String,
334 pub score: u8,
336 pub trend: Trend,
338 pub source: String,
340 pub identified_date: NaiveDate,
342}
343
344impl FraudRiskFactor {
345 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 pub fn with_trend(mut self, trend: Trend) -> Self {
365 self.trend = trend;
366 self
367 }
368}
369
370#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
372#[serde(rename_all = "snake_case")]
373pub enum FraudTriangleElement {
374 Opportunity,
376 Pressure,
378 Rationalization,
380}
381
382impl FraudTriangleElement {
383 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#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize, Default)]
395#[serde(rename_all = "snake_case")]
396pub enum Trend {
397 Increasing,
399 #[default]
401 Stable,
402 Decreasing,
404}
405
406#[derive(Debug, Clone, Serialize, Deserialize)]
408pub struct PlannedResponse {
409 pub response_id: Uuid,
411 pub procedure: String,
413 pub procedure_type: ResponseProcedureType,
415 pub assertion_addressed: Assertion,
417 pub assigned_to: String,
419 pub target_date: NaiveDate,
421 pub status: ResponseStatus,
423 pub workpaper_ref: Option<Uuid>,
425}
426
427impl PlannedResponse {
428 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#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize, Default)]
451#[serde(rename_all = "snake_case")]
452pub enum ResponseProcedureType {
453 TestOfControls,
455 AnalyticalProcedure,
457 #[default]
459 TestOfDetails,
460 Confirmation,
462 PhysicalInspection,
464 Inquiry,
466}
467
468#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize, Default)]
470#[serde(rename_all = "snake_case")]
471pub enum ResponseNature {
472 SubstantiveOnly,
474 ControlsReliance,
476 #[default]
478 Combined,
479}
480
481#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize, Default)]
483#[serde(rename_all = "snake_case")]
484pub enum ResponseTiming {
485 Interim,
487 #[default]
489 YearEnd,
490 RollForward,
492 Subsequent,
494}
495
496#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize, Default)]
498#[serde(rename_all = "snake_case")]
499pub enum ResponseStatus {
500 #[default]
502 Planned,
503 InProgress,
505 Complete,
507 Deferred,
509 NotRequired,
511}
512
513#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize, Default)]
515#[serde(rename_all = "snake_case")]
516pub enum RiskReviewStatus {
517 #[default]
519 Draft,
520 PendingReview,
522 Approved,
524 RequiresRevision,
526}
527
528#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
530#[serde(rename_all = "snake_case")]
531pub enum DetectionRisk {
532 High,
534 Medium,
536 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}