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 #[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 #[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); }
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 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 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 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 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}