Skip to main content

datasynth_core/models/audit/
procedure_step.rs

1//! Audit procedure step models per ISA 330.
2//!
3//! Represents individual steps within a workpaper, each addressing a specific
4//! assertion using a defined procedure type.
5
6use chrono::{DateTime, NaiveDate, Utc};
7use serde::{Deserialize, Serialize};
8use uuid::Uuid;
9
10use super::Assertion;
11
12/// Type of substantive or control testing procedure.
13#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize, Default)]
14#[serde(rename_all = "snake_case")]
15pub enum StepProcedureType {
16    /// Physical or documentary inspection
17    #[default]
18    Inspection,
19    /// Direct observation of a process or activity
20    Observation,
21    /// Inquiry of management or personnel
22    Inquiry,
23    /// External or internal confirmation
24    Confirmation,
25    /// Independent recalculation
26    Recalculation,
27    /// Independent re-execution of a procedure
28    Reperformance,
29    /// Analytical procedure (ratio, trend, expectation)
30    AnalyticalProcedure,
31    /// Tracing from document to ledger (vouching direction)
32    Vouching,
33    /// High-level review scan for unusual items
34    Scanning,
35}
36
37/// Status of an audit procedure step.
38#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize, Default)]
39#[serde(rename_all = "snake_case")]
40pub enum StepStatus {
41    /// Planned but not yet started
42    #[default]
43    Planned,
44    /// Currently being performed
45    InProgress,
46    /// Completed
47    Complete,
48    /// Deferred to a later date
49    Deferred,
50    /// Not applicable to this engagement
51    NotApplicable,
52}
53
54/// Result of a completed audit procedure step.
55#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize, Default)]
56#[serde(rename_all = "snake_case")]
57pub enum StepResult {
58    /// No exception noted
59    #[default]
60    Pass,
61    /// A failure / deviation found
62    Fail,
63    /// An exception noted (may be less than a full failure)
64    Exception,
65    /// Result is inconclusive; additional work required
66    Inconclusive,
67}
68
69/// A single documented step within an audit workpaper (ISA 330).
70#[derive(Debug, Clone, Serialize, Deserialize)]
71pub struct AuditProcedureStep {
72    /// Unique step ID
73    pub step_id: Uuid,
74    /// Step reference code, e.g. "STEP-a1b2c3d4-01"
75    pub step_ref: String,
76    /// Workpaper this step belongs to
77    pub workpaper_id: Uuid,
78    /// Engagement this step belongs to
79    pub engagement_id: Uuid,
80    /// Sequential step number within the workpaper
81    pub step_number: u32,
82    /// Description of the procedure
83    pub description: String,
84    /// Type of audit procedure
85    pub procedure_type: StepProcedureType,
86    /// Assertion addressed by this step
87    pub assertion: Assertion,
88    /// Planned performance date
89    pub planned_date: Option<NaiveDate>,
90    /// Actual performance date
91    pub performed_date: Option<NaiveDate>,
92    /// User ID of the performer
93    pub performed_by: Option<String>,
94    /// Display name of the performer
95    pub performed_by_name: Option<String>,
96    /// Current status of the step
97    pub status: StepStatus,
98    /// Result after completion
99    pub result: Option<StepResult>,
100    /// Whether an exception was noted
101    pub exception_noted: bool,
102    /// Description of any exception
103    pub exception_description: Option<String>,
104    /// Linked audit sample, if sampling was used
105    pub sample_id: Option<Uuid>,
106    /// Evidence items supporting this step
107    pub evidence_ids: Vec<Uuid>,
108    /// Creation timestamp
109    pub created_at: DateTime<Utc>,
110    /// Last-modified timestamp
111    pub updated_at: DateTime<Utc>,
112}
113
114impl AuditProcedureStep {
115    /// Create a new planned audit procedure step.
116    pub fn new(
117        workpaper_id: Uuid,
118        engagement_id: Uuid,
119        step_number: u32,
120        description: impl Into<String>,
121        procedure_type: StepProcedureType,
122        assertion: Assertion,
123    ) -> Self {
124        let now = Utc::now();
125        let step_ref = format!("STEP-{}-{:02}", &workpaper_id.to_string()[..8], step_number,);
126        Self {
127            step_id: Uuid::new_v4(),
128            step_ref,
129            workpaper_id,
130            engagement_id,
131            step_number,
132            description: description.into(),
133            procedure_type,
134            assertion,
135            planned_date: None,
136            performed_date: None,
137            performed_by: None,
138            performed_by_name: None,
139            status: StepStatus::Planned,
140            result: None,
141            exception_noted: false,
142            exception_description: None,
143            sample_id: None,
144            evidence_ids: Vec::new(),
145            created_at: now,
146            updated_at: now,
147        }
148    }
149
150    /// Link this step to an audit sample.
151    pub fn with_sample(mut self, sample_id: Uuid) -> Self {
152        self.sample_id = Some(sample_id);
153        self
154    }
155
156    /// Attach a set of evidence items to this step.
157    pub fn with_evidence(mut self, evidence_ids: Vec<Uuid>) -> Self {
158        self.evidence_ids = evidence_ids;
159        self
160    }
161
162    /// Record performance of the step.
163    ///
164    /// Sets the performer, date, result, status (`Complete`), and
165    /// `exception_noted` (true when `result` is `Exception`).
166    pub fn perform(&mut self, by: String, by_name: String, date: NaiveDate, result: StepResult) {
167        self.performed_by = Some(by);
168        self.performed_by_name = Some(by_name);
169        self.performed_date = Some(date);
170        self.result = Some(result);
171        self.exception_noted = matches!(result, StepResult::Exception);
172        self.status = StepStatus::Complete;
173        self.updated_at = Utc::now();
174    }
175}
176
177#[cfg(test)]
178#[allow(clippy::unwrap_used)]
179mod tests {
180    use super::*;
181
182    fn make_step() -> AuditProcedureStep {
183        AuditProcedureStep::new(
184            Uuid::new_v4(),
185            Uuid::new_v4(),
186            1,
187            "Inspect invoices for proper authorisation",
188            StepProcedureType::Inspection,
189            Assertion::Occurrence,
190        )
191    }
192
193    #[test]
194    fn test_new_step() {
195        let step = make_step();
196        assert_eq!(step.step_number, 1);
197        assert_eq!(step.status, StepStatus::Planned);
198        assert!(step.result.is_none());
199        assert!(!step.exception_noted);
200        assert!(step.step_ref.starts_with("STEP-"));
201        assert!(step.step_ref.ends_with("-01"));
202    }
203
204    #[test]
205    fn test_perform_sets_fields() {
206        let mut step = make_step();
207        let date = NaiveDate::from_ymd_opt(2026, 3, 1).unwrap();
208        step.perform("u123".into(), "Alice Audit".into(), date, StepResult::Pass);
209
210        assert_eq!(step.status, StepStatus::Complete);
211        assert_eq!(step.result, Some(StepResult::Pass));
212        assert_eq!(step.performed_by.as_deref(), Some("u123"));
213        assert_eq!(step.performed_by_name.as_deref(), Some("Alice Audit"));
214        assert_eq!(step.performed_date, Some(date));
215        assert!(!step.exception_noted);
216    }
217
218    #[test]
219    fn test_perform_exception_noted() {
220        let mut step = make_step();
221        let date = NaiveDate::from_ymd_opt(2026, 3, 2).unwrap();
222        step.perform(
223            "u456".into(),
224            "Bob Check".into(),
225            date,
226            StepResult::Exception,
227        );
228
229        assert!(step.exception_noted);
230        assert_eq!(step.result, Some(StepResult::Exception));
231    }
232
233    #[test]
234    fn test_with_sample() {
235        let sample_id = Uuid::new_v4();
236        let step = make_step().with_sample(sample_id);
237        assert_eq!(step.sample_id, Some(sample_id));
238    }
239
240    #[test]
241    fn test_with_evidence() {
242        let ids = vec![Uuid::new_v4(), Uuid::new_v4()];
243        let step = make_step().with_evidence(ids.clone());
244        assert_eq!(step.evidence_ids, ids);
245    }
246
247    #[test]
248    fn test_step_status_serde() {
249        let statuses = [
250            StepStatus::Planned,
251            StepStatus::InProgress,
252            StepStatus::Complete,
253            StepStatus::Deferred,
254            StepStatus::NotApplicable,
255        ];
256        for s in &statuses {
257            let json = serde_json::to_string(s).unwrap();
258            let back: StepStatus = serde_json::from_str(&json).unwrap();
259            assert_eq!(back, *s);
260        }
261    }
262
263    #[test]
264    fn test_step_result_serde() {
265        let results = [
266            StepResult::Pass,
267            StepResult::Fail,
268            StepResult::Exception,
269            StepResult::Inconclusive,
270        ];
271        for r in &results {
272            let json = serde_json::to_string(r).unwrap();
273            let back: StepResult = serde_json::from_str(&json).unwrap();
274            assert_eq!(back, *r);
275        }
276    }
277
278    #[test]
279    fn test_procedure_type_serde() {
280        let types = [
281            StepProcedureType::Inspection,
282            StepProcedureType::Observation,
283            StepProcedureType::Inquiry,
284            StepProcedureType::Confirmation,
285            StepProcedureType::Recalculation,
286            StepProcedureType::Reperformance,
287            StepProcedureType::AnalyticalProcedure,
288            StepProcedureType::Vouching,
289            StepProcedureType::Scanning,
290        ];
291        for t in &types {
292            let json = serde_json::to_string(t).unwrap();
293            let back: StepProcedureType = serde_json::from_str(&json).unwrap();
294            assert_eq!(back, *t);
295        }
296    }
297}