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 #[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 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 pub fn with_assertion(mut self, assertion: Assertion) -> Self {
224 self.assertion = Some(assertion);
225 self
226 }
227
228 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 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 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 pub fn add_response(&mut self, response: PlannedResponse) {
252 self.planned_response.push(response);
253 self.updated_at = Utc::now();
254 }
255
256 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 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 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 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 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#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize, Default)]
306#[serde(rename_all = "snake_case")]
307pub enum RiskCategory {
308 FinancialStatementLevel,
310 #[default]
312 AssertionLevel,
313 FraudRisk,
315 GoingConcern,
317 RelatedParty,
319 EstimateRisk,
321 ItGeneralControl,
323 RegulatoryCompliance,
325}
326
327#[derive(Debug, Clone, Serialize, Deserialize)]
329pub struct FraudRiskFactor {
330 pub factor_id: Uuid,
332 pub factor_type: FraudTriangleElement,
334 pub indicator: String,
336 pub score: u8,
338 pub trend: Trend,
340 pub source: String,
342 pub identified_date: NaiveDate,
344}
345
346impl FraudRiskFactor {
347 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 pub fn with_trend(mut self, trend: Trend) -> Self {
367 self.trend = trend;
368 self
369 }
370}
371
372#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
374#[serde(rename_all = "snake_case")]
375pub enum FraudTriangleElement {
376 Opportunity,
378 Pressure,
380 Rationalization,
382}
383
384impl FraudTriangleElement {
385 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#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize, Default)]
397#[serde(rename_all = "snake_case")]
398pub enum Trend {
399 Increasing,
401 #[default]
403 Stable,
404 Decreasing,
406}
407
408#[derive(Debug, Clone, Serialize, Deserialize)]
410pub struct PlannedResponse {
411 pub response_id: Uuid,
413 pub procedure: String,
415 pub procedure_type: ResponseProcedureType,
417 pub assertion_addressed: Assertion,
419 pub assigned_to: String,
421 pub target_date: NaiveDate,
423 pub status: ResponseStatus,
425 pub workpaper_ref: Option<Uuid>,
427}
428
429impl PlannedResponse {
430 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#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize, Default)]
453#[serde(rename_all = "snake_case")]
454pub enum ResponseProcedureType {
455 TestOfControls,
457 AnalyticalProcedure,
459 #[default]
461 TestOfDetails,
462 Confirmation,
464 PhysicalInspection,
466 Inquiry,
468}
469
470#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize, Default)]
472#[serde(rename_all = "snake_case")]
473pub enum ResponseNature {
474 SubstantiveOnly,
476 ControlsReliance,
478 #[default]
480 Combined,
481}
482
483#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize, Default)]
485#[serde(rename_all = "snake_case")]
486pub enum ResponseTiming {
487 Interim,
489 #[default]
491 YearEnd,
492 RollForward,
494 Subsequent,
496}
497
498#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize, Default)]
500#[serde(rename_all = "snake_case")]
501pub enum ResponseStatus {
502 #[default]
504 Planned,
505 InProgress,
507 Complete,
509 Deferred,
511 NotRequired,
513}
514
515#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize, Default)]
517#[serde(rename_all = "snake_case")]
518pub enum RiskReviewStatus {
519 #[default]
521 Draft,
522 PendingReview,
524 Approved,
526 RequiresRevision,
528}
529
530#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
532#[serde(rename_all = "snake_case")]
533pub enum DetectionRisk {
534 High,
536 Medium,
538 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}