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)]
194mod tests {
195    use super::*;
196    use rust_decimal_macros::dec;
197
198    fn make_result(
199        expectation: Decimal,
200        actual: Decimal,
201        threshold: Decimal,
202    ) -> AnalyticalProcedureResult {
203        AnalyticalProcedureResult::new(
204            Uuid::new_v4(),
205            "Revenue",
206            AnalyticalMethod::TrendAnalysis,
207            expectation,
208            "Prior year adjusted for growth",
209            threshold,
210            "5% of expectation",
211            actual,
212        )
213    }
214
215    #[test]
216    fn test_new_analytical_procedure() {
217        let result = make_result(dec!(1_000_000), dec!(1_050_000), dec!(50_000));
218        assert_eq!(result.account_or_area, "Revenue");
219        assert_eq!(result.analytical_method, AnalyticalMethod::TrendAnalysis);
220        assert_eq!(result.procedure_phase, AnalyticalPhase::Substantive);
221        assert_eq!(result.status, AnalyticalStatus::Performed);
222        assert!(result.result_ref.starts_with("AP-"));
223        assert_eq!(result.result_ref.len(), 11); // "AP-" + 8 hex chars
224    }
225
226    #[test]
227    fn test_variance_computation() {
228        let result = make_result(dec!(1_000_000), dec!(1_050_000), dec!(100_000));
229        assert_eq!(result.variance, dec!(50_000));
230        // 50,000 / 1,000,000 * 100 = 5.0
231        let pct = result.variance_percentage;
232        assert!((pct - 5.0).abs() < 0.0001, "expected ~5.0, got {pct}");
233    }
234
235    #[test]
236    fn test_variance_zero_expectation() {
237        let result = make_result(dec!(0), dec!(500), dec!(100));
238        // Should not panic; variance_percentage defaults to 0.0
239        assert_eq!(result.variance_percentage, 0.0);
240        assert_eq!(result.variance, dec!(500));
241    }
242
243    #[test]
244    fn test_requires_investigation_true() {
245        // variance = 50,000; threshold = 30,000 → requires investigation
246        let result = make_result(dec!(1_000_000), dec!(1_050_000), dec!(30_000));
247        assert!(result.requires_investigation);
248    }
249
250    #[test]
251    fn test_requires_investigation_false() {
252        // variance = 10,000; threshold = 50,000 → does NOT require investigation
253        let result = make_result(dec!(1_000_000), dec!(1_010_000), dec!(50_000));
254        assert!(!result.requires_investigation);
255    }
256
257    #[test]
258    fn test_analytical_phase_serde() {
259        let phases = [
260            AnalyticalPhase::Planning,
261            AnalyticalPhase::Substantive,
262            AnalyticalPhase::FinalReview,
263        ];
264        for phase in phases {
265            let json = serde_json::to_string(&phase).unwrap();
266            let roundtripped: AnalyticalPhase = serde_json::from_str(&json).unwrap();
267            assert_eq!(phase, roundtripped);
268        }
269        assert_eq!(
270            serde_json::to_string(&AnalyticalPhase::Planning).unwrap(),
271            "\"planning\""
272        );
273        assert_eq!(
274            serde_json::to_string(&AnalyticalPhase::FinalReview).unwrap(),
275            "\"final_review\""
276        );
277    }
278
279    #[test]
280    fn test_analytical_method_serde() {
281        let methods = [
282            AnalyticalMethod::TrendAnalysis,
283            AnalyticalMethod::RatioAnalysis,
284            AnalyticalMethod::ReasonablenessTest,
285            AnalyticalMethod::Regression,
286            AnalyticalMethod::Comparison,
287        ];
288        for method in methods {
289            let json = serde_json::to_string(&method).unwrap();
290            let roundtripped: AnalyticalMethod = serde_json::from_str(&json).unwrap();
291            assert_eq!(method, roundtripped);
292        }
293        assert_eq!(
294            serde_json::to_string(&AnalyticalMethod::TrendAnalysis).unwrap(),
295            "\"trend_analysis\""
296        );
297        assert_eq!(
298            serde_json::to_string(&AnalyticalMethod::ReasonablenessTest).unwrap(),
299            "\"reasonableness_test\""
300        );
301    }
302
303    #[test]
304    fn test_analytical_conclusion_serde() {
305        let conclusions = [
306            AnalyticalConclusion::Consistent,
307            AnalyticalConclusion::ExplainedVariance,
308            AnalyticalConclusion::FurtherInvestigation,
309            AnalyticalConclusion::PossibleMisstatement,
310        ];
311        for conclusion in conclusions {
312            let json = serde_json::to_string(&conclusion).unwrap();
313            let roundtripped: AnalyticalConclusion = serde_json::from_str(&json).unwrap();
314            assert_eq!(conclusion, roundtripped);
315        }
316        assert_eq!(
317            serde_json::to_string(&AnalyticalConclusion::ExplainedVariance).unwrap(),
318            "\"explained_variance\""
319        );
320        assert_eq!(
321            serde_json::to_string(&AnalyticalConclusion::PossibleMisstatement).unwrap(),
322            "\"possible_misstatement\""
323        );
324    }
325}