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)]
221mod tests {
222 use super::*;
223 use rust_decimal_macros::dec;
224
225 fn make_sample() -> AuditSample {
226 AuditSample::new(
227 Uuid::new_v4(),
228 Uuid::new_v4(),
229 "Accounts receivable invoices over $1,000",
230 500,
231 SamplingMethod::MonetaryUnit,
232 50,
233 )
234 }
235
236 #[test]
237 fn test_new_sample() {
238 let s = make_sample();
239 assert_eq!(s.sample_size, 50);
240 assert_eq!(s.population_size, 500);
241 assert_eq!(s.confidence_level, 0.95);
242 assert_eq!(s.total_misstatement_found, Decimal::ZERO);
243 assert!(s.conclusion.is_none());
244 assert!(s.sample_ref.starts_with("SAMP-"));
245 }
246
247 #[test]
248 fn test_add_item_accumulates_misstatement() {
249 let mut s = make_sample();
250
251 let mut item1 = SampleItem::new("INV-001", dec!(1000));
252 item1.misstatement = Some(dec!(50));
253 item1.result = SampleItemResult::Misstatement;
254
255 let mut item2 = SampleItem::new("INV-002", dec!(2000));
256 item2.misstatement = Some(dec!(-30)); item2.result = SampleItemResult::Misstatement;
258
259 s.add_item(item1);
260 s.add_item(item2);
261
262 assert_eq!(s.total_misstatement_found, dec!(80)); assert_eq!(s.items.len(), 2);
264 }
265
266 #[test]
267 fn test_compute_projected_zero_items() {
268 let mut s = make_sample();
269 s.compute_projected_misstatement();
270 assert_eq!(s.projected_misstatement, Some(Decimal::ZERO));
271 }
272
273 #[test]
274 fn test_compute_projected_zero_sample_value() {
275 let mut s = make_sample();
276 s.add_item(SampleItem::new("INV-000", dec!(0)));
278 s.compute_projected_misstatement();
279 assert_eq!(s.projected_misstatement, Some(Decimal::ZERO));
280 }
281
282 #[test]
283 fn test_compute_projected_normal() {
284 let mut s = make_sample();
285 s.population_value = Some(dec!(100_000));
286
287 let mut item = SampleItem::new("INV-001", dec!(5_000));
288 item.misstatement = Some(dec!(500));
289 s.add_item(item);
290
291 s.compute_projected_misstatement();
292 assert_eq!(s.projected_misstatement, Some(dec!(10_000)));
294 }
295
296 #[test]
297 fn test_conclude_below_tolerable() {
298 let mut s = make_sample();
299 s.population_value = Some(dec!(100_000));
300 s.tolerable_misstatement = Some(dec!(15_000));
301
302 let mut item = SampleItem::new("INV-001", dec!(5_000));
303 item.misstatement = Some(dec!(500)); s.add_item(item);
305
306 s.conclude();
307 assert_eq!(
308 s.conclusion,
309 Some(SampleConclusion::ProjectedBelowTolerable)
310 );
311 }
312
313 #[test]
314 fn test_conclude_exceeds_tolerable() {
315 let mut s = make_sample();
316 s.population_value = Some(dec!(100_000));
317 s.tolerable_misstatement = Some(dec!(5_000));
318
319 let mut item = SampleItem::new("INV-001", dec!(5_000));
320 item.misstatement = Some(dec!(500)); s.add_item(item);
322
323 s.conclude();
324 assert_eq!(
325 s.conclusion,
326 Some(SampleConclusion::ProjectedExceedsTolerable)
327 );
328 }
329
330 #[test]
331 fn test_conclude_no_tolerable() {
332 let mut s = make_sample();
333 s.conclude();
335 assert_eq!(s.conclusion, Some(SampleConclusion::InsufficientEvidence));
336 }
337
338 #[test]
339 fn test_sampling_method_serde() {
340 let methods = [
341 SamplingMethod::StatisticalRandom,
342 SamplingMethod::MonetaryUnit,
343 SamplingMethod::Judgmental,
344 SamplingMethod::Haphazard,
345 SamplingMethod::Block,
346 SamplingMethod::AllItems,
347 ];
348 for m in &methods {
349 let json = serde_json::to_string(m).unwrap();
350 let back: SamplingMethod = serde_json::from_str(&json).unwrap();
351 assert_eq!(back, *m);
352 }
353 }
354
355 #[test]
356 fn test_sample_conclusion_serde() {
357 let conclusions = [
358 SampleConclusion::ProjectedBelowTolerable,
359 SampleConclusion::ProjectedExceedsTolerable,
360 SampleConclusion::InsufficientEvidence,
361 ];
362 for c in &conclusions {
363 let json = serde_json::to_string(c).unwrap();
364 let back: SampleConclusion = serde_json::from_str(&json).unwrap();
365 assert_eq!(back, *c);
366 }
367 }
368}