1use chrono::{DateTime, NaiveDate, Utc};
7use rust_decimal::Decimal;
8use serde::{Deserialize, Serialize};
9use uuid::Uuid;
10
11use super::engagement::RiskLevel;
12use super::workpaper::Assertion;
13
14#[derive(Debug, Clone, Serialize, Deserialize)]
16pub struct AuditFinding {
17 pub finding_id: Uuid,
19 pub finding_ref: String,
21 pub engagement_id: Uuid,
23 pub finding_type: FindingType,
25 pub severity: FindingSeverity,
27 pub title: String,
29
30 pub condition: String,
33 pub criteria: String,
35 pub cause: String,
37 pub effect: String,
39
40 pub monetary_impact: Option<Decimal>,
43 pub is_misstatement: bool,
45 pub projected_misstatement: Option<Decimal>,
47 pub factual_misstatement: Option<Decimal>,
49 pub judgmental_misstatement: Option<Decimal>,
51
52 pub recommendation: String,
55 pub management_response: Option<String>,
57 pub management_response_date: Option<NaiveDate>,
59 pub management_agrees: Option<bool>,
61
62 pub remediation_plan: Option<RemediationPlan>,
65 pub status: FindingStatus,
67
68 pub assertions_affected: Vec<Assertion>,
71 pub accounts_affected: Vec<String>,
73 pub process_areas: Vec<String>,
75
76 pub related_control_ids: Vec<String>,
79 pub related_risk_id: Option<String>,
81 pub workpaper_id: Option<String>,
83
84 pub workpaper_refs: Vec<Uuid>,
87 pub evidence_refs: Vec<Uuid>,
89 pub related_findings: Vec<Uuid>,
91 pub prior_year_finding_id: Option<Uuid>,
93
94 pub include_in_management_letter: bool,
97 pub report_to_governance: bool,
99 pub communicated_date: Option<NaiveDate>,
101
102 pub identified_by: String,
105 pub identified_date: NaiveDate,
107 pub reviewed_by: Option<String>,
109 pub review_date: Option<NaiveDate>,
111
112 #[serde(with = "crate::serde_timestamp::utc")]
113 pub created_at: DateTime<Utc>,
114 #[serde(with = "crate::serde_timestamp::utc")]
115 pub updated_at: DateTime<Utc>,
116}
117
118impl AuditFinding {
119 pub fn new(engagement_id: Uuid, finding_type: FindingType, title: &str) -> Self {
121 let now = Utc::now();
122 Self {
123 finding_id: Uuid::new_v4(),
124 finding_ref: format!("FIND-{}-{:03}", now.format("%Y"), 1),
125 engagement_id,
126 finding_type,
127 severity: finding_type.default_severity(),
128 title: title.into(),
129 condition: String::new(),
130 criteria: String::new(),
131 cause: String::new(),
132 effect: String::new(),
133 monetary_impact: None,
134 is_misstatement: false,
135 projected_misstatement: None,
136 factual_misstatement: None,
137 judgmental_misstatement: None,
138 recommendation: String::new(),
139 management_response: None,
140 management_response_date: None,
141 management_agrees: None,
142 remediation_plan: None,
143 status: FindingStatus::Draft,
144 assertions_affected: Vec::new(),
145 accounts_affected: Vec::new(),
146 process_areas: Vec::new(),
147 related_control_ids: Vec::new(),
148 related_risk_id: None,
149 workpaper_id: None,
150 workpaper_refs: Vec::new(),
151 evidence_refs: Vec::new(),
152 related_findings: Vec::new(),
153 prior_year_finding_id: None,
154 include_in_management_letter: false,
155 report_to_governance: false,
156 communicated_date: None,
157 identified_by: String::new(),
158 identified_date: now.date_naive(),
159 reviewed_by: None,
160 review_date: None,
161 created_at: now,
162 updated_at: now,
163 }
164 }
165
166 pub fn with_details(
168 mut self,
169 condition: &str,
170 criteria: &str,
171 cause: &str,
172 effect: &str,
173 ) -> Self {
174 self.condition = condition.into();
175 self.criteria = criteria.into();
176 self.cause = cause.into();
177 self.effect = effect.into();
178 self
179 }
180
181 pub fn with_monetary_impact(mut self, impact: Decimal) -> Self {
183 self.monetary_impact = Some(impact);
184 self.is_misstatement = true;
185 self
186 }
187
188 pub fn with_misstatement(
190 mut self,
191 factual: Option<Decimal>,
192 projected: Option<Decimal>,
193 judgmental: Option<Decimal>,
194 ) -> Self {
195 self.factual_misstatement = factual;
196 self.projected_misstatement = projected;
197 self.judgmental_misstatement = judgmental;
198 self.is_misstatement = true;
199 self
200 }
201
202 pub fn with_recommendation(mut self, recommendation: &str) -> Self {
204 self.recommendation = recommendation.into();
205 self
206 }
207
208 pub fn add_management_response(&mut self, response: &str, agrees: bool, date: NaiveDate) {
210 self.management_response = Some(response.into());
211 self.management_agrees = Some(agrees);
212 self.management_response_date = Some(date);
213 self.status = FindingStatus::ManagementResponse;
214 self.updated_at = Utc::now();
215 }
216
217 pub fn with_remediation_plan(&mut self, plan: RemediationPlan) {
219 self.remediation_plan = Some(plan);
220 self.status = FindingStatus::RemediationPlanned;
221 self.updated_at = Utc::now();
222 }
223
224 pub fn mark_for_reporting(&mut self, management_letter: bool, governance: bool) {
226 self.include_in_management_letter = management_letter;
227 self.report_to_governance = governance;
228 self.updated_at = Utc::now();
229 }
230
231 pub fn total_misstatement(&self) -> Decimal {
233 let factual = self.factual_misstatement.unwrap_or_default();
234 let projected = self.projected_misstatement.unwrap_or_default();
235 let judgmental = self.judgmental_misstatement.unwrap_or_default();
236 factual + projected + judgmental
237 }
238
239 pub fn is_material_weakness(&self) -> bool {
241 matches!(self.finding_type, FindingType::MaterialWeakness)
242 }
243
244 pub fn requires_governance_communication(&self) -> bool {
246 matches!(
247 self.finding_type,
248 FindingType::MaterialWeakness | FindingType::SignificantDeficiency
249 ) || matches!(
250 self.severity,
251 FindingSeverity::Critical | FindingSeverity::High
252 )
253 }
254}
255
256#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize, Default)]
258#[serde(rename_all = "snake_case")]
259pub enum FindingType {
260 MaterialWeakness,
262 SignificantDeficiency,
264 #[default]
266 ControlDeficiency,
267 MaterialMisstatement,
269 ImmaterialMisstatement,
271 ComplianceException,
273 OtherMatter,
275 ItDeficiency,
277 ProcessImprovement,
279}
280
281impl FindingType {
282 pub fn default_severity(&self) -> FindingSeverity {
284 match self {
285 Self::MaterialWeakness => FindingSeverity::Critical,
286 Self::SignificantDeficiency => FindingSeverity::High,
287 Self::MaterialMisstatement => FindingSeverity::Critical,
288 Self::ControlDeficiency | Self::ImmaterialMisstatement => FindingSeverity::Medium,
289 Self::ComplianceException => FindingSeverity::Medium,
290 Self::OtherMatter | Self::ProcessImprovement => FindingSeverity::Low,
291 Self::ItDeficiency => FindingSeverity::Medium,
292 }
293 }
294
295 pub fn isa_reference(&self) -> &'static str {
297 match self {
298 Self::MaterialWeakness | Self::SignificantDeficiency | Self::ControlDeficiency => {
299 "ISA 265"
300 }
301 Self::MaterialMisstatement | Self::ImmaterialMisstatement => "ISA 450",
302 Self::ComplianceException => "ISA 250",
303 _ => "ISA 260",
304 }
305 }
306
307 pub fn requires_sox_reporting(&self) -> bool {
309 matches!(self, Self::MaterialWeakness | Self::SignificantDeficiency)
310 }
311}
312
313#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize, Default)]
315#[serde(rename_all = "snake_case")]
316pub enum FindingSeverity {
317 Critical,
319 High,
321 #[default]
323 Medium,
324 Low,
326 Informational,
328}
329
330impl FindingSeverity {
331 pub fn score(&self) -> u8 {
333 match self {
334 Self::Critical => 5,
335 Self::High => 4,
336 Self::Medium => 3,
337 Self::Low => 2,
338 Self::Informational => 1,
339 }
340 }
341
342 pub fn to_risk_level(&self) -> RiskLevel {
344 match self {
345 Self::Critical => RiskLevel::Significant,
346 Self::High => RiskLevel::High,
347 Self::Medium => RiskLevel::Medium,
348 Self::Low | Self::Informational => RiskLevel::Low,
349 }
350 }
351}
352
353#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize, Default)]
355#[serde(rename_all = "snake_case")]
356pub enum FindingStatus {
357 #[default]
359 Draft,
360 PendingReview,
362 AwaitingResponse,
364 ManagementResponse,
366 RemediationPlanned,
368 RemediationInProgress,
370 PendingValidation,
372 Closed,
374 Deferred,
376 NotApplicable,
378}
379
380#[derive(Debug, Clone, Serialize, Deserialize)]
382pub struct RemediationPlan {
383 pub plan_id: Uuid,
385 pub finding_id: Uuid,
387 pub description: String,
389 pub responsible_party: String,
391 pub target_date: NaiveDate,
393 pub actual_completion_date: Option<NaiveDate>,
395 pub status: RemediationStatus,
397 pub validation_approach: String,
399 pub validated_by: Option<String>,
401 pub validated_date: Option<NaiveDate>,
403 pub validation_result: Option<ValidationResult>,
405 pub milestones: Vec<RemediationMilestone>,
407 pub notes: String,
409 #[serde(with = "crate::serde_timestamp::utc")]
410 pub created_at: DateTime<Utc>,
411 #[serde(with = "crate::serde_timestamp::utc")]
412 pub updated_at: DateTime<Utc>,
413}
414
415impl RemediationPlan {
416 pub fn new(
418 finding_id: Uuid,
419 description: &str,
420 responsible_party: &str,
421 target_date: NaiveDate,
422 ) -> Self {
423 let now = Utc::now();
424 Self {
425 plan_id: Uuid::new_v4(),
426 finding_id,
427 description: description.into(),
428 responsible_party: responsible_party.into(),
429 target_date,
430 actual_completion_date: None,
431 status: RemediationStatus::Planned,
432 validation_approach: String::new(),
433 validated_by: None,
434 validated_date: None,
435 validation_result: None,
436 milestones: Vec::new(),
437 notes: String::new(),
438 created_at: now,
439 updated_at: now,
440 }
441 }
442
443 pub fn add_milestone(&mut self, description: &str, target_date: NaiveDate) {
445 self.milestones.push(RemediationMilestone {
446 milestone_id: Uuid::new_v4(),
447 description: description.into(),
448 target_date,
449 completion_date: None,
450 status: MilestoneStatus::Pending,
451 });
452 self.updated_at = Utc::now();
453 }
454
455 pub fn mark_complete(&mut self, completion_date: NaiveDate) {
457 self.actual_completion_date = Some(completion_date);
458 self.status = RemediationStatus::Complete;
459 self.updated_at = Utc::now();
460 }
461
462 pub fn validate(&mut self, validator: &str, date: NaiveDate, result: ValidationResult) {
464 self.validated_by = Some(validator.into());
465 self.validated_date = Some(date);
466 self.validation_result = Some(result);
467 self.status = match result {
468 ValidationResult::Effective => RemediationStatus::Validated,
469 ValidationResult::PartiallyEffective => RemediationStatus::PartiallyValidated,
470 ValidationResult::Ineffective => RemediationStatus::Failed,
471 };
472 self.updated_at = Utc::now();
473 }
474
475 pub fn is_overdue(&self) -> bool {
477 self.actual_completion_date.is_none()
478 && Utc::now().date_naive() > self.target_date
479 && !matches!(
480 self.status,
481 RemediationStatus::Complete | RemediationStatus::Validated
482 )
483 }
484}
485
486#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize, Default)]
488#[serde(rename_all = "snake_case")]
489pub enum RemediationStatus {
490 #[default]
492 Planned,
493 InProgress,
495 Complete,
497 Validated,
499 PartiallyValidated,
501 Failed,
503 Deferred,
505}
506
507#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
509#[serde(rename_all = "snake_case")]
510pub enum ValidationResult {
511 Effective,
513 PartiallyEffective,
515 Ineffective,
517}
518
519#[derive(Debug, Clone, Serialize, Deserialize)]
521pub struct RemediationMilestone {
522 pub milestone_id: Uuid,
523 pub description: String,
524 pub target_date: NaiveDate,
525 pub completion_date: Option<NaiveDate>,
526 pub status: MilestoneStatus,
527}
528
529#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize, Default)]
531#[serde(rename_all = "snake_case")]
532pub enum MilestoneStatus {
533 #[default]
534 Pending,
535 InProgress,
536 Complete,
537 Overdue,
538}
539
540#[cfg(test)]
541#[allow(clippy::unwrap_used)]
542mod tests {
543 use super::*;
544
545 #[test]
546 fn test_finding_creation() {
547 let finding = AuditFinding::new(
548 Uuid::new_v4(),
549 FindingType::ControlDeficiency,
550 "Inadequate segregation of duties",
551 )
552 .with_details(
553 "Same person can create and approve POs",
554 "SOD policy requires separation",
555 "Staffing constraints",
556 "Risk of unauthorized purchases",
557 );
558
559 assert_eq!(finding.finding_type, FindingType::ControlDeficiency);
560 assert!(!finding.condition.is_empty());
561 }
562
563 #[test]
564 fn test_material_weakness() {
565 let finding = AuditFinding::new(
566 Uuid::new_v4(),
567 FindingType::MaterialWeakness,
568 "Lack of revenue cut-off controls",
569 );
570
571 assert!(finding.is_material_weakness());
572 assert!(finding.requires_governance_communication());
573 assert_eq!(finding.severity, FindingSeverity::Critical);
574 }
575
576 #[test]
577 fn test_remediation_plan() {
578 let mut plan = RemediationPlan::new(
579 Uuid::new_v4(),
580 "Implement automated SOD controls",
581 "IT Manager",
582 NaiveDate::from_ymd_opt(2025, 6, 30).unwrap(),
583 );
584
585 plan.add_milestone(
586 "Complete requirements gathering",
587 NaiveDate::from_ymd_opt(2025, 3, 31).unwrap(),
588 );
589
590 assert_eq!(plan.milestones.len(), 1);
591 assert_eq!(plan.status, RemediationStatus::Planned);
592 }
593
594 #[test]
595 fn test_misstatement_total() {
596 let finding = AuditFinding::new(
597 Uuid::new_v4(),
598 FindingType::ImmaterialMisstatement,
599 "Revenue overstatement",
600 )
601 .with_misstatement(
602 Some(Decimal::new(10000, 0)),
603 Some(Decimal::new(5000, 0)),
604 Some(Decimal::new(2000, 0)),
605 );
606
607 assert_eq!(finding.total_misstatement(), Decimal::new(17000, 0));
608 }
609}