use chrono::{DateTime, NaiveDate, Utc};
use rust_decimal::Decimal;
use serde::{Deserialize, Serialize};
use uuid::Uuid;
use super::engagement::RiskLevel;
use super::workpaper::Assertion;
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct AuditFinding {
pub finding_id: Uuid,
pub finding_ref: String,
pub engagement_id: Uuid,
pub finding_type: FindingType,
pub severity: FindingSeverity,
pub title: String,
pub condition: String,
pub criteria: String,
pub cause: String,
pub effect: String,
pub monetary_impact: Option<Decimal>,
pub is_misstatement: bool,
pub projected_misstatement: Option<Decimal>,
pub factual_misstatement: Option<Decimal>,
pub judgmental_misstatement: Option<Decimal>,
pub recommendation: String,
pub management_response: Option<String>,
pub management_response_date: Option<NaiveDate>,
pub management_agrees: Option<bool>,
pub remediation_plan: Option<RemediationPlan>,
pub status: FindingStatus,
pub assertions_affected: Vec<Assertion>,
pub accounts_affected: Vec<String>,
pub process_areas: Vec<String>,
pub related_control_ids: Vec<String>,
pub related_risk_id: Option<String>,
pub workpaper_id: Option<String>,
pub workpaper_refs: Vec<Uuid>,
pub evidence_refs: Vec<Uuid>,
pub related_findings: Vec<Uuid>,
pub prior_year_finding_id: Option<Uuid>,
pub include_in_management_letter: bool,
pub report_to_governance: bool,
pub communicated_date: Option<NaiveDate>,
pub identified_by: String,
pub identified_date: NaiveDate,
pub reviewed_by: Option<String>,
pub review_date: Option<NaiveDate>,
#[serde(with = "crate::serde_timestamp::utc")]
pub created_at: DateTime<Utc>,
#[serde(with = "crate::serde_timestamp::utc")]
pub updated_at: DateTime<Utc>,
}
impl AuditFinding {
pub fn new(engagement_id: Uuid, finding_type: FindingType, title: &str) -> Self {
let now = Utc::now();
Self {
finding_id: Uuid::new_v4(),
finding_ref: format!("FIND-{}-{:03}", now.format("%Y"), 1),
engagement_id,
finding_type,
severity: finding_type.default_severity(),
title: title.into(),
condition: String::new(),
criteria: String::new(),
cause: String::new(),
effect: String::new(),
monetary_impact: None,
is_misstatement: false,
projected_misstatement: None,
factual_misstatement: None,
judgmental_misstatement: None,
recommendation: String::new(),
management_response: None,
management_response_date: None,
management_agrees: None,
remediation_plan: None,
status: FindingStatus::Draft,
assertions_affected: Vec::new(),
accounts_affected: Vec::new(),
process_areas: Vec::new(),
related_control_ids: Vec::new(),
related_risk_id: None,
workpaper_id: None,
workpaper_refs: Vec::new(),
evidence_refs: Vec::new(),
related_findings: Vec::new(),
prior_year_finding_id: None,
include_in_management_letter: false,
report_to_governance: false,
communicated_date: None,
identified_by: String::new(),
identified_date: now.date_naive(),
reviewed_by: None,
review_date: None,
created_at: now,
updated_at: now,
}
}
pub fn with_details(
mut self,
condition: &str,
criteria: &str,
cause: &str,
effect: &str,
) -> Self {
self.condition = condition.into();
self.criteria = criteria.into();
self.cause = cause.into();
self.effect = effect.into();
self
}
pub fn with_monetary_impact(mut self, impact: Decimal) -> Self {
self.monetary_impact = Some(impact);
self.is_misstatement = true;
self
}
pub fn with_misstatement(
mut self,
factual: Option<Decimal>,
projected: Option<Decimal>,
judgmental: Option<Decimal>,
) -> Self {
self.factual_misstatement = factual;
self.projected_misstatement = projected;
self.judgmental_misstatement = judgmental;
self.is_misstatement = true;
self
}
pub fn with_recommendation(mut self, recommendation: &str) -> Self {
self.recommendation = recommendation.into();
self
}
pub fn add_management_response(&mut self, response: &str, agrees: bool, date: NaiveDate) {
self.management_response = Some(response.into());
self.management_agrees = Some(agrees);
self.management_response_date = Some(date);
self.status = FindingStatus::ManagementResponse;
self.updated_at = Utc::now();
}
pub fn with_remediation_plan(&mut self, plan: RemediationPlan) {
self.remediation_plan = Some(plan);
self.status = FindingStatus::RemediationPlanned;
self.updated_at = Utc::now();
}
pub fn mark_for_reporting(&mut self, management_letter: bool, governance: bool) {
self.include_in_management_letter = management_letter;
self.report_to_governance = governance;
self.updated_at = Utc::now();
}
pub fn total_misstatement(&self) -> Decimal {
let factual = self.factual_misstatement.unwrap_or_default();
let projected = self.projected_misstatement.unwrap_or_default();
let judgmental = self.judgmental_misstatement.unwrap_or_default();
factual + projected + judgmental
}
pub fn is_material_weakness(&self) -> bool {
matches!(self.finding_type, FindingType::MaterialWeakness)
}
pub fn requires_governance_communication(&self) -> bool {
matches!(
self.finding_type,
FindingType::MaterialWeakness | FindingType::SignificantDeficiency
) || matches!(
self.severity,
FindingSeverity::Critical | FindingSeverity::High
)
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize, Default)]
#[serde(rename_all = "snake_case")]
pub enum FindingType {
MaterialWeakness,
SignificantDeficiency,
#[default]
ControlDeficiency,
MaterialMisstatement,
ImmaterialMisstatement,
ComplianceException,
OtherMatter,
ItDeficiency,
ProcessImprovement,
}
impl FindingType {
pub fn default_severity(&self) -> FindingSeverity {
match self {
Self::MaterialWeakness => FindingSeverity::Critical,
Self::SignificantDeficiency => FindingSeverity::High,
Self::MaterialMisstatement => FindingSeverity::Critical,
Self::ControlDeficiency | Self::ImmaterialMisstatement => FindingSeverity::Medium,
Self::ComplianceException => FindingSeverity::Medium,
Self::OtherMatter | Self::ProcessImprovement => FindingSeverity::Low,
Self::ItDeficiency => FindingSeverity::Medium,
}
}
pub fn isa_reference(&self) -> &'static str {
match self {
Self::MaterialWeakness | Self::SignificantDeficiency | Self::ControlDeficiency => {
"ISA 265"
}
Self::MaterialMisstatement | Self::ImmaterialMisstatement => "ISA 450",
Self::ComplianceException => "ISA 250",
_ => "ISA 260",
}
}
pub fn requires_sox_reporting(&self) -> bool {
matches!(self, Self::MaterialWeakness | Self::SignificantDeficiency)
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize, Default)]
#[serde(rename_all = "snake_case")]
pub enum FindingSeverity {
Critical,
High,
#[default]
Medium,
Low,
Informational,
}
impl FindingSeverity {
pub fn score(&self) -> u8 {
match self {
Self::Critical => 5,
Self::High => 4,
Self::Medium => 3,
Self::Low => 2,
Self::Informational => 1,
}
}
pub fn to_risk_level(&self) -> RiskLevel {
match self {
Self::Critical => RiskLevel::Significant,
Self::High => RiskLevel::High,
Self::Medium => RiskLevel::Medium,
Self::Low | Self::Informational => RiskLevel::Low,
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize, Default)]
#[serde(rename_all = "snake_case")]
pub enum FindingStatus {
#[default]
Draft,
PendingReview,
AwaitingResponse,
ManagementResponse,
RemediationPlanned,
RemediationInProgress,
PendingValidation,
Closed,
Deferred,
NotApplicable,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct RemediationPlan {
pub plan_id: Uuid,
pub finding_id: Uuid,
pub description: String,
pub responsible_party: String,
pub target_date: NaiveDate,
pub actual_completion_date: Option<NaiveDate>,
pub status: RemediationStatus,
pub validation_approach: String,
pub validated_by: Option<String>,
pub validated_date: Option<NaiveDate>,
pub validation_result: Option<ValidationResult>,
pub milestones: Vec<RemediationMilestone>,
pub notes: String,
#[serde(with = "crate::serde_timestamp::utc")]
pub created_at: DateTime<Utc>,
#[serde(with = "crate::serde_timestamp::utc")]
pub updated_at: DateTime<Utc>,
}
impl RemediationPlan {
pub fn new(
finding_id: Uuid,
description: &str,
responsible_party: &str,
target_date: NaiveDate,
) -> Self {
let now = Utc::now();
Self {
plan_id: Uuid::new_v4(),
finding_id,
description: description.into(),
responsible_party: responsible_party.into(),
target_date,
actual_completion_date: None,
status: RemediationStatus::Planned,
validation_approach: String::new(),
validated_by: None,
validated_date: None,
validation_result: None,
milestones: Vec::new(),
notes: String::new(),
created_at: now,
updated_at: now,
}
}
pub fn add_milestone(&mut self, description: &str, target_date: NaiveDate) {
self.milestones.push(RemediationMilestone {
milestone_id: Uuid::new_v4(),
description: description.into(),
target_date,
completion_date: None,
status: MilestoneStatus::Pending,
});
self.updated_at = Utc::now();
}
pub fn mark_complete(&mut self, completion_date: NaiveDate) {
self.actual_completion_date = Some(completion_date);
self.status = RemediationStatus::Complete;
self.updated_at = Utc::now();
}
pub fn validate(&mut self, validator: &str, date: NaiveDate, result: ValidationResult) {
self.validated_by = Some(validator.into());
self.validated_date = Some(date);
self.validation_result = Some(result);
self.status = match result {
ValidationResult::Effective => RemediationStatus::Validated,
ValidationResult::PartiallyEffective => RemediationStatus::PartiallyValidated,
ValidationResult::Ineffective => RemediationStatus::Failed,
};
self.updated_at = Utc::now();
}
pub fn is_overdue(&self) -> bool {
self.actual_completion_date.is_none()
&& Utc::now().date_naive() > self.target_date
&& !matches!(
self.status,
RemediationStatus::Complete | RemediationStatus::Validated
)
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize, Default)]
#[serde(rename_all = "snake_case")]
pub enum RemediationStatus {
#[default]
Planned,
InProgress,
Complete,
Validated,
PartiallyValidated,
Failed,
Deferred,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum ValidationResult {
Effective,
PartiallyEffective,
Ineffective,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct RemediationMilestone {
pub milestone_id: Uuid,
pub description: String,
pub target_date: NaiveDate,
pub completion_date: Option<NaiveDate>,
pub status: MilestoneStatus,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize, Default)]
#[serde(rename_all = "snake_case")]
pub enum MilestoneStatus {
#[default]
Pending,
InProgress,
Complete,
Overdue,
}
#[cfg(test)]
#[allow(clippy::unwrap_used)]
mod tests {
use super::*;
#[test]
fn test_finding_creation() {
let finding = AuditFinding::new(
Uuid::new_v4(),
FindingType::ControlDeficiency,
"Inadequate segregation of duties",
)
.with_details(
"Same person can create and approve POs",
"SOD policy requires separation",
"Staffing constraints",
"Risk of unauthorized purchases",
);
assert_eq!(finding.finding_type, FindingType::ControlDeficiency);
assert!(!finding.condition.is_empty());
}
#[test]
fn test_material_weakness() {
let finding = AuditFinding::new(
Uuid::new_v4(),
FindingType::MaterialWeakness,
"Lack of revenue cut-off controls",
);
assert!(finding.is_material_weakness());
assert!(finding.requires_governance_communication());
assert_eq!(finding.severity, FindingSeverity::Critical);
}
#[test]
fn test_remediation_plan() {
let mut plan = RemediationPlan::new(
Uuid::new_v4(),
"Implement automated SOD controls",
"IT Manager",
NaiveDate::from_ymd_opt(2025, 6, 30).unwrap(),
);
plan.add_milestone(
"Complete requirements gathering",
NaiveDate::from_ymd_opt(2025, 3, 31).unwrap(),
);
assert_eq!(plan.milestones.len(), 1);
assert_eq!(plan.status, RemediationStatus::Planned);
}
#[test]
fn test_misstatement_total() {
let finding = AuditFinding::new(
Uuid::new_v4(),
FindingType::ImmaterialMisstatement,
"Revenue overstatement",
)
.with_misstatement(
Some(Decimal::new(10000, 0)),
Some(Decimal::new(5000, 0)),
Some(Decimal::new(2000, 0)),
);
assert_eq!(finding.total_misstatement(), Decimal::new(17000, 0));
}
}