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)]
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); }
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 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 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 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 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}