use serde::{Deserialize, Serialize};
use uuid::Uuid;
use super::isa_reference::IsaRequirement;
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct AuditTrail {
pub trail_id: Uuid,
pub engagement_id: Uuid,
pub account_or_area: String,
pub assertion: Assertion,
pub risk_assessment: RiskTrailNode,
pub planned_responses: Vec<ResponseTrailNode>,
pub procedures_performed: Vec<ProcedureTrailNode>,
pub evidence_obtained: Vec<EvidenceTrailNode>,
pub conclusion: ConclusionTrailNode,
pub gaps_identified: Vec<TrailGap>,
pub isa_coverage: Vec<IsaRequirement>,
}
impl AuditTrail {
pub fn new(
engagement_id: Uuid,
account_or_area: impl Into<String>,
assertion: Assertion,
) -> Self {
Self {
trail_id: Uuid::now_v7(),
engagement_id,
account_or_area: account_or_area.into(),
assertion,
risk_assessment: RiskTrailNode::default(),
planned_responses: Vec::new(),
procedures_performed: Vec::new(),
evidence_obtained: Vec::new(),
conclusion: ConclusionTrailNode::default(),
gaps_identified: Vec::new(),
isa_coverage: Vec::new(),
}
}
pub fn is_complete(&self) -> bool {
self.gaps_identified.is_empty()
&& self.conclusion.conclusion_reached
&& !self.evidence_obtained.is_empty()
}
pub fn identify_gaps(&mut self) {
self.gaps_identified.clear();
if !self.risk_assessment.risk_identified {
self.gaps_identified.push(TrailGap {
gap_type: GapType::RiskAssessment,
description: "Risk of material misstatement not documented".to_string(),
severity: GapSeverity::High,
remediation_required: true,
});
}
if self.planned_responses.is_empty() {
self.gaps_identified.push(TrailGap {
gap_type: GapType::PlannedResponse,
description: "No audit responses planned".to_string(),
severity: GapSeverity::High,
remediation_required: true,
});
}
if self.procedures_performed.is_empty() {
self.gaps_identified.push(TrailGap {
gap_type: GapType::ProceduresPerformed,
description: "No audit procedures performed".to_string(),
severity: GapSeverity::High,
remediation_required: true,
});
}
if self.evidence_obtained.is_empty() {
self.gaps_identified.push(TrailGap {
gap_type: GapType::Evidence,
description: "No audit evidence documented".to_string(),
severity: GapSeverity::High,
remediation_required: true,
});
}
if !self.conclusion.conclusion_reached {
self.gaps_identified.push(TrailGap {
gap_type: GapType::Conclusion,
description: "No conclusion documented".to_string(),
severity: GapSeverity::High,
remediation_required: true,
});
}
for response in &self.planned_responses {
if !self
.procedures_performed
.iter()
.any(|p| p.response_id == Some(response.response_id))
{
self.gaps_identified.push(TrailGap {
gap_type: GapType::Linkage,
description: format!(
"Planned response '{}' not linked to performed procedure",
response.response_description
),
severity: GapSeverity::Medium,
remediation_required: true,
});
}
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize, Default)]
#[serde(rename_all = "snake_case")]
pub enum Assertion {
#[default]
Occurrence,
Completeness,
Cutoff,
Accuracy,
Classification,
Existence,
RightsAndObligations,
Valuation,
Understandability,
ClassificationAndUnderstandability,
AccuracyAndValuation,
}
impl std::fmt::Display for Assertion {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Self::Occurrence => write!(f, "Occurrence"),
Self::Completeness => write!(f, "Completeness"),
Self::Cutoff => write!(f, "Cutoff"),
Self::Accuracy => write!(f, "Accuracy"),
Self::Classification => write!(f, "Classification"),
Self::Existence => write!(f, "Existence"),
Self::RightsAndObligations => write!(f, "Rights and Obligations"),
Self::Valuation => write!(f, "Valuation"),
Self::Understandability => write!(f, "Understandability"),
Self::ClassificationAndUnderstandability => {
write!(f, "Classification and Understandability")
}
Self::AccuracyAndValuation => write!(f, "Accuracy and Valuation"),
}
}
}
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct RiskTrailNode {
pub risk_identified: bool,
pub risk_description: String,
pub inherent_risk_level: AuditRiskLevel,
pub control_risk_level: AuditRiskLevel,
pub romm_level: AuditRiskLevel,
pub is_significant_risk: bool,
pub fraud_risk_identified: bool,
pub understanding_documented: bool,
pub controls_evaluated: bool,
pub workpaper_reference: Option<String>,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize, Default)]
#[serde(rename_all = "snake_case")]
pub enum AuditRiskLevel {
Low,
#[default]
Medium,
High,
Maximum,
}
impl std::fmt::Display for AuditRiskLevel {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Self::Low => write!(f, "Low"),
Self::Medium => write!(f, "Medium"),
Self::High => write!(f, "High"),
Self::Maximum => write!(f, "Maximum"),
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ResponseTrailNode {
pub response_id: Uuid,
pub response_description: String,
pub response_type: ResponseType,
pub risk_addressed: String,
pub procedure_nature: ProcedureNature,
pub procedure_timing: ProcedureTiming,
pub procedure_extent: String,
pub staff_assigned: Vec<String>,
pub budgeted_hours: Option<f64>,
}
impl ResponseTrailNode {
pub fn new(response_description: impl Into<String>, response_type: ResponseType) -> Self {
Self {
response_id: Uuid::now_v7(),
response_description: response_description.into(),
response_type,
risk_addressed: String::new(),
procedure_nature: ProcedureNature::Substantive,
procedure_timing: ProcedureTiming::YearEnd,
procedure_extent: String::new(),
staff_assigned: Vec::new(),
budgeted_hours: None,
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize, Default)]
#[serde(rename_all = "snake_case")]
pub enum ResponseType {
TestOfControls,
#[default]
Substantive,
Combined,
Overall,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize, Default)]
#[serde(rename_all = "snake_case")]
pub enum ProcedureNature {
Inspection,
Observation,
Confirmation,
Recalculation,
Reperformance,
Analytical,
Inquiry,
#[default]
Substantive,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize, Default)]
#[serde(rename_all = "snake_case")]
pub enum ProcedureTiming {
Interim,
#[default]
YearEnd,
RollForward,
Continuous,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ProcedureTrailNode {
pub procedure_id: Uuid,
pub response_id: Option<Uuid>,
pub procedure_description: String,
pub date_performed: chrono::NaiveDate,
pub performed_by: String,
pub reviewed_by: Option<String>,
pub hours_spent: Option<f64>,
pub population_size: Option<u64>,
pub sample_size: Option<u64>,
pub exceptions_found: u32,
pub results_summary: String,
pub workpaper_reference: Option<String>,
}
impl ProcedureTrailNode {
pub fn new(
procedure_description: impl Into<String>,
date_performed: chrono::NaiveDate,
performed_by: impl Into<String>,
) -> Self {
Self {
procedure_id: Uuid::now_v7(),
response_id: None,
procedure_description: procedure_description.into(),
date_performed,
performed_by: performed_by.into(),
reviewed_by: None,
hours_spent: None,
population_size: None,
sample_size: None,
exceptions_found: 0,
results_summary: String::new(),
workpaper_reference: None,
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct EvidenceTrailNode {
pub evidence_id: Uuid,
pub procedure_id: Option<Uuid>,
pub evidence_type: EvidenceType,
pub evidence_description: String,
pub source: EvidenceSource,
pub reliability: EvidenceReliability,
pub relevance: EvidenceRelevance,
pub document_reference: Option<String>,
pub date_obtained: chrono::NaiveDate,
}
impl EvidenceTrailNode {
pub fn new(
evidence_type: EvidenceType,
evidence_description: impl Into<String>,
source: EvidenceSource,
) -> Self {
Self {
evidence_id: Uuid::now_v7(),
procedure_id: None,
evidence_type,
evidence_description: evidence_description.into(),
source,
reliability: EvidenceReliability::Moderate,
relevance: EvidenceRelevance::Relevant,
document_reference: None,
date_obtained: chrono::Utc::now().date_naive(),
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum EvidenceType {
Physical,
Confirmation,
DocumentaryExternal,
DocumentaryInternal,
Recalculation,
Analytical,
Representation,
Observation,
Inquiry,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize, Default)]
#[serde(rename_all = "snake_case")]
pub enum EvidenceSource {
ExternalThirdParty,
ExternalClientRecords,
#[default]
Internal,
AuditorGenerated,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize, Default)]
#[serde(rename_all = "snake_case")]
pub enum EvidenceReliability {
Low,
#[default]
Moderate,
High,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize, Default)]
#[serde(rename_all = "snake_case")]
pub enum EvidenceRelevance {
NotRelevant,
PartiallyRelevant,
#[default]
Relevant,
DirectlyRelevant,
}
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct ConclusionTrailNode {
pub conclusion_reached: bool,
pub conclusion_text: String,
pub conclusion_type: ConclusionType,
pub misstatements_identified: Vec<MisstatementReference>,
pub sufficient_evidence: bool,
pub further_procedures_required: bool,
pub summary_memo_reference: Option<String>,
pub prepared_by: String,
pub reviewed_by: Option<String>,
pub conclusion_date: Option<chrono::NaiveDate>,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize, Default)]
#[serde(rename_all = "snake_case")]
pub enum ConclusionType {
#[default]
Satisfactory,
SatisfactoryWithMinorIssues,
PotentialMisstatement,
MisstatementIdentified,
UnableToConclude,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct MisstatementReference {
pub misstatement_id: Uuid,
pub description: String,
pub amount: Option<rust_decimal::Decimal>,
pub misstatement_type: MisstatementType,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum MisstatementType {
Factual,
Judgmental,
Projected,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct TrailGap {
pub gap_type: GapType,
pub description: String,
pub severity: GapSeverity,
pub remediation_required: bool,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum GapType {
RiskAssessment,
PlannedResponse,
ProceduresPerformed,
Evidence,
Conclusion,
Linkage,
Documentation,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum GapSeverity {
Low,
Medium,
High,
}
#[cfg(test)]
#[allow(clippy::unwrap_used)]
mod tests {
use super::*;
#[test]
fn test_audit_trail_creation() {
let trail = AuditTrail::new(Uuid::now_v7(), "Revenue", Assertion::Occurrence);
assert_eq!(trail.account_or_area, "Revenue");
assert_eq!(trail.assertion, Assertion::Occurrence);
assert!(!trail.is_complete());
}
#[test]
fn test_gap_identification() {
let mut trail = AuditTrail::new(Uuid::now_v7(), "Inventory", Assertion::Existence);
trail.identify_gaps();
assert!(!trail.gaps_identified.is_empty());
assert!(trail
.gaps_identified
.iter()
.any(|g| matches!(g.gap_type, GapType::RiskAssessment)));
assert!(trail
.gaps_identified
.iter()
.any(|g| matches!(g.gap_type, GapType::Evidence)));
}
#[test]
fn test_complete_trail() {
let mut trail = AuditTrail::new(Uuid::now_v7(), "Cash", Assertion::Existence);
trail.risk_assessment.risk_identified = true;
trail.risk_assessment.risk_description = "Risk of misappropriation".to_string();
let response =
ResponseTrailNode::new("Perform bank reconciliation", ResponseType::Substantive);
let response_id = response.response_id;
trail.planned_responses.push(response);
let mut procedure = ProcedureTrailNode::new(
"Reconciled bank to GL",
chrono::NaiveDate::from_ymd_opt(2024, 1, 31).unwrap(),
"Auditor A",
);
procedure.response_id = Some(response_id);
trail.procedures_performed.push(procedure);
trail.evidence_obtained.push(EvidenceTrailNode::new(
EvidenceType::DocumentaryExternal,
"Bank statement obtained",
EvidenceSource::ExternalThirdParty,
));
trail.conclusion.conclusion_reached = true;
trail.conclusion.conclusion_type = ConclusionType::Satisfactory;
trail.conclusion.sufficient_evidence = true;
trail.identify_gaps();
assert!(trail.is_complete());
assert!(trail.gaps_identified.is_empty());
}
}