use chrono::{DateTime, Utc};
use rust_decimal::prelude::ToPrimitive;
use rust_decimal::Decimal;
use serde::{Deserialize, Serialize};
use uuid::Uuid;
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize, Default)]
#[serde(rename_all = "snake_case")]
pub enum AnalyticalPhase {
Planning,
#[default]
Substantive,
FinalReview,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize, Default)]
#[serde(rename_all = "snake_case")]
pub enum AnalyticalMethod {
#[default]
TrendAnalysis,
RatioAnalysis,
ReasonablenessTest,
Regression,
Comparison,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize, Default)]
#[serde(rename_all = "snake_case")]
pub enum AnalyticalConclusion {
#[default]
Consistent,
ExplainedVariance,
FurtherInvestigation,
PossibleMisstatement,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize, Default)]
#[serde(rename_all = "snake_case")]
pub enum AnalyticalStatus {
Planned,
#[default]
Performed,
Concluded,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct AnalyticalProcedureResult {
pub result_id: Uuid,
pub result_ref: String,
pub engagement_id: Uuid,
pub workpaper_id: Option<Uuid>,
pub procedure_phase: AnalyticalPhase,
pub account_or_area: String,
pub account_id: Option<String>,
pub analytical_method: AnalyticalMethod,
pub expectation: Decimal,
pub expectation_basis: String,
pub threshold: Decimal,
pub threshold_basis: String,
pub actual_value: Decimal,
pub variance: Decimal,
pub variance_percentage: f64,
pub requires_investigation: bool,
pub explanation: Option<String>,
pub explanation_corroborated: Option<bool>,
pub corroboration_evidence: Option<String>,
pub conclusion: Option<AnalyticalConclusion>,
pub status: AnalyticalStatus,
#[serde(with = "crate::serde_timestamp::utc")]
pub created_at: DateTime<Utc>,
#[serde(with = "crate::serde_timestamp::utc")]
pub updated_at: DateTime<Utc>,
}
impl AnalyticalProcedureResult {
#[allow(clippy::too_many_arguments)]
pub fn new(
engagement_id: Uuid,
account_or_area: impl Into<String>,
analytical_method: AnalyticalMethod,
expectation: Decimal,
expectation_basis: impl Into<String>,
threshold: Decimal,
threshold_basis: impl Into<String>,
actual_value: Decimal,
) -> Self {
let now = Utc::now();
let id = Uuid::new_v4();
let result_ref = format!("AP-{}", &id.simple().to_string()[..8]);
let variance = actual_value - expectation;
let variance_percentage = if expectation.is_zero() {
0.0
} else {
(variance / expectation * Decimal::from(100))
.to_f64()
.unwrap_or(0.0)
};
let requires_investigation = variance.abs() > threshold;
Self {
result_id: id,
result_ref,
engagement_id,
workpaper_id: None,
procedure_phase: AnalyticalPhase::Substantive,
account_or_area: account_or_area.into(),
account_id: None,
analytical_method,
expectation,
expectation_basis: expectation_basis.into(),
threshold,
threshold_basis: threshold_basis.into(),
actual_value,
variance,
variance_percentage,
requires_investigation,
explanation: None,
explanation_corroborated: None,
corroboration_evidence: None,
conclusion: None,
status: AnalyticalStatus::Performed,
created_at: now,
updated_at: now,
}
}
}
#[cfg(test)]
#[allow(clippy::unwrap_used)]
mod tests {
use super::*;
use rust_decimal_macros::dec;
fn make_result(
expectation: Decimal,
actual: Decimal,
threshold: Decimal,
) -> AnalyticalProcedureResult {
AnalyticalProcedureResult::new(
Uuid::new_v4(),
"Revenue",
AnalyticalMethod::TrendAnalysis,
expectation,
"Prior year adjusted for growth",
threshold,
"5% of expectation",
actual,
)
}
#[test]
fn test_new_analytical_procedure() {
let result = make_result(dec!(1_000_000), dec!(1_050_000), dec!(50_000));
assert_eq!(result.account_or_area, "Revenue");
assert_eq!(result.analytical_method, AnalyticalMethod::TrendAnalysis);
assert_eq!(result.procedure_phase, AnalyticalPhase::Substantive);
assert_eq!(result.status, AnalyticalStatus::Performed);
assert!(result.result_ref.starts_with("AP-"));
assert_eq!(result.result_ref.len(), 11); }
#[test]
fn test_variance_computation() {
let result = make_result(dec!(1_000_000), dec!(1_050_000), dec!(100_000));
assert_eq!(result.variance, dec!(50_000));
let pct = result.variance_percentage;
assert!((pct - 5.0).abs() < 0.0001, "expected ~5.0, got {pct}");
}
#[test]
fn test_variance_zero_expectation() {
let result = make_result(dec!(0), dec!(500), dec!(100));
assert_eq!(result.variance_percentage, 0.0);
assert_eq!(result.variance, dec!(500));
}
#[test]
fn test_requires_investigation_true() {
let result = make_result(dec!(1_000_000), dec!(1_050_000), dec!(30_000));
assert!(result.requires_investigation);
}
#[test]
fn test_requires_investigation_false() {
let result = make_result(dec!(1_000_000), dec!(1_010_000), dec!(50_000));
assert!(!result.requires_investigation);
}
#[test]
fn test_analytical_phase_serde() {
let phases = [
AnalyticalPhase::Planning,
AnalyticalPhase::Substantive,
AnalyticalPhase::FinalReview,
];
for phase in phases {
let json = serde_json::to_string(&phase).unwrap();
let roundtripped: AnalyticalPhase = serde_json::from_str(&json).unwrap();
assert_eq!(phase, roundtripped);
}
assert_eq!(
serde_json::to_string(&AnalyticalPhase::Planning).unwrap(),
"\"planning\""
);
assert_eq!(
serde_json::to_string(&AnalyticalPhase::FinalReview).unwrap(),
"\"final_review\""
);
}
#[test]
fn test_analytical_method_serde() {
let methods = [
AnalyticalMethod::TrendAnalysis,
AnalyticalMethod::RatioAnalysis,
AnalyticalMethod::ReasonablenessTest,
AnalyticalMethod::Regression,
AnalyticalMethod::Comparison,
];
for method in methods {
let json = serde_json::to_string(&method).unwrap();
let roundtripped: AnalyticalMethod = serde_json::from_str(&json).unwrap();
assert_eq!(method, roundtripped);
}
assert_eq!(
serde_json::to_string(&AnalyticalMethod::TrendAnalysis).unwrap(),
"\"trend_analysis\""
);
assert_eq!(
serde_json::to_string(&AnalyticalMethod::ReasonablenessTest).unwrap(),
"\"reasonableness_test\""
);
}
#[test]
fn test_analytical_conclusion_serde() {
let conclusions = [
AnalyticalConclusion::Consistent,
AnalyticalConclusion::ExplainedVariance,
AnalyticalConclusion::FurtherInvestigation,
AnalyticalConclusion::PossibleMisstatement,
];
for conclusion in conclusions {
let json = serde_json::to_string(&conclusion).unwrap();
let roundtripped: AnalyticalConclusion = serde_json::from_str(&json).unwrap();
assert_eq!(conclusion, roundtripped);
}
assert_eq!(
serde_json::to_string(&AnalyticalConclusion::ExplainedVariance).unwrap(),
"\"explained_variance\""
);
assert_eq!(
serde_json::to_string(&AnalyticalConclusion::PossibleMisstatement).unwrap(),
"\"possible_misstatement\""
);
}
}