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    #[serde(with = "crate::serde_timestamp::utc")]
110    pub created_at: DateTime<Utc>,
111    /// Last-modified timestamp
112    #[serde(with = "crate::serde_timestamp::utc")]
113    pub updated_at: DateTime<Utc>,
114}
115
116impl AuditProcedureStep {
117    /// Create a new planned audit procedure step.
118    pub fn new(
119        workpaper_id: Uuid,
120        engagement_id: Uuid,
121        step_number: u32,
122        description: impl Into<String>,
123        procedure_type: StepProcedureType,
124        assertion: Assertion,
125    ) -> Self {
126        let now = Utc::now();
127        let step_ref = format!("STEP-{}-{:02}", &workpaper_id.to_string()[..8], step_number,);
128        Self {
129            step_id: Uuid::new_v4(),
130            step_ref,
131            workpaper_id,
132            engagement_id,
133            step_number,
134            description: description.into(),
135            procedure_type,
136            assertion,
137            planned_date: None,
138            performed_date: None,
139            performed_by: None,
140            performed_by_name: None,
141            status: StepStatus::Planned,
142            result: None,
143            exception_noted: false,
144            exception_description: None,
145            sample_id: None,
146            evidence_ids: Vec::new(),
147            created_at: now,
148            updated_at: now,
149        }
150    }
151
152    /// Link this step to an audit sample.
153    pub fn with_sample(mut self, sample_id: Uuid) -> Self {
154        self.sample_id = Some(sample_id);
155        self
156    }
157
158    /// Attach a set of evidence items to this step.
159    pub fn with_evidence(mut self, evidence_ids: Vec<Uuid>) -> Self {
160        self.evidence_ids = evidence_ids;
161        self
162    }
163
164    /// Record performance of the step.
165    ///
166    /// Sets the performer, date, result, status (`Complete`), and
167    /// `exception_noted` (true when `result` is `Exception`).
168    pub fn perform(&mut self, by: String, by_name: String, date: NaiveDate, result: StepResult) {
169        self.performed_by = Some(by);
170        self.performed_by_name = Some(by_name);
171        self.performed_date = Some(date);
172        self.result = Some(result);
173        self.exception_noted = matches!(result, StepResult::Exception);
174        self.status = StepStatus::Complete;
175        self.updated_at = Utc::now();
176    }
177}
178
179#[cfg(test)]
180#[allow(clippy::unwrap_used)]
181mod tests {
182    use super::*;
183
184    fn make_step() -> AuditProcedureStep {
185        AuditProcedureStep::new(
186            Uuid::new_v4(),
187            Uuid::new_v4(),
188            1,
189            "Inspect invoices for proper authorisation",
190            StepProcedureType::Inspection,
191            Assertion::Occurrence,
192        )
193    }
194
195    #[test]
196    fn test_new_step() {
197        let step = make_step();
198        assert_eq!(step.step_number, 1);
199        assert_eq!(step.status, StepStatus::Planned);
200        assert!(step.result.is_none());
201        assert!(!step.exception_noted);
202        assert!(step.step_ref.starts_with("STEP-"));
203        assert!(step.step_ref.ends_with("-01"));
204    }
205
206    #[test]
207    fn test_perform_sets_fields() {
208        let mut step = make_step();
209        let date = NaiveDate::from_ymd_opt(2026, 3, 1).unwrap();
210        step.perform("u123".into(), "Alice Audit".into(), date, StepResult::Pass);
211
212        assert_eq!(step.status, StepStatus::Complete);
213        assert_eq!(step.result, Some(StepResult::Pass));
214        assert_eq!(step.performed_by.as_deref(), Some("u123"));
215        assert_eq!(step.performed_by_name.as_deref(), Some("Alice Audit"));
216        assert_eq!(step.performed_date, Some(date));
217        assert!(!step.exception_noted);
218    }
219
220    #[test]
221    fn test_perform_exception_noted() {
222        let mut step = make_step();
223        let date = NaiveDate::from_ymd_opt(2026, 3, 2).unwrap();
224        step.perform(
225            "u456".into(),
226            "Bob Check".into(),
227            date,
228            StepResult::Exception,
229        );
230
231        assert!(step.exception_noted);
232        assert_eq!(step.result, Some(StepResult::Exception));
233    }
234
235    #[test]
236    fn test_with_sample() {
237        let sample_id = Uuid::new_v4();
238        let step = make_step().with_sample(sample_id);
239        assert_eq!(step.sample_id, Some(sample_id));
240    }
241
242    #[test]
243    fn test_with_evidence() {
244        let ids = vec![Uuid::new_v4(), Uuid::new_v4()];
245        let step = make_step().with_evidence(ids.clone());
246        assert_eq!(step.evidence_ids, ids);
247    }
248
249    #[test]
250    fn test_step_status_serde() {
251        let statuses = [
252            StepStatus::Planned,
253            StepStatus::InProgress,
254            StepStatus::Complete,
255            StepStatus::Deferred,
256            StepStatus::NotApplicable,
257        ];
258        for s in &statuses {
259            let json = serde_json::to_string(s).unwrap();
260            let back: StepStatus = serde_json::from_str(&json).unwrap();
261            assert_eq!(back, *s);
262        }
263    }
264
265    #[test]
266    fn test_step_result_serde() {
267        let results = [
268            StepResult::Pass,
269            StepResult::Fail,
270            StepResult::Exception,
271            StepResult::Inconclusive,
272        ];
273        for r in &results {
274            let json = serde_json::to_string(r).unwrap();
275            let back: StepResult = serde_json::from_str(&json).unwrap();
276            assert_eq!(back, *r);
277        }
278    }
279
280    #[test]
281    fn test_procedure_type_serde() {
282        let types = [
283            StepProcedureType::Inspection,
284            StepProcedureType::Observation,
285            StepProcedureType::Inquiry,
286            StepProcedureType::Confirmation,
287            StepProcedureType::Recalculation,
288            StepProcedureType::Reperformance,
289            StepProcedureType::AnalyticalProcedure,
290            StepProcedureType::Vouching,
291            StepProcedureType::Scanning,
292        ];
293        for t in &types {
294            let json = serde_json::to_string(t).unwrap();
295            let back: StepProcedureType = serde_json::from_str(&json).unwrap();
296            assert_eq!(back, *t);
297        }
298    }
299}