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