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    #[serde(with = "crate::serde_timestamp::utc")]
109    pub created_at: DateTime<Utc>,
110    /// Last-modified timestamp
111    #[serde(with = "crate::serde_timestamp::utc")]
112    pub updated_at: DateTime<Utc>,
113}
114
115impl AuditSample {
116    /// Create a new audit sample.
117    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    /// Add a tested item to the sample and accumulate total misstatement.
151    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    /// Compute projected population misstatement based on sample results.
160    ///
161    /// Formula: `(total_misstatement / sample_value) × population_value`
162    ///
163    /// Falls back to `0` when there are no items or the sample value is zero.
164    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                // (total_misstatement / sample_value) * population_value
179                let rate = self.total_misstatement_found / sample_value;
180                rate * pop_val
181            }
182            None => {
183                // No population value — scale by population count / sample count
184                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                    // Approximate: rate × (pop_count / samp_count) × average_book_value
191                    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    /// Compute projection and reach a conclusion against tolerable misstatement.
202    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)); // negative misstatement — abs is taken
258        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)); // 50 + 30
264        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        // Item with zero book value
278        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        // rate = 500/5000 = 0.1; projected = 0.1 * 100_000 = 10_000
294        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)); // projected = 10_000 < 15_000
305        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)); // projected = 10_000 > 5_000
322        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        // no tolerable_misstatement set
335        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}