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    // === References ===
77    /// Supporting workpaper IDs
78    pub workpaper_refs: Vec<Uuid>,
79    /// Supporting evidence IDs
80    pub evidence_refs: Vec<Uuid>,
81    /// Related finding IDs (if recurring)
82    pub related_findings: Vec<Uuid>,
83    /// Prior year finding ID if recurring
84    pub prior_year_finding_id: Option<Uuid>,
85
86    // === Reporting ===
87    /// Include in management letter?
88    pub include_in_management_letter: bool,
89    /// Report to those charged with governance?
90    pub report_to_governance: bool,
91    /// Communicated date
92    pub communicated_date: Option<NaiveDate>,
93
94    // === Metadata ===
95    /// Identified by user ID
96    pub identified_by: String,
97    /// Date identified
98    pub identified_date: NaiveDate,
99    /// Reviewed by
100    pub reviewed_by: Option<String>,
101    /// Review date
102    pub review_date: Option<NaiveDate>,
103
104    pub created_at: DateTime<Utc>,
105    pub updated_at: DateTime<Utc>,
106}
107
108impl AuditFinding {
109    /// Create a new audit finding.
110    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    /// Set the finding details (condition, criteria, cause, effect).
154    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    /// Set monetary impact.
169    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    /// Set misstatement details.
176    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    /// Set recommendation.
190    pub fn with_recommendation(mut self, recommendation: &str) -> Self {
191        self.recommendation = recommendation.into();
192        self
193    }
194
195    /// Add management response.
196    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    /// Set remediation plan.
205    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    /// Mark for reporting.
212    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    /// Get total misstatement amount.
219    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    /// Check if this is a material weakness (for SOX).
227    pub fn is_material_weakness(&self) -> bool {
228        matches!(self.finding_type, FindingType::MaterialWeakness)
229    }
230
231    /// Check if this requires governance communication per ISA 260.
232    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/// Type of audit finding per ISA 265.
244#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize, Default)]
245#[serde(rename_all = "snake_case")]
246pub enum FindingType {
247    /// Material weakness in internal control
248    MaterialWeakness,
249    /// Significant deficiency in internal control
250    SignificantDeficiency,
251    /// Control deficiency (not significant)
252    #[default]
253    ControlDeficiency,
254    /// Material misstatement in financial statements
255    MaterialMisstatement,
256    /// Immaterial misstatement
257    ImmaterialMisstatement,
258    /// Compliance exception
259    ComplianceException,
260    /// Other matter for management attention
261    OtherMatter,
262    /// IT-related deficiency
263    ItDeficiency,
264    /// Process improvement opportunity
265    ProcessImprovement,
266}
267
268impl FindingType {
269    /// Get the default severity for this finding type.
270    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    /// Get ISA reference for this finding type.
283    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    /// Check if this type requires SOX reporting.
295    pub fn requires_sox_reporting(&self) -> bool {
296        matches!(self, Self::MaterialWeakness | Self::SignificantDeficiency)
297    }
298}
299
300/// Finding severity level.
301#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize, Default)]
302#[serde(rename_all = "snake_case")]
303pub enum FindingSeverity {
304    /// Critical - immediate attention required
305    Critical,
306    /// High - significant impact
307    High,
308    /// Medium - moderate impact
309    #[default]
310    Medium,
311    /// Low - minor impact
312    Low,
313    /// Informational only
314    Informational,
315}
316
317impl FindingSeverity {
318    /// Get numeric score for prioritization.
319    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    /// Convert to risk level.
330    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/// Finding status.
341#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize, Default)]
342#[serde(rename_all = "snake_case")]
343pub enum FindingStatus {
344    /// Draft - being documented
345    #[default]
346    Draft,
347    /// Pending review
348    PendingReview,
349    /// Awaiting management response
350    AwaitingResponse,
351    /// Management has responded
352    ManagementResponse,
353    /// Remediation planned
354    RemediationPlanned,
355    /// Remediation in progress
356    RemediationInProgress,
357    /// Remediation complete - pending validation
358    PendingValidation,
359    /// Validated and closed
360    Closed,
361    /// Deferred to future period
362    Deferred,
363    /// Not applicable / withdrawn
364    NotApplicable,
365}
366
367/// Remediation plan for a finding.
368#[derive(Debug, Clone, Serialize, Deserialize)]
369pub struct RemediationPlan {
370    /// Plan ID
371    pub plan_id: Uuid,
372    /// Finding ID this plan addresses
373    pub finding_id: Uuid,
374    /// Plan description
375    pub description: String,
376    /// Responsible party
377    pub responsible_party: String,
378    /// Target completion date
379    pub target_date: NaiveDate,
380    /// Actual completion date
381    pub actual_completion_date: Option<NaiveDate>,
382    /// Plan status
383    pub status: RemediationStatus,
384    /// Validation approach
385    pub validation_approach: String,
386    /// Validated by auditor ID
387    pub validated_by: Option<String>,
388    /// Validation date
389    pub validated_date: Option<NaiveDate>,
390    /// Validation result
391    pub validation_result: Option<ValidationResult>,
392    /// Milestones
393    pub milestones: Vec<RemediationMilestone>,
394    /// Notes
395    pub notes: String,
396    pub created_at: DateTime<Utc>,
397    pub updated_at: DateTime<Utc>,
398}
399
400impl RemediationPlan {
401    /// Create a new remediation plan.
402    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    /// Add a milestone.
429    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    /// Mark as complete.
441    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    /// Add validation result.
448    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    /// Check if plan is overdue.
461    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/// Remediation plan status.
472#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize, Default)]
473#[serde(rename_all = "snake_case")]
474pub enum RemediationStatus {
475    /// Planned but not started
476    #[default]
477    Planned,
478    /// In progress
479    InProgress,
480    /// Complete - pending validation
481    Complete,
482    /// Validated as effective
483    Validated,
484    /// Partially validated
485    PartiallyValidated,
486    /// Failed validation
487    Failed,
488    /// Deferred
489    Deferred,
490}
491
492/// Validation result.
493#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
494#[serde(rename_all = "snake_case")]
495pub enum ValidationResult {
496    /// Remediation is effective
497    Effective,
498    /// Partially effective
499    PartiallyEffective,
500    /// Not effective
501    Ineffective,
502}
503
504/// Remediation milestone.
505#[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/// Milestone status.
515#[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}