Skip to main content

datasynth_core/models/audit/
sample.rs

1//! Audit sample models per ISA 530.
2//!
3//! Provides structures for documenting audit sampling — the items selected,
4//! misstatements found, and the projected conclusion against tolerable error.
5
6use chrono::{DateTime, Utc};
7use rust_decimal::Decimal;
8use serde::{Deserialize, Serialize};
9use uuid::Uuid;
10
11use super::SamplingMethod;
12
13/// Result for a single sampled item.
14#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize, Default)]
15#[serde(rename_all = "snake_case")]
16pub enum SampleItemResult {
17    /// No misstatement or deviation — item is correct
18    #[default]
19    Correct,
20    /// A misstatement was identified
21    Misstatement,
22    /// A deviation / exception was noted
23    Exception,
24}
25
26/// Overall conclusion for the audit sample.
27#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize, Default)]
28#[serde(rename_all = "snake_case")]
29pub enum SampleConclusion {
30    /// Projected misstatement is at or below tolerable misstatement
31    ProjectedBelowTolerable,
32    /// Projected misstatement exceeds tolerable misstatement
33    ProjectedExceedsTolerable,
34    /// Insufficient information to reach a conclusion
35    #[default]
36    InsufficientEvidence,
37}
38
39/// A single item selected for testing within an audit sample.
40#[derive(Debug, Clone, Serialize, Deserialize)]
41pub struct SampleItem {
42    /// Unique item ID
43    pub item_id: Uuid,
44    /// Document or transaction reference
45    pub document_ref: String,
46    /// Book value of the item
47    pub book_value: Decimal,
48    /// Audited (corrected) value, if tested
49    pub audited_value: Option<Decimal>,
50    /// Misstatement amount (book minus audited), if any
51    pub misstatement: Option<Decimal>,
52    /// Result for this item
53    pub result: SampleItemResult,
54}
55
56impl SampleItem {
57    /// Create a new sample item with only a document reference and book value.
58    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/// A documented audit sample per ISA 530.
71#[derive(Debug, Clone, Serialize, Deserialize)]
72pub struct AuditSample {
73    /// Unique sample ID
74    pub sample_id: Uuid,
75    /// Sample reference code, e.g. "SAMP-a1b2c3d4"
76    pub sample_ref: String,
77    /// Workpaper this sample is part of
78    pub workpaper_id: Uuid,
79    /// Engagement this sample belongs to
80    pub engagement_id: Uuid,
81    /// Description of the population tested
82    pub population_description: String,
83    /// Total number of items in the population
84    pub population_size: u64,
85    /// Total monetary value of the population (for MUS / projection)
86    pub population_value: Option<Decimal>,
87    /// Sampling methodology used
88    pub sampling_method: SamplingMethod,
89    /// Planned / actual number of items selected
90    pub sample_size: u32,
91    /// Sampling interval (used for systematic / MUS selection)
92    pub sampling_interval: Option<Decimal>,
93    /// Confidence level (e.g. 0.95 for 95 %)
94    pub confidence_level: f64,
95    /// Tolerable misstatement threshold
96    pub tolerable_misstatement: Option<Decimal>,
97    /// Expected misstatement used in sample size determination
98    pub expected_misstatement: Option<Decimal>,
99    /// Individual items tested
100    pub items: Vec<SampleItem>,
101    /// Cumulative misstatement found across all items
102    pub total_misstatement_found: Decimal,
103    /// Projected population misstatement
104    pub projected_misstatement: Option<Decimal>,
105    /// Conclusion reached
106    pub conclusion: Option<SampleConclusion>,
107    /// Creation timestamp
108    pub created_at: DateTime<Utc>,
109    /// Last-modified timestamp
110    pub updated_at: DateTime<Utc>,
111}
112
113impl AuditSample {
114    /// Create a new audit sample.
115    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    /// Add a tested item to the sample and accumulate total misstatement.
149    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    /// Compute projected population misstatement based on sample results.
158    ///
159    /// Formula: `(total_misstatement / sample_value) × population_value`
160    ///
161    /// Falls back to `0` when there are no items or the sample value is zero.
162    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                // (total_misstatement / sample_value) * population_value
177                let rate = self.total_misstatement_found / sample_value;
178                rate * pop_val
179            }
180            None => {
181                // No population value — scale by population count / sample count
182                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                    // Approximate: rate × (pop_count / samp_count) × average_book_value
189                    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    /// Compute projection and reach a conclusion against tolerable misstatement.
200    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)); // negative misstatement — abs is taken
256        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)); // 50 + 30
262        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        // Item with zero book value
276        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        // rate = 500/5000 = 0.1; projected = 0.1 * 100_000 = 10_000
292        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)); // projected = 10_000 < 15_000
303        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)); // projected = 10_000 > 5_000
320        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        // no tolerable_misstatement set
333        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}