Skip to main content

datasynth_core/models/audit/
analytical_procedure.rs

1//! Analytical procedure models per ISA 520.
2//!
3//! Analytical procedures are evaluations of financial information through
4//! analysis of plausible relationships among both financial and non-financial data.
5
6use chrono::{DateTime, Utc};
7use rust_decimal::prelude::ToPrimitive;
8use rust_decimal::Decimal;
9use serde::{Deserialize, Serialize};
10use uuid::Uuid;
11
12/// Phase of the audit in which the analytical procedure is applied.
13#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize, Default)]
14#[serde(rename_all = "snake_case")]
15pub enum AnalyticalPhase {
16    /// Planning phase — used to understand the entity and identify risk areas
17    Planning,
18    /// Substantive phase — used as a substantive procedure to detect material misstatement
19    #[default]
20    Substantive,
21    /// Final review phase — used as an overall review at completion
22    FinalReview,
23}
24
25/// Method used to perform the analytical procedure.
26#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize, Default)]
27#[serde(rename_all = "snake_case")]
28pub enum AnalyticalMethod {
29    /// Analysis of changes over time
30    #[default]
31    TrendAnalysis,
32    /// Computation of key financial ratios
33    RatioAnalysis,
34    /// Assessment of whether recorded amounts are reasonable
35    ReasonablenessTest,
36    /// Statistical regression to develop an expectation
37    Regression,
38    /// Comparison against industry data or prior periods
39    Comparison,
40}
41
42/// Conclusion reached after performing the analytical procedure.
43#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize, Default)]
44#[serde(rename_all = "snake_case")]
45pub enum AnalyticalConclusion {
46    /// Result is consistent with auditor's expectation — no further work required
47    #[default]
48    Consistent,
49    /// Variance exists but has been satisfactorily explained
50    ExplainedVariance,
51    /// Variance requires further investigation before a conclusion can be drawn
52    FurtherInvestigation,
53    /// Variance may indicate a possible misstatement
54    PossibleMisstatement,
55}
56
57/// Lifecycle status of the analytical procedure.
58#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize, Default)]
59#[serde(rename_all = "snake_case")]
60pub enum AnalyticalStatus {
61    /// Procedure has been planned but not yet performed
62    Planned,
63    /// Procedure has been performed and variance computed
64    #[default]
65    Performed,
66    /// Procedure has been completed and a conclusion recorded
67    Concluded,
68}
69
70/// Result of a single analytical procedure per ISA 520.
71#[derive(Debug, Clone, Serialize, Deserialize)]
72pub struct AnalyticalProcedureResult {
73    /// Unique result ID
74    pub result_id: Uuid,
75    /// Human-readable reference (format: "AP-{first 8 hex chars of result_id}")
76    pub result_ref: String,
77    /// Engagement this result belongs to
78    pub engagement_id: Uuid,
79    /// Workpaper that documents this procedure (optional)
80    pub workpaper_id: Option<Uuid>,
81
82    // === Procedure Design ===
83    /// Phase in which this procedure is applied
84    pub procedure_phase: AnalyticalPhase,
85    /// Account or area under analysis (e.g., "Revenue", "Accounts Receivable")
86    pub account_or_area: String,
87    /// Specific account ID if applicable
88    pub account_id: Option<String>,
89    /// Analytical method used
90    pub analytical_method: AnalyticalMethod,
91
92    // === Expectation ===
93    /// Auditor's expectation of the recorded amount
94    pub expectation: Decimal,
95    /// Basis on which the expectation was developed
96    pub expectation_basis: String,
97
98    // === Threshold ===
99    /// Threshold of acceptable variance before investigation is required
100    pub threshold: Decimal,
101    /// Basis for setting the threshold (e.g., "5% of expectation" or "materiality")
102    pub threshold_basis: String,
103
104    // === Actual & Computed Fields ===
105    /// Actual recorded amount
106    pub actual_value: Decimal,
107    /// Variance (actual − expectation), auto-computed by constructor
108    pub variance: Decimal,
109    /// Variance as a percentage of expectation, auto-computed
110    pub variance_percentage: f64,
111    /// Whether the variance exceeds the threshold, auto-computed
112    pub requires_investigation: bool,
113
114    // === Investigation & Explanation ===
115    /// Explanation provided by management or the auditor for the variance
116    pub explanation: Option<String>,
117    /// Whether the explanation has been corroborated by additional evidence
118    pub explanation_corroborated: Option<bool>,
119    /// Description of corroboration evidence if applicable
120    pub corroboration_evidence: Option<String>,
121
122    // === Conclusion ===
123    /// Conclusion reached after evaluation
124    pub conclusion: Option<AnalyticalConclusion>,
125    /// Current lifecycle status
126    pub status: AnalyticalStatus,
127
128    // === Timestamps ===
129    #[serde(with = "crate::serde_timestamp::utc")]
130    pub created_at: DateTime<Utc>,
131    #[serde(with = "crate::serde_timestamp::utc")]
132    pub updated_at: DateTime<Utc>,
133}
134
135impl AnalyticalProcedureResult {
136    /// Create a new analytical procedure result.
137    ///
138    /// Automatically computes `variance`, `variance_percentage`, and
139    /// `requires_investigation` from the provided inputs.
140    #[allow(clippy::too_many_arguments)]
141    pub fn new(
142        engagement_id: Uuid,
143        account_or_area: impl Into<String>,
144        analytical_method: AnalyticalMethod,
145        expectation: Decimal,
146        expectation_basis: impl Into<String>,
147        threshold: Decimal,
148        threshold_basis: impl Into<String>,
149        actual_value: Decimal,
150    ) -> Self {
151        let now = Utc::now();
152        let id = Uuid::new_v4();
153        let result_ref = format!("AP-{}", &id.simple().to_string()[..8]);
154
155        let variance = actual_value - expectation;
156        let variance_percentage = if expectation.is_zero() {
157            0.0
158        } else {
159            (variance / expectation * Decimal::from(100))
160                .to_f64()
161                .unwrap_or(0.0)
162        };
163        let requires_investigation = variance.abs() > threshold;
164
165        Self {
166            result_id: id,
167            result_ref,
168            engagement_id,
169            workpaper_id: None,
170            procedure_phase: AnalyticalPhase::Substantive,
171            account_or_area: account_or_area.into(),
172            account_id: None,
173            analytical_method,
174            expectation,
175            expectation_basis: expectation_basis.into(),
176            threshold,
177            threshold_basis: threshold_basis.into(),
178            actual_value,
179            variance,
180            variance_percentage,
181            requires_investigation,
182            explanation: None,
183            explanation_corroborated: None,
184            corroboration_evidence: None,
185            conclusion: None,
186            status: AnalyticalStatus::Performed,
187            created_at: now,
188            updated_at: now,
189        }
190    }
191}
192
193#[cfg(test)]
194#[allow(clippy::unwrap_used)]
195mod tests {
196    use super::*;
197    use rust_decimal_macros::dec;
198
199    fn make_result(
200        expectation: Decimal,
201        actual: Decimal,
202        threshold: Decimal,
203    ) -> AnalyticalProcedureResult {
204        AnalyticalProcedureResult::new(
205            Uuid::new_v4(),
206            "Revenue",
207            AnalyticalMethod::TrendAnalysis,
208            expectation,
209            "Prior year adjusted for growth",
210            threshold,
211            "5% of expectation",
212            actual,
213        )
214    }
215
216    #[test]
217    fn test_new_analytical_procedure() {
218        let result = make_result(dec!(1_000_000), dec!(1_050_000), dec!(50_000));
219        assert_eq!(result.account_or_area, "Revenue");
220        assert_eq!(result.analytical_method, AnalyticalMethod::TrendAnalysis);
221        assert_eq!(result.procedure_phase, AnalyticalPhase::Substantive);
222        assert_eq!(result.status, AnalyticalStatus::Performed);
223        assert!(result.result_ref.starts_with("AP-"));
224        assert_eq!(result.result_ref.len(), 11); // "AP-" + 8 hex chars
225    }
226
227    #[test]
228    fn test_variance_computation() {
229        let result = make_result(dec!(1_000_000), dec!(1_050_000), dec!(100_000));
230        assert_eq!(result.variance, dec!(50_000));
231        // 50,000 / 1,000,000 * 100 = 5.0
232        let pct = result.variance_percentage;
233        assert!((pct - 5.0).abs() < 0.0001, "expected ~5.0, got {pct}");
234    }
235
236    #[test]
237    fn test_variance_zero_expectation() {
238        let result = make_result(dec!(0), dec!(500), dec!(100));
239        // Should not panic; variance_percentage defaults to 0.0
240        assert_eq!(result.variance_percentage, 0.0);
241        assert_eq!(result.variance, dec!(500));
242    }
243
244    #[test]
245    fn test_requires_investigation_true() {
246        // variance = 50,000; threshold = 30,000 → requires investigation
247        let result = make_result(dec!(1_000_000), dec!(1_050_000), dec!(30_000));
248        assert!(result.requires_investigation);
249    }
250
251    #[test]
252    fn test_requires_investigation_false() {
253        // variance = 10,000; threshold = 50,000 → does NOT require investigation
254        let result = make_result(dec!(1_000_000), dec!(1_010_000), dec!(50_000));
255        assert!(!result.requires_investigation);
256    }
257
258    #[test]
259    fn test_analytical_phase_serde() {
260        let phases = [
261            AnalyticalPhase::Planning,
262            AnalyticalPhase::Substantive,
263            AnalyticalPhase::FinalReview,
264        ];
265        for phase in phases {
266            let json = serde_json::to_string(&phase).unwrap();
267            let roundtripped: AnalyticalPhase = serde_json::from_str(&json).unwrap();
268            assert_eq!(phase, roundtripped);
269        }
270        assert_eq!(
271            serde_json::to_string(&AnalyticalPhase::Planning).unwrap(),
272            "\"planning\""
273        );
274        assert_eq!(
275            serde_json::to_string(&AnalyticalPhase::FinalReview).unwrap(),
276            "\"final_review\""
277        );
278    }
279
280    #[test]
281    fn test_analytical_method_serde() {
282        let methods = [
283            AnalyticalMethod::TrendAnalysis,
284            AnalyticalMethod::RatioAnalysis,
285            AnalyticalMethod::ReasonablenessTest,
286            AnalyticalMethod::Regression,
287            AnalyticalMethod::Comparison,
288        ];
289        for method in methods {
290            let json = serde_json::to_string(&method).unwrap();
291            let roundtripped: AnalyticalMethod = serde_json::from_str(&json).unwrap();
292            assert_eq!(method, roundtripped);
293        }
294        assert_eq!(
295            serde_json::to_string(&AnalyticalMethod::TrendAnalysis).unwrap(),
296            "\"trend_analysis\""
297        );
298        assert_eq!(
299            serde_json::to_string(&AnalyticalMethod::ReasonablenessTest).unwrap(),
300            "\"reasonableness_test\""
301        );
302    }
303
304    #[test]
305    fn test_analytical_conclusion_serde() {
306        let conclusions = [
307            AnalyticalConclusion::Consistent,
308            AnalyticalConclusion::ExplainedVariance,
309            AnalyticalConclusion::FurtherInvestigation,
310            AnalyticalConclusion::PossibleMisstatement,
311        ];
312        for conclusion in conclusions {
313            let json = serde_json::to_string(&conclusion).unwrap();
314            let roundtripped: AnalyticalConclusion = serde_json::from_str(&json).unwrap();
315            assert_eq!(conclusion, roundtripped);
316        }
317        assert_eq!(
318            serde_json::to_string(&AnalyticalConclusion::ExplainedVariance).unwrap(),
319            "\"explained_variance\""
320        );
321        assert_eq!(
322            serde_json::to_string(&AnalyticalConclusion::PossibleMisstatement).unwrap(),
323            "\"possible_misstatement\""
324        );
325    }
326}