datasynth_core/models/audit/
sample.rs1use chrono::{DateTime, Utc};
7use rust_decimal::Decimal;
8use serde::{Deserialize, Serialize};
9use uuid::Uuid;
10
11use super::SamplingMethod;
12
13#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize, Default)]
15#[serde(rename_all = "snake_case")]
16pub enum SampleItemResult {
17 #[default]
19 Correct,
20 Misstatement,
22 Exception,
24}
25
26#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize, Default)]
28#[serde(rename_all = "snake_case")]
29pub enum SampleConclusion {
30 ProjectedBelowTolerable,
32 ProjectedExceedsTolerable,
34 #[default]
36 InsufficientEvidence,
37}
38
39#[derive(Debug, Clone, Serialize, Deserialize)]
41pub struct SampleItem {
42 pub item_id: Uuid,
44 pub document_ref: String,
46 pub book_value: Decimal,
48 pub audited_value: Option<Decimal>,
50 pub misstatement: Option<Decimal>,
52 pub result: SampleItemResult,
54}
55
56impl SampleItem {
57 pub fn new(document_ref: impl Into<String>, book_value: Decimal) -> Self {
59 Self {
60 item_id: Uuid::new_v4(),
61 document_ref: document_ref.into(),
62 book_value,
63 audited_value: None,
64 misstatement: None,
65 result: SampleItemResult::Correct,
66 }
67 }
68}
69
70#[derive(Debug, Clone, Serialize, Deserialize)]
72pub struct AuditSample {
73 pub sample_id: Uuid,
75 pub sample_ref: String,
77 pub workpaper_id: Uuid,
79 pub engagement_id: Uuid,
81 pub population_description: String,
83 pub population_size: u64,
85 pub population_value: Option<Decimal>,
87 pub sampling_method: SamplingMethod,
89 pub sample_size: u32,
91 pub sampling_interval: Option<Decimal>,
93 pub confidence_level: f64,
95 pub tolerable_misstatement: Option<Decimal>,
97 pub expected_misstatement: Option<Decimal>,
99 pub items: Vec<SampleItem>,
101 pub total_misstatement_found: Decimal,
103 pub projected_misstatement: Option<Decimal>,
105 pub conclusion: Option<SampleConclusion>,
107 #[serde(with = "crate::serde_timestamp::utc")]
109 pub created_at: DateTime<Utc>,
110 #[serde(with = "crate::serde_timestamp::utc")]
112 pub updated_at: DateTime<Utc>,
113}
114
115impl AuditSample {
116 pub fn new(
118 workpaper_id: Uuid,
119 engagement_id: Uuid,
120 population_description: impl Into<String>,
121 population_size: u64,
122 sampling_method: SamplingMethod,
123 sample_size: u32,
124 ) -> Self {
125 let now = Utc::now();
126 let sample_ref = format!("SAMP-{}", &workpaper_id.to_string()[..8]);
127 Self {
128 sample_id: Uuid::new_v4(),
129 sample_ref,
130 workpaper_id,
131 engagement_id,
132 population_description: population_description.into(),
133 population_size,
134 population_value: None,
135 sampling_method,
136 sample_size,
137 sampling_interval: None,
138 confidence_level: 0.95,
139 tolerable_misstatement: None,
140 expected_misstatement: None,
141 items: Vec::new(),
142 total_misstatement_found: Decimal::ZERO,
143 projected_misstatement: None,
144 conclusion: None,
145 created_at: now,
146 updated_at: now,
147 }
148 }
149
150 pub fn add_item(&mut self, item: SampleItem) {
152 if let Some(m) = item.misstatement {
153 self.total_misstatement_found += m.abs();
154 }
155 self.items.push(item);
156 self.updated_at = Utc::now();
157 }
158
159 pub fn compute_projected_misstatement(&mut self) {
165 if self.items.is_empty() {
166 self.projected_misstatement = Some(Decimal::ZERO);
167 return;
168 }
169
170 let sample_value: Decimal = self.items.iter().map(|i| i.book_value).sum();
171 if sample_value == Decimal::ZERO {
172 self.projected_misstatement = Some(Decimal::ZERO);
173 return;
174 }
175
176 let projected = match self.population_value {
177 Some(pop_val) => {
178 let rate = self.total_misstatement_found / sample_value;
180 rate * pop_val
181 }
182 None => {
183 let pop_count = Decimal::from(self.population_size);
185 let samp_count = Decimal::from(self.items.len() as u64);
186 if samp_count == Decimal::ZERO {
187 Decimal::ZERO
188 } else {
189 let rate = self.total_misstatement_found / sample_value;
190 let avg_book = sample_value / samp_count;
192 rate * avg_book * pop_count
193 }
194 }
195 };
196
197 self.projected_misstatement = Some(projected);
198 self.updated_at = Utc::now();
199 }
200
201 pub fn conclude(&mut self) {
203 self.compute_projected_misstatement();
204 let projected = self.projected_misstatement.unwrap_or(Decimal::ZERO);
205
206 self.conclusion = Some(match self.tolerable_misstatement {
207 Some(tolerable) => {
208 if projected <= tolerable {
209 SampleConclusion::ProjectedBelowTolerable
210 } else {
211 SampleConclusion::ProjectedExceedsTolerable
212 }
213 }
214 None => SampleConclusion::InsufficientEvidence,
215 });
216 self.updated_at = Utc::now();
217 }
218}
219
220#[cfg(test)]
221#[allow(clippy::unwrap_used)]
222mod tests {
223 use super::*;
224 use rust_decimal_macros::dec;
225
226 fn make_sample() -> AuditSample {
227 AuditSample::new(
228 Uuid::new_v4(),
229 Uuid::new_v4(),
230 "Accounts receivable invoices over $1,000",
231 500,
232 SamplingMethod::MonetaryUnit,
233 50,
234 )
235 }
236
237 #[test]
238 fn test_new_sample() {
239 let s = make_sample();
240 assert_eq!(s.sample_size, 50);
241 assert_eq!(s.population_size, 500);
242 assert_eq!(s.confidence_level, 0.95);
243 assert_eq!(s.total_misstatement_found, Decimal::ZERO);
244 assert!(s.conclusion.is_none());
245 assert!(s.sample_ref.starts_with("SAMP-"));
246 }
247
248 #[test]
249 fn test_add_item_accumulates_misstatement() {
250 let mut s = make_sample();
251
252 let mut item1 = SampleItem::new("INV-001", dec!(1000));
253 item1.misstatement = Some(dec!(50));
254 item1.result = SampleItemResult::Misstatement;
255
256 let mut item2 = SampleItem::new("INV-002", dec!(2000));
257 item2.misstatement = Some(dec!(-30)); item2.result = SampleItemResult::Misstatement;
259
260 s.add_item(item1);
261 s.add_item(item2);
262
263 assert_eq!(s.total_misstatement_found, dec!(80)); assert_eq!(s.items.len(), 2);
265 }
266
267 #[test]
268 fn test_compute_projected_zero_items() {
269 let mut s = make_sample();
270 s.compute_projected_misstatement();
271 assert_eq!(s.projected_misstatement, Some(Decimal::ZERO));
272 }
273
274 #[test]
275 fn test_compute_projected_zero_sample_value() {
276 let mut s = make_sample();
277 s.add_item(SampleItem::new("INV-000", dec!(0)));
279 s.compute_projected_misstatement();
280 assert_eq!(s.projected_misstatement, Some(Decimal::ZERO));
281 }
282
283 #[test]
284 fn test_compute_projected_normal() {
285 let mut s = make_sample();
286 s.population_value = Some(dec!(100_000));
287
288 let mut item = SampleItem::new("INV-001", dec!(5_000));
289 item.misstatement = Some(dec!(500));
290 s.add_item(item);
291
292 s.compute_projected_misstatement();
293 assert_eq!(s.projected_misstatement, Some(dec!(10_000)));
295 }
296
297 #[test]
298 fn test_conclude_below_tolerable() {
299 let mut s = make_sample();
300 s.population_value = Some(dec!(100_000));
301 s.tolerable_misstatement = Some(dec!(15_000));
302
303 let mut item = SampleItem::new("INV-001", dec!(5_000));
304 item.misstatement = Some(dec!(500)); s.add_item(item);
306
307 s.conclude();
308 assert_eq!(
309 s.conclusion,
310 Some(SampleConclusion::ProjectedBelowTolerable)
311 );
312 }
313
314 #[test]
315 fn test_conclude_exceeds_tolerable() {
316 let mut s = make_sample();
317 s.population_value = Some(dec!(100_000));
318 s.tolerable_misstatement = Some(dec!(5_000));
319
320 let mut item = SampleItem::new("INV-001", dec!(5_000));
321 item.misstatement = Some(dec!(500)); s.add_item(item);
323
324 s.conclude();
325 assert_eq!(
326 s.conclusion,
327 Some(SampleConclusion::ProjectedExceedsTolerable)
328 );
329 }
330
331 #[test]
332 fn test_conclude_no_tolerable() {
333 let mut s = make_sample();
334 s.conclude();
336 assert_eq!(s.conclusion, Some(SampleConclusion::InsufficientEvidence));
337 }
338
339 #[test]
340 fn test_sampling_method_serde() {
341 let methods = [
342 SamplingMethod::StatisticalRandom,
343 SamplingMethod::MonetaryUnit,
344 SamplingMethod::Judgmental,
345 SamplingMethod::Haphazard,
346 SamplingMethod::Block,
347 SamplingMethod::AllItems,
348 ];
349 for m in &methods {
350 let json = serde_json::to_string(m).unwrap();
351 let back: SamplingMethod = serde_json::from_str(&json).unwrap();
352 assert_eq!(back, *m);
353 }
354 }
355
356 #[test]
357 fn test_sample_conclusion_serde() {
358 let conclusions = [
359 SampleConclusion::ProjectedBelowTolerable,
360 SampleConclusion::ProjectedExceedsTolerable,
361 SampleConclusion::InsufficientEvidence,
362 ];
363 for c in &conclusions {
364 let json = serde_json::to_string(c).unwrap();
365 let back: SampleConclusion = serde_json::from_str(&json).unwrap();
366 assert_eq!(back, *c);
367 }
368 }
369}