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 pub created_at: DateTime<Utc>,
109 pub updated_at: DateTime<Utc>,
111}
112
113impl AuditSample {
114 pub fn new(
116 workpaper_id: Uuid,
117 engagement_id: Uuid,
118 population_description: impl Into<String>,
119 population_size: u64,
120 sampling_method: SamplingMethod,
121 sample_size: u32,
122 ) -> Self {
123 let now = Utc::now();
124 let sample_ref = format!("SAMP-{}", &workpaper_id.to_string()[..8]);
125 Self {
126 sample_id: Uuid::new_v4(),
127 sample_ref,
128 workpaper_id,
129 engagement_id,
130 population_description: population_description.into(),
131 population_size,
132 population_value: None,
133 sampling_method,
134 sample_size,
135 sampling_interval: None,
136 confidence_level: 0.95,
137 tolerable_misstatement: None,
138 expected_misstatement: None,
139 items: Vec::new(),
140 total_misstatement_found: Decimal::ZERO,
141 projected_misstatement: None,
142 conclusion: None,
143 created_at: now,
144 updated_at: now,
145 }
146 }
147
148 pub fn add_item(&mut self, item: SampleItem) {
150 if let Some(m) = item.misstatement {
151 self.total_misstatement_found += m.abs();
152 }
153 self.items.push(item);
154 self.updated_at = Utc::now();
155 }
156
157 pub fn compute_projected_misstatement(&mut self) {
163 if self.items.is_empty() {
164 self.projected_misstatement = Some(Decimal::ZERO);
165 return;
166 }
167
168 let sample_value: Decimal = self.items.iter().map(|i| i.book_value).sum();
169 if sample_value == Decimal::ZERO {
170 self.projected_misstatement = Some(Decimal::ZERO);
171 return;
172 }
173
174 let projected = match self.population_value {
175 Some(pop_val) => {
176 let rate = self.total_misstatement_found / sample_value;
178 rate * pop_val
179 }
180 None => {
181 let pop_count = Decimal::from(self.population_size);
183 let samp_count = Decimal::from(self.items.len() as u64);
184 if samp_count == Decimal::ZERO {
185 Decimal::ZERO
186 } else {
187 let rate = self.total_misstatement_found / sample_value;
188 let avg_book = sample_value / samp_count;
190 rate * avg_book * pop_count
191 }
192 }
193 };
194
195 self.projected_misstatement = Some(projected);
196 self.updated_at = Utc::now();
197 }
198
199 pub fn conclude(&mut self) {
201 self.compute_projected_misstatement();
202 let projected = self.projected_misstatement.unwrap_or(Decimal::ZERO);
203
204 self.conclusion = Some(match self.tolerable_misstatement {
205 Some(tolerable) => {
206 if projected <= tolerable {
207 SampleConclusion::ProjectedBelowTolerable
208 } else {
209 SampleConclusion::ProjectedExceedsTolerable
210 }
211 }
212 None => SampleConclusion::InsufficientEvidence,
213 });
214 self.updated_at = Utc::now();
215 }
216}
217
218#[cfg(test)]
219#[allow(clippy::unwrap_used)]
220mod tests {
221 use super::*;
222 use rust_decimal_macros::dec;
223
224 fn make_sample() -> AuditSample {
225 AuditSample::new(
226 Uuid::new_v4(),
227 Uuid::new_v4(),
228 "Accounts receivable invoices over $1,000",
229 500,
230 SamplingMethod::MonetaryUnit,
231 50,
232 )
233 }
234
235 #[test]
236 fn test_new_sample() {
237 let s = make_sample();
238 assert_eq!(s.sample_size, 50);
239 assert_eq!(s.population_size, 500);
240 assert_eq!(s.confidence_level, 0.95);
241 assert_eq!(s.total_misstatement_found, Decimal::ZERO);
242 assert!(s.conclusion.is_none());
243 assert!(s.sample_ref.starts_with("SAMP-"));
244 }
245
246 #[test]
247 fn test_add_item_accumulates_misstatement() {
248 let mut s = make_sample();
249
250 let mut item1 = SampleItem::new("INV-001", dec!(1000));
251 item1.misstatement = Some(dec!(50));
252 item1.result = SampleItemResult::Misstatement;
253
254 let mut item2 = SampleItem::new("INV-002", dec!(2000));
255 item2.misstatement = Some(dec!(-30)); item2.result = SampleItemResult::Misstatement;
257
258 s.add_item(item1);
259 s.add_item(item2);
260
261 assert_eq!(s.total_misstatement_found, dec!(80)); assert_eq!(s.items.len(), 2);
263 }
264
265 #[test]
266 fn test_compute_projected_zero_items() {
267 let mut s = make_sample();
268 s.compute_projected_misstatement();
269 assert_eq!(s.projected_misstatement, Some(Decimal::ZERO));
270 }
271
272 #[test]
273 fn test_compute_projected_zero_sample_value() {
274 let mut s = make_sample();
275 s.add_item(SampleItem::new("INV-000", dec!(0)));
277 s.compute_projected_misstatement();
278 assert_eq!(s.projected_misstatement, Some(Decimal::ZERO));
279 }
280
281 #[test]
282 fn test_compute_projected_normal() {
283 let mut s = make_sample();
284 s.population_value = Some(dec!(100_000));
285
286 let mut item = SampleItem::new("INV-001", dec!(5_000));
287 item.misstatement = Some(dec!(500));
288 s.add_item(item);
289
290 s.compute_projected_misstatement();
291 assert_eq!(s.projected_misstatement, Some(dec!(10_000)));
293 }
294
295 #[test]
296 fn test_conclude_below_tolerable() {
297 let mut s = make_sample();
298 s.population_value = Some(dec!(100_000));
299 s.tolerable_misstatement = Some(dec!(15_000));
300
301 let mut item = SampleItem::new("INV-001", dec!(5_000));
302 item.misstatement = Some(dec!(500)); s.add_item(item);
304
305 s.conclude();
306 assert_eq!(
307 s.conclusion,
308 Some(SampleConclusion::ProjectedBelowTolerable)
309 );
310 }
311
312 #[test]
313 fn test_conclude_exceeds_tolerable() {
314 let mut s = make_sample();
315 s.population_value = Some(dec!(100_000));
316 s.tolerable_misstatement = Some(dec!(5_000));
317
318 let mut item = SampleItem::new("INV-001", dec!(5_000));
319 item.misstatement = Some(dec!(500)); s.add_item(item);
321
322 s.conclude();
323 assert_eq!(
324 s.conclusion,
325 Some(SampleConclusion::ProjectedExceedsTolerable)
326 );
327 }
328
329 #[test]
330 fn test_conclude_no_tolerable() {
331 let mut s = make_sample();
332 s.conclude();
334 assert_eq!(s.conclusion, Some(SampleConclusion::InsufficientEvidence));
335 }
336
337 #[test]
338 fn test_sampling_method_serde() {
339 let methods = [
340 SamplingMethod::StatisticalRandom,
341 SamplingMethod::MonetaryUnit,
342 SamplingMethod::Judgmental,
343 SamplingMethod::Haphazard,
344 SamplingMethod::Block,
345 SamplingMethod::AllItems,
346 ];
347 for m in &methods {
348 let json = serde_json::to_string(m).unwrap();
349 let back: SamplingMethod = serde_json::from_str(&json).unwrap();
350 assert_eq!(back, *m);
351 }
352 }
353
354 #[test]
355 fn test_sample_conclusion_serde() {
356 let conclusions = [
357 SampleConclusion::ProjectedBelowTolerable,
358 SampleConclusion::ProjectedExceedsTolerable,
359 SampleConclusion::InsufficientEvidence,
360 ];
361 for c in &conclusions {
362 let json = serde_json::to_string(c).unwrap();
363 let back: SampleConclusion = serde_json::from_str(&json).unwrap();
364 assert_eq!(back, *c);
365 }
366 }
367}