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