Skip to main content

datasynth_core/models/audit/
finding.rs

1//! Audit finding and issue models per ISA 265.
2//!
3//! Findings represent deficiencies identified during the audit,
4//! ranging from control deficiencies to material misstatements.
5
6use 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/// Audit finding representing an identified issue.
15#[derive(Debug, Clone, Serialize, Deserialize)]
16pub struct AuditFinding {
17    /// Unique finding ID
18    pub finding_id: Uuid,
19    /// External reference (e.g., "FIND-2025-001")
20    pub finding_ref: String,
21    /// Engagement ID
22    pub engagement_id: Uuid,
23    /// Finding type/classification
24    pub finding_type: FindingType,
25    /// Severity level
26    pub severity: FindingSeverity,
27    /// Finding title
28    pub title: String,
29
30    // === Finding Details (Condition, Criteria, Cause, Effect) ===
31    /// Condition: What we found
32    pub condition: String,
33    /// Criteria: What it should be (standard/policy)
34    pub criteria: String,
35    /// Cause: Why it happened
36    pub cause: String,
37    /// Effect: Impact/consequence
38    pub effect: String,
39
40    // === Quantification ===
41    /// Monetary impact if quantifiable
42    pub monetary_impact: Option<Decimal>,
43    /// Is this a known misstatement?
44    pub is_misstatement: bool,
45    /// Projected misstatement (for sampling)
46    pub projected_misstatement: Option<Decimal>,
47    /// Factual misstatement
48    pub factual_misstatement: Option<Decimal>,
49    /// Judgmental misstatement
50    pub judgmental_misstatement: Option<Decimal>,
51
52    // === Recommendations ===
53    /// Recommendation for remediation
54    pub recommendation: String,
55    /// Management response
56    pub management_response: Option<String>,
57    /// Management response date
58    pub management_response_date: Option<NaiveDate>,
59    /// Does management agree?
60    pub management_agrees: Option<bool>,
61
62    // === Remediation ===
63    /// Remediation plan
64    pub remediation_plan: Option<RemediationPlan>,
65    /// Finding status
66    pub status: FindingStatus,
67
68    // === Assertions & Accounts ===
69    /// Assertions affected
70    pub assertions_affected: Vec<Assertion>,
71    /// Account IDs affected
72    pub accounts_affected: Vec<String>,
73    /// Process areas affected
74    pub process_areas: Vec<String>,
75
76    // === Relationship Linkage ===
77    /// Control IDs related to this finding (populated at generation time)
78    pub related_control_ids: Vec<String>,
79    /// Risk ID this finding was raised against (populated at generation time)
80    pub related_risk_id: Option<String>,
81    /// Primary workpaper ID that documents this finding
82    pub workpaper_id: Option<String>,
83
84    // === References ===
85    /// Supporting workpaper IDs
86    pub workpaper_refs: Vec<Uuid>,
87    /// Supporting evidence IDs
88    pub evidence_refs: Vec<Uuid>,
89    /// Related finding IDs (if recurring)
90    pub related_findings: Vec<Uuid>,
91    /// Prior year finding ID if recurring
92    pub prior_year_finding_id: Option<Uuid>,
93
94    // === Reporting ===
95    /// Include in management letter?
96    pub include_in_management_letter: bool,
97    /// Report to those charged with governance?
98    pub report_to_governance: bool,
99    /// Communicated date
100    pub communicated_date: Option<NaiveDate>,
101
102    // === Metadata ===
103    /// Identified by user ID
104    pub identified_by: String,
105    /// Date identified
106    pub identified_date: NaiveDate,
107    /// Reviewed by
108    pub reviewed_by: Option<String>,
109    /// Review date
110    pub review_date: Option<NaiveDate>,
111
112    pub created_at: DateTime<Utc>,
113    pub updated_at: DateTime<Utc>,
114}
115
116impl AuditFinding {
117    /// Create a new audit finding.
118    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    /// Set the finding details (condition, criteria, cause, effect).
165    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    /// Set monetary impact.
180    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    /// Set misstatement details.
187    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    /// Set recommendation.
201    pub fn with_recommendation(mut self, recommendation: &str) -> Self {
202        self.recommendation = recommendation.into();
203        self
204    }
205
206    /// Add management response.
207    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    /// Set remediation plan.
216    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    /// Mark for reporting.
223    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    /// Get total misstatement amount.
230    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    /// Check if this is a material weakness (for SOX).
238    pub fn is_material_weakness(&self) -> bool {
239        matches!(self.finding_type, FindingType::MaterialWeakness)
240    }
241
242    /// Check if this requires governance communication per ISA 260.
243    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/// Type of audit finding per ISA 265.
255#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize, Default)]
256#[serde(rename_all = "snake_case")]
257pub enum FindingType {
258    /// Material weakness in internal control
259    MaterialWeakness,
260    /// Significant deficiency in internal control
261    SignificantDeficiency,
262    /// Control deficiency (not significant)
263    #[default]
264    ControlDeficiency,
265    /// Material misstatement in financial statements
266    MaterialMisstatement,
267    /// Immaterial misstatement
268    ImmaterialMisstatement,
269    /// Compliance exception
270    ComplianceException,
271    /// Other matter for management attention
272    OtherMatter,
273    /// IT-related deficiency
274    ItDeficiency,
275    /// Process improvement opportunity
276    ProcessImprovement,
277}
278
279impl FindingType {
280    /// Get the default severity for this finding type.
281    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    /// Get ISA reference for this finding type.
294    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    /// Check if this type requires SOX reporting.
306    pub fn requires_sox_reporting(&self) -> bool {
307        matches!(self, Self::MaterialWeakness | Self::SignificantDeficiency)
308    }
309}
310
311/// Finding severity level.
312#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize, Default)]
313#[serde(rename_all = "snake_case")]
314pub enum FindingSeverity {
315    /// Critical - immediate attention required
316    Critical,
317    /// High - significant impact
318    High,
319    /// Medium - moderate impact
320    #[default]
321    Medium,
322    /// Low - minor impact
323    Low,
324    /// Informational only
325    Informational,
326}
327
328impl FindingSeverity {
329    /// Get numeric score for prioritization.
330    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    /// Convert to risk level.
341    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/// Finding status.
352#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize, Default)]
353#[serde(rename_all = "snake_case")]
354pub enum FindingStatus {
355    /// Draft - being documented
356    #[default]
357    Draft,
358    /// Pending review
359    PendingReview,
360    /// Awaiting management response
361    AwaitingResponse,
362    /// Management has responded
363    ManagementResponse,
364    /// Remediation planned
365    RemediationPlanned,
366    /// Remediation in progress
367    RemediationInProgress,
368    /// Remediation complete - pending validation
369    PendingValidation,
370    /// Validated and closed
371    Closed,
372    /// Deferred to future period
373    Deferred,
374    /// Not applicable / withdrawn
375    NotApplicable,
376}
377
378/// Remediation plan for a finding.
379#[derive(Debug, Clone, Serialize, Deserialize)]
380pub struct RemediationPlan {
381    /// Plan ID
382    pub plan_id: Uuid,
383    /// Finding ID this plan addresses
384    pub finding_id: Uuid,
385    /// Plan description
386    pub description: String,
387    /// Responsible party
388    pub responsible_party: String,
389    /// Target completion date
390    pub target_date: NaiveDate,
391    /// Actual completion date
392    pub actual_completion_date: Option<NaiveDate>,
393    /// Plan status
394    pub status: RemediationStatus,
395    /// Validation approach
396    pub validation_approach: String,
397    /// Validated by auditor ID
398    pub validated_by: Option<String>,
399    /// Validation date
400    pub validated_date: Option<NaiveDate>,
401    /// Validation result
402    pub validation_result: Option<ValidationResult>,
403    /// Milestones
404    pub milestones: Vec<RemediationMilestone>,
405    /// Notes
406    pub notes: String,
407    pub created_at: DateTime<Utc>,
408    pub updated_at: DateTime<Utc>,
409}
410
411impl RemediationPlan {
412    /// Create a new remediation plan.
413    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    /// Add a milestone.
440    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    /// Mark as complete.
452    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    /// Add validation result.
459    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    /// Check if plan is overdue.
472    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/// Remediation plan status.
483#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize, Default)]
484#[serde(rename_all = "snake_case")]
485pub enum RemediationStatus {
486    /// Planned but not started
487    #[default]
488    Planned,
489    /// In progress
490    InProgress,
491    /// Complete - pending validation
492    Complete,
493    /// Validated as effective
494    Validated,
495    /// Partially validated
496    PartiallyValidated,
497    /// Failed validation
498    Failed,
499    /// Deferred
500    Deferred,
501}
502
503/// Validation result.
504#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
505#[serde(rename_all = "snake_case")]
506pub enum ValidationResult {
507    /// Remediation is effective
508    Effective,
509    /// Partially effective
510    PartiallyEffective,
511    /// Not effective
512    Ineffective,
513}
514
515/// Remediation milestone.
516#[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/// Milestone status.
526#[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}