use chrono::{DateTime, Utc};
use rust_decimal::Decimal;
use serde::{Deserialize, Serialize};
use uuid::Uuid;
use super::SamplingMethod;
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize, Default)]
#[serde(rename_all = "snake_case")]
pub enum SampleItemResult {
#[default]
Correct,
Misstatement,
Exception,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize, Default)]
#[serde(rename_all = "snake_case")]
pub enum SampleConclusion {
ProjectedBelowTolerable,
ProjectedExceedsTolerable,
#[default]
InsufficientEvidence,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct SampleItem {
pub item_id: Uuid,
pub document_ref: String,
pub book_value: Decimal,
pub audited_value: Option<Decimal>,
pub misstatement: Option<Decimal>,
pub result: SampleItemResult,
}
impl SampleItem {
pub fn new(document_ref: impl Into<String>, book_value: Decimal) -> Self {
Self {
item_id: Uuid::new_v4(),
document_ref: document_ref.into(),
book_value,
audited_value: None,
misstatement: None,
result: SampleItemResult::Correct,
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct AuditSample {
pub sample_id: Uuid,
pub sample_ref: String,
pub workpaper_id: Uuid,
pub engagement_id: Uuid,
pub population_description: String,
pub population_size: u64,
pub population_value: Option<Decimal>,
pub sampling_method: SamplingMethod,
pub sample_size: u32,
pub sampling_interval: Option<Decimal>,
pub confidence_level: f64,
pub tolerable_misstatement: Option<Decimal>,
pub expected_misstatement: Option<Decimal>,
pub items: Vec<SampleItem>,
pub total_misstatement_found: Decimal,
pub projected_misstatement: Option<Decimal>,
pub conclusion: Option<SampleConclusion>,
#[serde(with = "crate::serde_timestamp::utc")]
pub created_at: DateTime<Utc>,
#[serde(with = "crate::serde_timestamp::utc")]
pub updated_at: DateTime<Utc>,
}
impl AuditSample {
pub fn new(
workpaper_id: Uuid,
engagement_id: Uuid,
population_description: impl Into<String>,
population_size: u64,
sampling_method: SamplingMethod,
sample_size: u32,
) -> Self {
let now = Utc::now();
let sample_ref = format!("SAMP-{}", &workpaper_id.to_string()[..8]);
Self {
sample_id: Uuid::new_v4(),
sample_ref,
workpaper_id,
engagement_id,
population_description: population_description.into(),
population_size,
population_value: None,
sampling_method,
sample_size,
sampling_interval: None,
confidence_level: 0.95,
tolerable_misstatement: None,
expected_misstatement: None,
items: Vec::new(),
total_misstatement_found: Decimal::ZERO,
projected_misstatement: None,
conclusion: None,
created_at: now,
updated_at: now,
}
}
pub fn add_item(&mut self, item: SampleItem) {
if let Some(m) = item.misstatement {
self.total_misstatement_found += m.abs();
}
self.items.push(item);
self.updated_at = Utc::now();
}
pub fn compute_projected_misstatement(&mut self) {
if self.items.is_empty() {
self.projected_misstatement = Some(Decimal::ZERO);
return;
}
let sample_value: Decimal = self.items.iter().map(|i| i.book_value).sum();
if sample_value == Decimal::ZERO {
self.projected_misstatement = Some(Decimal::ZERO);
return;
}
let projected = match self.population_value {
Some(pop_val) => {
let rate = self.total_misstatement_found / sample_value;
rate * pop_val
}
None => {
let pop_count = Decimal::from(self.population_size);
let samp_count = Decimal::from(self.items.len() as u64);
if samp_count == Decimal::ZERO {
Decimal::ZERO
} else {
let rate = self.total_misstatement_found / sample_value;
let avg_book = sample_value / samp_count;
rate * avg_book * pop_count
}
}
};
self.projected_misstatement = Some(projected);
self.updated_at = Utc::now();
}
pub fn conclude(&mut self) {
self.compute_projected_misstatement();
let projected = self.projected_misstatement.unwrap_or(Decimal::ZERO);
self.conclusion = Some(match self.tolerable_misstatement {
Some(tolerable) => {
if projected <= tolerable {
SampleConclusion::ProjectedBelowTolerable
} else {
SampleConclusion::ProjectedExceedsTolerable
}
}
None => SampleConclusion::InsufficientEvidence,
});
self.updated_at = Utc::now();
}
}
#[cfg(test)]
#[allow(clippy::unwrap_used)]
mod tests {
use super::*;
use rust_decimal_macros::dec;
fn make_sample() -> AuditSample {
AuditSample::new(
Uuid::new_v4(),
Uuid::new_v4(),
"Accounts receivable invoices over $1,000",
500,
SamplingMethod::MonetaryUnit,
50,
)
}
#[test]
fn test_new_sample() {
let s = make_sample();
assert_eq!(s.sample_size, 50);
assert_eq!(s.population_size, 500);
assert_eq!(s.confidence_level, 0.95);
assert_eq!(s.total_misstatement_found, Decimal::ZERO);
assert!(s.conclusion.is_none());
assert!(s.sample_ref.starts_with("SAMP-"));
}
#[test]
fn test_add_item_accumulates_misstatement() {
let mut s = make_sample();
let mut item1 = SampleItem::new("INV-001", dec!(1000));
item1.misstatement = Some(dec!(50));
item1.result = SampleItemResult::Misstatement;
let mut item2 = SampleItem::new("INV-002", dec!(2000));
item2.misstatement = Some(dec!(-30)); item2.result = SampleItemResult::Misstatement;
s.add_item(item1);
s.add_item(item2);
assert_eq!(s.total_misstatement_found, dec!(80)); assert_eq!(s.items.len(), 2);
}
#[test]
fn test_compute_projected_zero_items() {
let mut s = make_sample();
s.compute_projected_misstatement();
assert_eq!(s.projected_misstatement, Some(Decimal::ZERO));
}
#[test]
fn test_compute_projected_zero_sample_value() {
let mut s = make_sample();
s.add_item(SampleItem::new("INV-000", dec!(0)));
s.compute_projected_misstatement();
assert_eq!(s.projected_misstatement, Some(Decimal::ZERO));
}
#[test]
fn test_compute_projected_normal() {
let mut s = make_sample();
s.population_value = Some(dec!(100_000));
let mut item = SampleItem::new("INV-001", dec!(5_000));
item.misstatement = Some(dec!(500));
s.add_item(item);
s.compute_projected_misstatement();
assert_eq!(s.projected_misstatement, Some(dec!(10_000)));
}
#[test]
fn test_conclude_below_tolerable() {
let mut s = make_sample();
s.population_value = Some(dec!(100_000));
s.tolerable_misstatement = Some(dec!(15_000));
let mut item = SampleItem::new("INV-001", dec!(5_000));
item.misstatement = Some(dec!(500)); s.add_item(item);
s.conclude();
assert_eq!(
s.conclusion,
Some(SampleConclusion::ProjectedBelowTolerable)
);
}
#[test]
fn test_conclude_exceeds_tolerable() {
let mut s = make_sample();
s.population_value = Some(dec!(100_000));
s.tolerable_misstatement = Some(dec!(5_000));
let mut item = SampleItem::new("INV-001", dec!(5_000));
item.misstatement = Some(dec!(500)); s.add_item(item);
s.conclude();
assert_eq!(
s.conclusion,
Some(SampleConclusion::ProjectedExceedsTolerable)
);
}
#[test]
fn test_conclude_no_tolerable() {
let mut s = make_sample();
s.conclude();
assert_eq!(s.conclusion, Some(SampleConclusion::InsufficientEvidence));
}
#[test]
fn test_sampling_method_serde() {
let methods = [
SamplingMethod::StatisticalRandom,
SamplingMethod::MonetaryUnit,
SamplingMethod::Judgmental,
SamplingMethod::Haphazard,
SamplingMethod::Block,
SamplingMethod::AllItems,
];
for m in &methods {
let json = serde_json::to_string(m).unwrap();
let back: SamplingMethod = serde_json::from_str(&json).unwrap();
assert_eq!(back, *m);
}
}
#[test]
fn test_sample_conclusion_serde() {
let conclusions = [
SampleConclusion::ProjectedBelowTolerable,
SampleConclusion::ProjectedExceedsTolerable,
SampleConclusion::InsufficientEvidence,
];
for c in &conclusions {
let json = serde_json::to_string(c).unwrap();
let back: SampleConclusion = serde_json::from_str(&json).unwrap();
assert_eq!(back, *c);
}
}
}