Skip to main content

datasynth_core/models/compliance/
finding.rs

1//! Compliance findings and deficiency classification.
2
3use std::collections::HashMap;
4
5use chrono::{Datelike, NaiveDate};
6use rust_decimal::Decimal;
7use serde::{Deserialize, Serialize};
8use uuid::Uuid;
9
10use super::assertion::ComplianceAssertion;
11use super::standard_id::StandardId;
12use crate::models::graph_properties::{GraphPropertyValue, ToNodeProperties};
13
14/// Deficiency severity level per SOX/ISA classification.
15#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
16#[serde(rename_all = "snake_case")]
17pub enum DeficiencyLevel {
18    /// Reasonable possibility of material misstatement not being prevented/detected
19    MaterialWeakness,
20    /// Important enough to merit attention of those charged with governance
21    SignificantDeficiency,
22    /// Design or operation deficiency that does not rise to significant deficiency
23    ControlDeficiency,
24}
25
26impl DeficiencyLevel {
27    /// Returns a numeric severity score for ML features.
28    pub fn severity_score(&self) -> f64 {
29        match self {
30            Self::MaterialWeakness => 1.0,
31            Self::SignificantDeficiency => 0.66,
32            Self::ControlDeficiency => 0.33,
33        }
34    }
35}
36
37impl std::fmt::Display for DeficiencyLevel {
38    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
39        match self {
40            Self::MaterialWeakness => write!(f, "Material Weakness"),
41            Self::SignificantDeficiency => write!(f, "Significant Deficiency"),
42            Self::ControlDeficiency => write!(f, "Control Deficiency"),
43        }
44    }
45}
46
47/// Finding severity level.
48#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
49#[serde(rename_all = "snake_case")]
50pub enum FindingSeverity {
51    /// High severity — likely material
52    High,
53    /// Moderate severity — potentially significant
54    Moderate,
55    /// Low severity — minor issue
56    Low,
57}
58
59impl FindingSeverity {
60    /// Returns a numeric score for ML features.
61    pub fn score(&self) -> f64 {
62        match self {
63            Self::High => 1.0,
64            Self::Moderate => 0.66,
65            Self::Low => 0.33,
66        }
67    }
68}
69
70impl std::fmt::Display for FindingSeverity {
71    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
72        match self {
73            Self::High => write!(f, "High"),
74            Self::Moderate => write!(f, "Moderate"),
75            Self::Low => write!(f, "Low"),
76        }
77    }
78}
79
80/// Remediation status of a finding.
81#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
82#[serde(rename_all = "snake_case")]
83pub enum RemediationStatus {
84    /// Finding is open; no action taken
85    Open,
86    /// Remediation is in progress
87    InProgress,
88    /// Finding has been remediated and retested
89    Remediated,
90}
91
92impl std::fmt::Display for RemediationStatus {
93    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
94        match self {
95            Self::Open => write!(f, "Open"),
96            Self::InProgress => write!(f, "In Progress"),
97            Self::Remediated => write!(f, "Remediated"),
98        }
99    }
100}
101
102/// A compliance finding from an audit procedure.
103#[derive(Debug, Clone, Serialize, Deserialize)]
104pub struct ComplianceFinding {
105    /// Unique finding identifier
106    pub finding_id: Uuid,
107    /// Company code
108    pub company_code: String,
109    /// Finding title
110    pub title: String,
111    /// Detailed description
112    pub description: String,
113    /// Finding severity
114    pub severity: FindingSeverity,
115    /// Deficiency classification (SOX)
116    pub deficiency_level: DeficiencyLevel,
117    /// Control ID where finding was identified
118    pub control_id: Option<String>,
119    /// Procedure that identified this finding
120    pub procedure_id: Option<String>,
121    /// Affected audit assertions
122    pub affected_assertions: Vec<ComplianceAssertion>,
123    /// Related standards
124    pub related_standards: Vec<StandardId>,
125    /// Date finding was identified
126    pub identified_date: NaiveDate,
127    /// Remediation status
128    pub remediation_status: RemediationStatus,
129    /// Estimated financial impact
130    pub financial_impact: Option<Decimal>,
131    /// Whether this is a repeat finding from a prior period
132    pub is_repeat: bool,
133    /// Account codes affected
134    pub affected_accounts: Vec<String>,
135    /// Fiscal year
136    pub fiscal_year: i32,
137}
138
139impl ComplianceFinding {
140    /// Creates a new compliance finding.
141    pub fn new(
142        company_code: impl Into<String>,
143        title: impl Into<String>,
144        severity: FindingSeverity,
145        deficiency_level: DeficiencyLevel,
146        identified_date: NaiveDate,
147    ) -> Self {
148        Self {
149            finding_id: Uuid::new_v4(),
150            company_code: company_code.into(),
151            title: title.into(),
152            description: String::new(),
153            severity,
154            deficiency_level,
155            control_id: None,
156            procedure_id: None,
157            affected_assertions: Vec::new(),
158            related_standards: Vec::new(),
159            identified_date,
160            remediation_status: RemediationStatus::Open,
161            financial_impact: None,
162            is_repeat: false,
163            affected_accounts: Vec::new(),
164            fiscal_year: identified_date.year(),
165        }
166    }
167
168    /// Sets the description.
169    pub fn with_description(mut self, desc: impl Into<String>) -> Self {
170        self.description = desc.into();
171        self
172    }
173
174    /// Links to a control.
175    pub fn on_control(mut self, control_id: impl Into<String>) -> Self {
176        self.control_id = Some(control_id.into());
177        self
178    }
179
180    /// Links to a procedure.
181    pub fn identified_by(mut self, procedure_id: impl Into<String>) -> Self {
182        self.procedure_id = Some(procedure_id.into());
183        self
184    }
185
186    /// Adds an affected assertion.
187    pub fn with_assertion(mut self, assertion: ComplianceAssertion) -> Self {
188        self.affected_assertions.push(assertion);
189        self
190    }
191
192    /// Adds a related standard.
193    pub fn with_standard(mut self, id: StandardId) -> Self {
194        self.related_standards.push(id);
195        self
196    }
197
198    /// Sets the remediation status.
199    pub fn with_remediation(mut self, status: RemediationStatus) -> Self {
200        self.remediation_status = status;
201        self
202    }
203
204    /// Marks as a repeat finding.
205    pub fn as_repeat(mut self) -> Self {
206        self.is_repeat = true;
207        self
208    }
209}
210
211impl ToNodeProperties for ComplianceFinding {
212    fn node_type_name(&self) -> &'static str {
213        "compliance_finding"
214    }
215    fn node_type_code(&self) -> u16 {
216        511
217    }
218    fn to_node_properties(&self) -> HashMap<String, GraphPropertyValue> {
219        let mut p = HashMap::new();
220        p.insert(
221            "findingId".into(),
222            GraphPropertyValue::String(self.finding_id.to_string()),
223        );
224        p.insert(
225            "companyCode".into(),
226            GraphPropertyValue::String(self.company_code.clone()),
227        );
228        p.insert(
229            "title".into(),
230            GraphPropertyValue::String(self.title.clone()),
231        );
232        p.insert(
233            "severity".into(),
234            GraphPropertyValue::String(self.severity.to_string()),
235        );
236        p.insert(
237            "severityScore".into(),
238            GraphPropertyValue::Float(self.severity.score()),
239        );
240        p.insert(
241            "deficiencyLevel".into(),
242            GraphPropertyValue::String(self.deficiency_level.to_string()),
243        );
244        p.insert(
245            "deficiencySeverityScore".into(),
246            GraphPropertyValue::Float(self.deficiency_level.severity_score()),
247        );
248        if let Some(ref cid) = self.control_id {
249            p.insert("controlId".into(), GraphPropertyValue::String(cid.clone()));
250        }
251        if let Some(ref pid) = self.procedure_id {
252            p.insert(
253                "procedureId".into(),
254                GraphPropertyValue::String(pid.clone()),
255            );
256        }
257        p.insert(
258            "identifiedDate".into(),
259            GraphPropertyValue::Date(self.identified_date),
260        );
261        p.insert(
262            "remediationStatus".into(),
263            GraphPropertyValue::String(self.remediation_status.to_string()),
264        );
265        if let Some(impact) = self.financial_impact {
266            p.insert(
267                "financialImpact".into(),
268                GraphPropertyValue::Decimal(impact),
269            );
270        }
271        p.insert("isRepeat".into(), GraphPropertyValue::Bool(self.is_repeat));
272        p.insert(
273            "fiscalYear".into(),
274            GraphPropertyValue::Int(self.fiscal_year as i64),
275        );
276        if !self.affected_assertions.is_empty() {
277            p.insert(
278                "affectedAssertions".into(),
279                GraphPropertyValue::StringList(
280                    self.affected_assertions
281                        .iter()
282                        .map(|a| a.to_string())
283                        .collect(),
284                ),
285            );
286        }
287        if !self.related_standards.is_empty() {
288            p.insert(
289                "relatedStandards".into(),
290                GraphPropertyValue::StringList(
291                    self.related_standards
292                        .iter()
293                        .map(|s| s.as_str().to_string())
294                        .collect(),
295                ),
296            );
297        }
298        if !self.affected_accounts.is_empty() {
299            p.insert(
300                "affectedAccounts".into(),
301                GraphPropertyValue::StringList(self.affected_accounts.clone()),
302            );
303        }
304        p
305    }
306}
307
308#[cfg(test)]
309mod tests {
310    use super::*;
311
312    #[test]
313    fn test_finding_creation() {
314        let date = NaiveDate::from_ymd_opt(2025, 6, 30).expect("valid date");
315        let finding = ComplianceFinding::new(
316            "C001",
317            "Three-way match exception",
318            FindingSeverity::Moderate,
319            DeficiencyLevel::SignificantDeficiency,
320            date,
321        )
322        .on_control("C010")
323        .with_assertion(ComplianceAssertion::Occurrence)
324        .with_standard(StandardId::new("SOX", "404"));
325
326        assert_eq!(finding.severity, FindingSeverity::Moderate);
327        assert_eq!(finding.control_id.as_deref(), Some("C010"));
328        assert_eq!(finding.related_standards.len(), 1);
329    }
330
331    #[test]
332    fn test_deficiency_severity_ordering() {
333        assert!(
334            DeficiencyLevel::MaterialWeakness.severity_score()
335                > DeficiencyLevel::SignificantDeficiency.severity_score()
336        );
337        assert!(
338            DeficiencyLevel::SignificantDeficiency.severity_score()
339                > DeficiencyLevel::ControlDeficiency.severity_score()
340        );
341    }
342}