datasynth_core/models/audit/
analytical_procedure.rs1use chrono::{DateTime, Utc};
7use rust_decimal::prelude::ToPrimitive;
8use rust_decimal::Decimal;
9use serde::{Deserialize, Serialize};
10use uuid::Uuid;
11
12#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize, Default)]
14#[serde(rename_all = "snake_case")]
15pub enum AnalyticalPhase {
16 Planning,
18 #[default]
20 Substantive,
21 FinalReview,
23}
24
25#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize, Default)]
27#[serde(rename_all = "snake_case")]
28pub enum AnalyticalMethod {
29 #[default]
31 TrendAnalysis,
32 RatioAnalysis,
34 ReasonablenessTest,
36 Regression,
38 Comparison,
40}
41
42#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize, Default)]
44#[serde(rename_all = "snake_case")]
45pub enum AnalyticalConclusion {
46 #[default]
48 Consistent,
49 ExplainedVariance,
51 FurtherInvestigation,
53 PossibleMisstatement,
55}
56
57#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize, Default)]
59#[serde(rename_all = "snake_case")]
60pub enum AnalyticalStatus {
61 Planned,
63 #[default]
65 Performed,
66 Concluded,
68}
69
70#[derive(Debug, Clone, Serialize, Deserialize)]
72pub struct AnalyticalProcedureResult {
73 pub result_id: Uuid,
75 pub result_ref: String,
77 pub engagement_id: Uuid,
79 pub workpaper_id: Option<Uuid>,
81
82 pub procedure_phase: AnalyticalPhase,
85 pub account_or_area: String,
87 pub account_id: Option<String>,
89 pub analytical_method: AnalyticalMethod,
91
92 pub expectation: Decimal,
95 pub expectation_basis: String,
97
98 pub threshold: Decimal,
101 pub threshold_basis: String,
103
104 pub actual_value: Decimal,
107 pub variance: Decimal,
109 pub variance_percentage: f64,
111 pub requires_investigation: bool,
113
114 pub explanation: Option<String>,
117 pub explanation_corroborated: Option<bool>,
119 pub corroboration_evidence: Option<String>,
121
122 pub conclusion: Option<AnalyticalConclusion>,
125 pub status: AnalyticalStatus,
127
128 pub created_at: DateTime<Utc>,
130 pub updated_at: DateTime<Utc>,
131}
132
133impl AnalyticalProcedureResult {
134 #[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); }
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 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 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 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 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}