1use serde::{Deserialize, Serialize};
8use uuid::Uuid;
9
10use super::isa_reference::IsaRequirement;
11
12#[derive(Debug, Clone, Serialize, Deserialize)]
14pub struct AuditTrail {
15 pub trail_id: Uuid,
17
18 pub engagement_id: Uuid,
20
21 pub account_or_area: String,
23
24 pub assertion: Assertion,
26
27 pub risk_assessment: RiskTrailNode,
29
30 pub planned_responses: Vec<ResponseTrailNode>,
32
33 pub procedures_performed: Vec<ProcedureTrailNode>,
35
36 pub evidence_obtained: Vec<EvidenceTrailNode>,
38
39 pub conclusion: ConclusionTrailNode,
41
42 pub gaps_identified: Vec<TrailGap>,
44
45 pub isa_coverage: Vec<IsaRequirement>,
47}
48
49impl AuditTrail {
50 pub fn new(
52 engagement_id: Uuid,
53 account_or_area: impl Into<String>,
54 assertion: Assertion,
55 ) -> Self {
56 Self {
57 trail_id: Uuid::now_v7(),
58 engagement_id,
59 account_or_area: account_or_area.into(),
60 assertion,
61 risk_assessment: RiskTrailNode::default(),
62 planned_responses: Vec::new(),
63 procedures_performed: Vec::new(),
64 evidence_obtained: Vec::new(),
65 conclusion: ConclusionTrailNode::default(),
66 gaps_identified: Vec::new(),
67 isa_coverage: Vec::new(),
68 }
69 }
70
71 pub fn is_complete(&self) -> bool {
73 self.gaps_identified.is_empty()
74 && self.conclusion.conclusion_reached
75 && !self.evidence_obtained.is_empty()
76 }
77
78 pub fn identify_gaps(&mut self) {
80 self.gaps_identified.clear();
81
82 if !self.risk_assessment.risk_identified {
84 self.gaps_identified.push(TrailGap {
85 gap_type: GapType::RiskAssessment,
86 description: "Risk of material misstatement not documented".to_string(),
87 severity: GapSeverity::High,
88 remediation_required: true,
89 });
90 }
91
92 if self.planned_responses.is_empty() {
94 self.gaps_identified.push(TrailGap {
95 gap_type: GapType::PlannedResponse,
96 description: "No audit responses planned".to_string(),
97 severity: GapSeverity::High,
98 remediation_required: true,
99 });
100 }
101
102 if self.procedures_performed.is_empty() {
104 self.gaps_identified.push(TrailGap {
105 gap_type: GapType::ProceduresPerformed,
106 description: "No audit procedures performed".to_string(),
107 severity: GapSeverity::High,
108 remediation_required: true,
109 });
110 }
111
112 if self.evidence_obtained.is_empty() {
114 self.gaps_identified.push(TrailGap {
115 gap_type: GapType::Evidence,
116 description: "No audit evidence documented".to_string(),
117 severity: GapSeverity::High,
118 remediation_required: true,
119 });
120 }
121
122 if !self.conclusion.conclusion_reached {
124 self.gaps_identified.push(TrailGap {
125 gap_type: GapType::Conclusion,
126 description: "No conclusion documented".to_string(),
127 severity: GapSeverity::High,
128 remediation_required: true,
129 });
130 }
131
132 for response in &self.planned_responses {
134 if !self
135 .procedures_performed
136 .iter()
137 .any(|p| p.response_id == Some(response.response_id))
138 {
139 self.gaps_identified.push(TrailGap {
140 gap_type: GapType::Linkage,
141 description: format!(
142 "Planned response '{}' not linked to performed procedure",
143 response.response_description
144 ),
145 severity: GapSeverity::Medium,
146 remediation_required: true,
147 });
148 }
149 }
150 }
151}
152
153#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize, Default)]
155#[serde(rename_all = "snake_case")]
156pub enum Assertion {
157 #[default]
160 Occurrence,
161 Completeness,
163 Cutoff,
165 Accuracy,
167 Classification,
169
170 Existence,
173 RightsAndObligations,
175 Valuation,
177
178 Understandability,
181 ClassificationAndUnderstandability,
183 AccuracyAndValuation,
185}
186
187impl std::fmt::Display for Assertion {
188 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
189 match self {
190 Self::Occurrence => write!(f, "Occurrence"),
191 Self::Completeness => write!(f, "Completeness"),
192 Self::Cutoff => write!(f, "Cutoff"),
193 Self::Accuracy => write!(f, "Accuracy"),
194 Self::Classification => write!(f, "Classification"),
195 Self::Existence => write!(f, "Existence"),
196 Self::RightsAndObligations => write!(f, "Rights and Obligations"),
197 Self::Valuation => write!(f, "Valuation"),
198 Self::Understandability => write!(f, "Understandability"),
199 Self::ClassificationAndUnderstandability => {
200 write!(f, "Classification and Understandability")
201 }
202 Self::AccuracyAndValuation => write!(f, "Accuracy and Valuation"),
203 }
204 }
205}
206
207#[derive(Debug, Clone, Default, Serialize, Deserialize)]
209pub struct RiskTrailNode {
210 pub risk_identified: bool,
212
213 pub risk_description: String,
215
216 pub inherent_risk_level: AuditRiskLevel,
218
219 pub control_risk_level: AuditRiskLevel,
221
222 pub romm_level: AuditRiskLevel,
224
225 pub is_significant_risk: bool,
227
228 pub fraud_risk_identified: bool,
230
231 pub understanding_documented: bool,
233
234 pub controls_evaluated: bool,
236
237 pub workpaper_reference: Option<String>,
239}
240
241#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize, Default)]
243#[serde(rename_all = "snake_case")]
244pub enum AuditRiskLevel {
245 Low,
246 #[default]
247 Medium,
248 High,
249 Maximum,
250}
251
252impl std::fmt::Display for AuditRiskLevel {
253 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
254 match self {
255 Self::Low => write!(f, "Low"),
256 Self::Medium => write!(f, "Medium"),
257 Self::High => write!(f, "High"),
258 Self::Maximum => write!(f, "Maximum"),
259 }
260 }
261}
262
263#[derive(Debug, Clone, Serialize, Deserialize)]
265pub struct ResponseTrailNode {
266 pub response_id: Uuid,
268
269 pub response_description: String,
271
272 pub response_type: ResponseType,
274
275 pub risk_addressed: String,
277
278 pub procedure_nature: ProcedureNature,
280
281 pub procedure_timing: ProcedureTiming,
283
284 pub procedure_extent: String,
286
287 pub staff_assigned: Vec<String>,
289
290 pub budgeted_hours: Option<f64>,
292}
293
294impl ResponseTrailNode {
295 pub fn new(response_description: impl Into<String>, response_type: ResponseType) -> Self {
297 Self {
298 response_id: Uuid::now_v7(),
299 response_description: response_description.into(),
300 response_type,
301 risk_addressed: String::new(),
302 procedure_nature: ProcedureNature::Substantive,
303 procedure_timing: ProcedureTiming::YearEnd,
304 procedure_extent: String::new(),
305 staff_assigned: Vec::new(),
306 budgeted_hours: None,
307 }
308 }
309}
310
311#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize, Default)]
313#[serde(rename_all = "snake_case")]
314pub enum ResponseType {
315 TestOfControls,
317 #[default]
319 Substantive,
320 Combined,
322 Overall,
324}
325
326#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize, Default)]
328#[serde(rename_all = "snake_case")]
329pub enum ProcedureNature {
330 Inspection,
332 Observation,
334 Confirmation,
336 Recalculation,
338 Reperformance,
340 Analytical,
342 Inquiry,
344 #[default]
345 Substantive,
347}
348
349#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize, Default)]
351#[serde(rename_all = "snake_case")]
352pub enum ProcedureTiming {
353 Interim,
355 #[default]
357 YearEnd,
358 RollForward,
360 Continuous,
362}
363
364#[derive(Debug, Clone, Serialize, Deserialize)]
366pub struct ProcedureTrailNode {
367 pub procedure_id: Uuid,
369
370 pub response_id: Option<Uuid>,
372
373 pub procedure_description: String,
375
376 pub date_performed: chrono::NaiveDate,
378
379 pub performed_by: String,
381
382 pub reviewed_by: Option<String>,
384
385 pub hours_spent: Option<f64>,
387
388 pub population_size: Option<u64>,
390
391 pub sample_size: Option<u64>,
393
394 pub exceptions_found: u32,
396
397 pub results_summary: String,
399
400 pub workpaper_reference: Option<String>,
402}
403
404impl ProcedureTrailNode {
405 pub fn new(
407 procedure_description: impl Into<String>,
408 date_performed: chrono::NaiveDate,
409 performed_by: impl Into<String>,
410 ) -> Self {
411 Self {
412 procedure_id: Uuid::now_v7(),
413 response_id: None,
414 procedure_description: procedure_description.into(),
415 date_performed,
416 performed_by: performed_by.into(),
417 reviewed_by: None,
418 hours_spent: None,
419 population_size: None,
420 sample_size: None,
421 exceptions_found: 0,
422 results_summary: String::new(),
423 workpaper_reference: None,
424 }
425 }
426}
427
428#[derive(Debug, Clone, Serialize, Deserialize)]
430pub struct EvidenceTrailNode {
431 pub evidence_id: Uuid,
433
434 pub procedure_id: Option<Uuid>,
436
437 pub evidence_type: EvidenceType,
439
440 pub evidence_description: String,
442
443 pub source: EvidenceSource,
445
446 pub reliability: EvidenceReliability,
448
449 pub relevance: EvidenceRelevance,
451
452 pub document_reference: Option<String>,
454
455 pub date_obtained: chrono::NaiveDate,
457}
458
459impl EvidenceTrailNode {
460 pub fn new(
462 evidence_type: EvidenceType,
463 evidence_description: impl Into<String>,
464 source: EvidenceSource,
465 ) -> Self {
466 Self {
467 evidence_id: Uuid::now_v7(),
468 procedure_id: None,
469 evidence_type,
470 evidence_description: evidence_description.into(),
471 source,
472 reliability: EvidenceReliability::Moderate,
473 relevance: EvidenceRelevance::Relevant,
474 document_reference: None,
475 date_obtained: chrono::Utc::now().date_naive(),
476 }
477 }
478}
479
480#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
482#[serde(rename_all = "snake_case")]
483pub enum EvidenceType {
484 Physical,
486 Confirmation,
488 DocumentaryExternal,
490 DocumentaryInternal,
492 Recalculation,
494 Analytical,
496 Representation,
498 Observation,
500 Inquiry,
502}
503
504#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize, Default)]
506#[serde(rename_all = "snake_case")]
507pub enum EvidenceSource {
508 ExternalThirdParty,
510 ExternalClientRecords,
512 #[default]
514 Internal,
515 AuditorGenerated,
517}
518
519#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize, Default)]
521#[serde(rename_all = "snake_case")]
522pub enum EvidenceReliability {
523 Low,
525 #[default]
527 Moderate,
528 High,
530}
531
532#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize, Default)]
534#[serde(rename_all = "snake_case")]
535pub enum EvidenceRelevance {
536 NotRelevant,
538 PartiallyRelevant,
540 #[default]
542 Relevant,
543 DirectlyRelevant,
545}
546
547#[derive(Debug, Clone, Default, Serialize, Deserialize)]
549pub struct ConclusionTrailNode {
550 pub conclusion_reached: bool,
552
553 pub conclusion_text: String,
555
556 pub conclusion_type: ConclusionType,
558
559 pub misstatements_identified: Vec<MisstatementReference>,
561
562 pub sufficient_evidence: bool,
564
565 pub further_procedures_required: bool,
567
568 pub summary_memo_reference: Option<String>,
570
571 pub prepared_by: String,
573
574 pub reviewed_by: Option<String>,
576
577 pub conclusion_date: Option<chrono::NaiveDate>,
579}
580
581#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize, Default)]
583#[serde(rename_all = "snake_case")]
584pub enum ConclusionType {
585 #[default]
587 Satisfactory,
588 SatisfactoryWithMinorIssues,
590 PotentialMisstatement,
592 MisstatementIdentified,
594 UnableToConclude,
596}
597
598#[derive(Debug, Clone, Serialize, Deserialize)]
600pub struct MisstatementReference {
601 pub misstatement_id: Uuid,
603
604 pub description: String,
606
607 pub amount: Option<rust_decimal::Decimal>,
609
610 pub misstatement_type: MisstatementType,
612}
613
614#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
616#[serde(rename_all = "snake_case")]
617pub enum MisstatementType {
618 Factual,
620 Judgmental,
622 Projected,
624}
625
626#[derive(Debug, Clone, Serialize, Deserialize)]
628pub struct TrailGap {
629 pub gap_type: GapType,
631
632 pub description: String,
634
635 pub severity: GapSeverity,
637
638 pub remediation_required: bool,
640}
641
642#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
644#[serde(rename_all = "snake_case")]
645pub enum GapType {
646 RiskAssessment,
648 PlannedResponse,
650 ProceduresPerformed,
652 Evidence,
654 Conclusion,
656 Linkage,
658 Documentation,
660}
661
662#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
664#[serde(rename_all = "snake_case")]
665pub enum GapSeverity {
666 Low,
668 Medium,
670 High,
672}
673
674#[cfg(test)]
675#[allow(clippy::unwrap_used)]
676mod tests {
677 use super::*;
678
679 #[test]
680 fn test_audit_trail_creation() {
681 let trail = AuditTrail::new(Uuid::now_v7(), "Revenue", Assertion::Occurrence);
682
683 assert_eq!(trail.account_or_area, "Revenue");
684 assert_eq!(trail.assertion, Assertion::Occurrence);
685 assert!(!trail.is_complete());
686 }
687
688 #[test]
689 fn test_gap_identification() {
690 let mut trail = AuditTrail::new(Uuid::now_v7(), "Inventory", Assertion::Existence);
691
692 trail.identify_gaps();
693
694 assert!(!trail.gaps_identified.is_empty());
696 assert!(trail
697 .gaps_identified
698 .iter()
699 .any(|g| matches!(g.gap_type, GapType::RiskAssessment)));
700 assert!(trail
701 .gaps_identified
702 .iter()
703 .any(|g| matches!(g.gap_type, GapType::Evidence)));
704 }
705
706 #[test]
707 fn test_complete_trail() {
708 let mut trail = AuditTrail::new(Uuid::now_v7(), "Cash", Assertion::Existence);
709
710 trail.risk_assessment.risk_identified = true;
712 trail.risk_assessment.risk_description = "Risk of misappropriation".to_string();
713
714 let response =
715 ResponseTrailNode::new("Perform bank reconciliation", ResponseType::Substantive);
716 let response_id = response.response_id;
717 trail.planned_responses.push(response);
718
719 let mut procedure = ProcedureTrailNode::new(
720 "Reconciled bank to GL",
721 chrono::NaiveDate::from_ymd_opt(2024, 1, 31).unwrap(),
722 "Auditor A",
723 );
724 procedure.response_id = Some(response_id);
725 trail.procedures_performed.push(procedure);
726
727 trail.evidence_obtained.push(EvidenceTrailNode::new(
728 EvidenceType::DocumentaryExternal,
729 "Bank statement obtained",
730 EvidenceSource::ExternalThirdParty,
731 ));
732
733 trail.conclusion.conclusion_reached = true;
734 trail.conclusion.conclusion_type = ConclusionType::Satisfactory;
735 trail.conclusion.sufficient_evidence = true;
736
737 trail.identify_gaps();
738
739 assert!(trail.is_complete());
740 assert!(trail.gaps_identified.is_empty());
741 }
742}