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    pub created_at: DateTime<Utc>,
130    pub updated_at: DateTime<Utc>,
131}
132
133impl AnalyticalProcedureResult {
134    /// Create a new analytical procedure result.
135    ///
136    /// Automatically computes `variance`, `variance_percentage`, and
137    /// `requires_investigation` from the provided inputs.
138    #[allow(clippy::too_many_arguments)]
139    pub fn new(
140        engagement_id: Uuid,
141        account_or_area: impl Into<String>,
142        analytical_method: AnalyticalMethod,
143        expectation: Decimal,
144        expectation_basis: impl Into<String>,
145        threshold: Decimal,
146        threshold_basis: impl Into<String>,
147        actual_value: Decimal,
148    ) -> Self {
149        let now = Utc::now();
150        let id = Uuid::new_v4();
151        let result_ref = format!("AP-{}", &id.simple().to_string()[..8]);
152
153        let variance = actual_value - expectation;
154        let variance_percentage = if expectation.is_zero() {
155            0.0
156        } else {
157            (variance / expectation * Decimal::from(100))
158                .to_f64()
159                .unwrap_or(0.0)
160        };
161        let requires_investigation = variance.abs() > threshold;
162
163        Self {
164            result_id: id,
165            result_ref,
166            engagement_id,
167            workpaper_id: None,
168            procedure_phase: AnalyticalPhase::Substantive,
169            account_or_area: account_or_area.into(),
170            account_id: None,
171            analytical_method,
172            expectation,
173            expectation_basis: expectation_basis.into(),
174            threshold,
175            threshold_basis: threshold_basis.into(),
176            actual_value,
177            variance,
178            variance_percentage,
179            requires_investigation,
180            explanation: None,
181            explanation_corroborated: None,
182            corroboration_evidence: None,
183            conclusion: None,
184            status: AnalyticalStatus::Performed,
185            created_at: now,
186            updated_at: now,
187        }
188    }
189}
190
191#[cfg(test)]
192#[allow(clippy::unwrap_used)]
193mod tests {
194    use super::*;
195    use rust_decimal_macros::dec;
196
197    fn make_result(
198        expectation: Decimal,
199        actual: Decimal,
200        threshold: Decimal,
201    ) -> AnalyticalProcedureResult {
202        AnalyticalProcedureResult::new(
203            Uuid::new_v4(),
204            "Revenue",
205            AnalyticalMethod::TrendAnalysis,
206            expectation,
207            "Prior year adjusted for growth",
208            threshold,
209            "5% of expectation",
210            actual,
211        )
212    }
213
214    #[test]
215    fn test_new_analytical_procedure() {
216        let result = make_result(dec!(1_000_000), dec!(1_050_000), dec!(50_000));
217        assert_eq!(result.account_or_area, "Revenue");
218        assert_eq!(result.analytical_method, AnalyticalMethod::TrendAnalysis);
219        assert_eq!(result.procedure_phase, AnalyticalPhase::Substantive);
220        assert_eq!(result.status, AnalyticalStatus::Performed);
221        assert!(result.result_ref.starts_with("AP-"));
222        assert_eq!(result.result_ref.len(), 11); // "AP-" + 8 hex chars
223    }
224
225    #[test]
226    fn test_variance_computation() {
227        let result = make_result(dec!(1_000_000), dec!(1_050_000), dec!(100_000));
228        assert_eq!(result.variance, dec!(50_000));
229        // 50,000 / 1,000,000 * 100 = 5.0
230        let pct = result.variance_percentage;
231        assert!((pct - 5.0).abs() < 0.0001, "expected ~5.0, got {pct}");
232    }
233
234    #[test]
235    fn test_variance_zero_expectation() {
236        let result = make_result(dec!(0), dec!(500), dec!(100));
237        // Should not panic; variance_percentage defaults to 0.0
238        assert_eq!(result.variance_percentage, 0.0);
239        assert_eq!(result.variance, dec!(500));
240    }
241
242    #[test]
243    fn test_requires_investigation_true() {
244        // variance = 50,000; threshold = 30,000 → requires investigation
245        let result = make_result(dec!(1_000_000), dec!(1_050_000), dec!(30_000));
246        assert!(result.requires_investigation);
247    }
248
249    #[test]
250    fn test_requires_investigation_false() {
251        // variance = 10,000; threshold = 50,000 → does NOT require investigation
252        let result = make_result(dec!(1_000_000), dec!(1_010_000), dec!(50_000));
253        assert!(!result.requires_investigation);
254    }
255
256    #[test]
257    fn test_analytical_phase_serde() {
258        let phases = [
259            AnalyticalPhase::Planning,
260            AnalyticalPhase::Substantive,
261            AnalyticalPhase::FinalReview,
262        ];
263        for phase in phases {
264            let json = serde_json::to_string(&phase).unwrap();
265            let roundtripped: AnalyticalPhase = serde_json::from_str(&json).unwrap();
266            assert_eq!(phase, roundtripped);
267        }
268        assert_eq!(
269            serde_json::to_string(&AnalyticalPhase::Planning).unwrap(),
270            "\"planning\""
271        );
272        assert_eq!(
273            serde_json::to_string(&AnalyticalPhase::FinalReview).unwrap(),
274            "\"final_review\""
275        );
276    }
277
278    #[test]
279    fn test_analytical_method_serde() {
280        let methods = [
281            AnalyticalMethod::TrendAnalysis,
282            AnalyticalMethod::RatioAnalysis,
283            AnalyticalMethod::ReasonablenessTest,
284            AnalyticalMethod::Regression,
285            AnalyticalMethod::Comparison,
286        ];
287        for method in methods {
288            let json = serde_json::to_string(&method).unwrap();
289            let roundtripped: AnalyticalMethod = serde_json::from_str(&json).unwrap();
290            assert_eq!(method, roundtripped);
291        }
292        assert_eq!(
293            serde_json::to_string(&AnalyticalMethod::TrendAnalysis).unwrap(),
294            "\"trend_analysis\""
295        );
296        assert_eq!(
297            serde_json::to_string(&AnalyticalMethod::ReasonablenessTest).unwrap(),
298            "\"reasonableness_test\""
299        );
300    }
301
302    #[test]
303    fn test_analytical_conclusion_serde() {
304        let conclusions = [
305            AnalyticalConclusion::Consistent,
306            AnalyticalConclusion::ExplainedVariance,
307            AnalyticalConclusion::FurtherInvestigation,
308            AnalyticalConclusion::PossibleMisstatement,
309        ];
310        for conclusion in conclusions {
311            let json = serde_json::to_string(&conclusion).unwrap();
312            let roundtripped: AnalyticalConclusion = serde_json::from_str(&json).unwrap();
313            assert_eq!(conclusion, roundtripped);
314        }
315        assert_eq!(
316            serde_json::to_string(&AnalyticalConclusion::ExplainedVariance).unwrap(),
317            "\"explained_variance\""
318        );
319        assert_eq!(
320            serde_json::to_string(&AnalyticalConclusion::PossibleMisstatement).unwrap(),
321            "\"possible_misstatement\""
322        );
323    }
324}