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)]
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)); // negative misstatement — abs is taken
257        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)); // 50 + 30
263        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        // Item with zero book value
277        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        // rate = 500/5000 = 0.1; projected = 0.1 * 100_000 = 10_000
293        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)); // projected = 10_000 < 15_000
304        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)); // projected = 10_000 > 5_000
321        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        // no tolerable_misstatement set
334        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}