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)]
180mod tests {
181    use super::*;
182
183    fn make_step() -> AuditProcedureStep {
184        AuditProcedureStep::new(
185            Uuid::new_v4(),
186            Uuid::new_v4(),
187            1,
188            "Inspect invoices for proper authorisation",
189            StepProcedureType::Inspection,
190            Assertion::Occurrence,
191        )
192    }
193
194    #[test]
195    fn test_new_step() {
196        let step = make_step();
197        assert_eq!(step.step_number, 1);
198        assert_eq!(step.status, StepStatus::Planned);
199        assert!(step.result.is_none());
200        assert!(!step.exception_noted);
201        assert!(step.step_ref.starts_with("STEP-"));
202        assert!(step.step_ref.ends_with("-01"));
203    }
204
205    #[test]
206    fn test_perform_sets_fields() {
207        let mut step = make_step();
208        let date = NaiveDate::from_ymd_opt(2026, 3, 1).unwrap();
209        step.perform("u123".into(), "Alice Audit".into(), date, StepResult::Pass);
210
211        assert_eq!(step.status, StepStatus::Complete);
212        assert_eq!(step.result, Some(StepResult::Pass));
213        assert_eq!(step.performed_by.as_deref(), Some("u123"));
214        assert_eq!(step.performed_by_name.as_deref(), Some("Alice Audit"));
215        assert_eq!(step.performed_date, Some(date));
216        assert!(!step.exception_noted);
217    }
218
219    #[test]
220    fn test_perform_exception_noted() {
221        let mut step = make_step();
222        let date = NaiveDate::from_ymd_opt(2026, 3, 2).unwrap();
223        step.perform(
224            "u456".into(),
225            "Bob Check".into(),
226            date,
227            StepResult::Exception,
228        );
229
230        assert!(step.exception_noted);
231        assert_eq!(step.result, Some(StepResult::Exception));
232    }
233
234    #[test]
235    fn test_with_sample() {
236        let sample_id = Uuid::new_v4();
237        let step = make_step().with_sample(sample_id);
238        assert_eq!(step.sample_id, Some(sample_id));
239    }
240
241    #[test]
242    fn test_with_evidence() {
243        let ids = vec![Uuid::new_v4(), Uuid::new_v4()];
244        let step = make_step().with_evidence(ids.clone());
245        assert_eq!(step.evidence_ids, ids);
246    }
247
248    #[test]
249    fn test_step_status_serde() {
250        let statuses = [
251            StepStatus::Planned,
252            StepStatus::InProgress,
253            StepStatus::Complete,
254            StepStatus::Deferred,
255            StepStatus::NotApplicable,
256        ];
257        for s in &statuses {
258            let json = serde_json::to_string(s).unwrap();
259            let back: StepStatus = serde_json::from_str(&json).unwrap();
260            assert_eq!(back, *s);
261        }
262    }
263
264    #[test]
265    fn test_step_result_serde() {
266        let results = [
267            StepResult::Pass,
268            StepResult::Fail,
269            StepResult::Exception,
270            StepResult::Inconclusive,
271        ];
272        for r in &results {
273            let json = serde_json::to_string(r).unwrap();
274            let back: StepResult = serde_json::from_str(&json).unwrap();
275            assert_eq!(back, *r);
276        }
277    }
278
279    #[test]
280    fn test_procedure_type_serde() {
281        let types = [
282            StepProcedureType::Inspection,
283            StepProcedureType::Observation,
284            StepProcedureType::Inquiry,
285            StepProcedureType::Confirmation,
286            StepProcedureType::Recalculation,
287            StepProcedureType::Reperformance,
288            StepProcedureType::AnalyticalProcedure,
289            StepProcedureType::Vouching,
290            StepProcedureType::Scanning,
291        ];
292        for t in &types {
293            let json = serde_json::to_string(t).unwrap();
294            let back: StepProcedureType = serde_json::from_str(&json).unwrap();
295            assert_eq!(back, *t);
296        }
297    }
298}