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    #[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    /// Create a new audit finding.
120    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    /// Set the finding details (condition, criteria, cause, effect).
167    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    /// Set monetary impact.
182    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    /// Set misstatement details.
189    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    /// Set recommendation.
203    pub fn with_recommendation(mut self, recommendation: &str) -> Self {
204        self.recommendation = recommendation.into();
205        self
206    }
207
208    /// Add management response.
209    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    /// Set remediation plan.
218    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    /// Mark for reporting.
225    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    /// Get total misstatement amount.
232    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    /// Check if this is a material weakness (for SOX).
240    pub fn is_material_weakness(&self) -> bool {
241        matches!(self.finding_type, FindingType::MaterialWeakness)
242    }
243
244    /// Check if this requires governance communication per ISA 260.
245    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/// Type of audit finding per ISA 265.
257#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize, Default)]
258#[serde(rename_all = "snake_case")]
259pub enum FindingType {
260    /// Material weakness in internal control
261    MaterialWeakness,
262    /// Significant deficiency in internal control
263    SignificantDeficiency,
264    /// Control deficiency (not significant)
265    #[default]
266    ControlDeficiency,
267    /// Material misstatement in financial statements
268    MaterialMisstatement,
269    /// Immaterial misstatement
270    ImmaterialMisstatement,
271    /// Compliance exception
272    ComplianceException,
273    /// Other matter for management attention
274    OtherMatter,
275    /// IT-related deficiency
276    ItDeficiency,
277    /// Process improvement opportunity
278    ProcessImprovement,
279}
280
281impl FindingType {
282    /// Get the default severity for this finding type.
283    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    /// Get ISA reference for this finding type.
296    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    /// Check if this type requires SOX reporting.
308    pub fn requires_sox_reporting(&self) -> bool {
309        matches!(self, Self::MaterialWeakness | Self::SignificantDeficiency)
310    }
311}
312
313/// Finding severity level.
314#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize, Default)]
315#[serde(rename_all = "snake_case")]
316pub enum FindingSeverity {
317    /// Critical - immediate attention required
318    Critical,
319    /// High - significant impact
320    High,
321    /// Medium - moderate impact
322    #[default]
323    Medium,
324    /// Low - minor impact
325    Low,
326    /// Informational only
327    Informational,
328}
329
330impl FindingSeverity {
331    /// Get numeric score for prioritization.
332    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    /// Convert to risk level.
343    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/// Finding status.
354#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize, Default)]
355#[serde(rename_all = "snake_case")]
356pub enum FindingStatus {
357    /// Draft - being documented
358    #[default]
359    Draft,
360    /// Pending review
361    PendingReview,
362    /// Awaiting management response
363    AwaitingResponse,
364    /// Management has responded
365    ManagementResponse,
366    /// Remediation planned
367    RemediationPlanned,
368    /// Remediation in progress
369    RemediationInProgress,
370    /// Remediation complete - pending validation
371    PendingValidation,
372    /// Validated and closed
373    Closed,
374    /// Deferred to future period
375    Deferred,
376    /// Not applicable / withdrawn
377    NotApplicable,
378}
379
380/// Remediation plan for a finding.
381#[derive(Debug, Clone, Serialize, Deserialize)]
382pub struct RemediationPlan {
383    /// Plan ID
384    pub plan_id: Uuid,
385    /// Finding ID this plan addresses
386    pub finding_id: Uuid,
387    /// Plan description
388    pub description: String,
389    /// Responsible party
390    pub responsible_party: String,
391    /// Target completion date
392    pub target_date: NaiveDate,
393    /// Actual completion date
394    pub actual_completion_date: Option<NaiveDate>,
395    /// Plan status
396    pub status: RemediationStatus,
397    /// Validation approach
398    pub validation_approach: String,
399    /// Validated by auditor ID
400    pub validated_by: Option<String>,
401    /// Validation date
402    pub validated_date: Option<NaiveDate>,
403    /// Validation result
404    pub validation_result: Option<ValidationResult>,
405    /// Milestones
406    pub milestones: Vec<RemediationMilestone>,
407    /// Notes
408    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    /// Create a new remediation plan.
417    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    /// Add a milestone.
444    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    /// Mark as complete.
456    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    /// Add validation result.
463    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    /// Check if plan is overdue.
476    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/// Remediation plan status.
487#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize, Default)]
488#[serde(rename_all = "snake_case")]
489pub enum RemediationStatus {
490    /// Planned but not started
491    #[default]
492    Planned,
493    /// In progress
494    InProgress,
495    /// Complete - pending validation
496    Complete,
497    /// Validated as effective
498    Validated,
499    /// Partially validated
500    PartiallyValidated,
501    /// Failed validation
502    Failed,
503    /// Deferred
504    Deferred,
505}
506
507/// Validation result.
508#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
509#[serde(rename_all = "snake_case")]
510pub enum ValidationResult {
511    /// Remediation is effective
512    Effective,
513    /// Partially effective
514    PartiallyEffective,
515    /// Not effective
516    Ineffective,
517}
518
519/// Remediation milestone.
520#[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/// Milestone status.
530#[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}