Skip to main content

datasynth_core/models/audit/
sampling_plan.rs

1//! Audit sampling methodology models per ISA 530.
2//!
3//! ISA 530 (Audit Sampling) requires the auditor to design and perform audit
4//! procedures to obtain sufficient appropriate audit evidence.  The sampling
5//! plan documents the population, key items, methodology, and sample drawn.
6//!
7//! Key concepts:
8//! - **Key items**: 100% tested; amounts ≥ tolerable error, unusual or high-risk items.
9//! - **Representative sample**: drawn from the residual population using a statistical
10//!   or non-statistical method proportional to the CRA level.
11//! - **Monetary Unit Sampling (MUS)**: preferred for balance testing (existence,
12//!   valuation) — each monetary unit has an equal probability of selection.
13//! - **Systematic selection**: preferred for transaction testing — fixed interval
14//!   with random start.
15//!
16//! References:
17//! - ISA 530 — Audit Sampling
18//! - ISA 315 — links CRA level to sampling extent
19//! - ISA 320 / ISA 450 — tolerable error equals performance materiality
20
21use rust_decimal::Decimal;
22use serde::{Deserialize, Serialize};
23
24// ---------------------------------------------------------------------------
25// Sampling methodology
26// ---------------------------------------------------------------------------
27
28/// Sampling methodology chosen for the plan per ISA 530.A5–A8.
29#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
30#[serde(rename_all = "snake_case")]
31pub enum SamplingMethodology {
32    /// Monetary Unit Sampling (MUS) — each currency unit has equal selection probability.
33    /// Preferred for balance testing (existence, valuation assertions).
34    MonetaryUnitSampling,
35    /// Simple random selection — each item has an equal probability of selection.
36    RandomSelection,
37    /// Systematic selection — fixed interval with a random start point.
38    /// Preferred for transaction testing (occurrence, completeness assertions).
39    SystematicSelection,
40    /// Haphazard (non-statistical) selection — for low-risk areas where a formal
41    /// statistical inference is not required (ISA 530.A8).
42    HaphazardSelection,
43}
44
45impl std::fmt::Display for SamplingMethodology {
46    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
47        let s = match self {
48            Self::MonetaryUnitSampling => "Monetary Unit Sampling (MUS)",
49            Self::RandomSelection => "Random Selection",
50            Self::SystematicSelection => "Systematic Selection",
51            Self::HaphazardSelection => "Haphazard Selection",
52        };
53        write!(f, "{s}")
54    }
55}
56
57// ---------------------------------------------------------------------------
58// Key items
59// ---------------------------------------------------------------------------
60
61/// Reason why an item was designated as a key item (100% tested).
62#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
63#[serde(rename_all = "snake_case")]
64pub enum KeyItemReason {
65    /// Amount ≥ tolerable error — automatically selected per ISA 530.A14.
66    AboveTolerableError,
67    /// Unusual nature — related party, unusual counterparty, or non-routine transaction.
68    UnusualNature,
69    /// High-risk item originating from a significant risk area per ISA 315.28.
70    HighRisk,
71    /// Manual journal entry to an automated account — management override indicator.
72    ManagementOverride,
73}
74
75impl std::fmt::Display for KeyItemReason {
76    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
77        let s = match self {
78            Self::AboveTolerableError => "Amount above tolerable error",
79            Self::UnusualNature => "Unusual nature (related party / unusual counterparty)",
80            Self::HighRisk => "High-risk area (significant risk per ISA 315.28)",
81            Self::ManagementOverride => "Management override — manual JE to automated account",
82        };
83        write!(f, "{s}")
84    }
85}
86
87/// A key item selected for 100% testing outside the representative sample.
88#[derive(Debug, Clone, Serialize, Deserialize)]
89pub struct KeyItem {
90    /// Reference ID — JE document_id, subledger record ID, etc.
91    pub item_id: String,
92    /// Monetary amount of the item.
93    #[serde(with = "crate::serde_decimal")]
94    pub amount: Decimal,
95    /// Reason this item was designated as a key item.
96    pub reason: KeyItemReason,
97}
98
99// ---------------------------------------------------------------------------
100// Sampled items
101// ---------------------------------------------------------------------------
102
103/// Whether an item was selected as a key item or a representative sample item.
104#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
105#[serde(rename_all = "snake_case")]
106pub enum SelectionType {
107    /// 100%-tested key item (above TE, unusual, high-risk, or management override).
108    KeyItem,
109    /// Representative sample item drawn from the residual population.
110    Representative,
111}
112
113/// A single item selected into the audit sample (key item or representative).
114#[derive(Debug, Clone, Serialize, Deserialize)]
115pub struct SampledItem {
116    /// Reference ID — links to JE document_id, subledger record, etc.
117    pub item_id: String,
118    /// FK → `SamplingPlan.id` — the plan this item belongs to.
119    pub sampling_plan_id: String,
120    /// Monetary amount of the item.
121    #[serde(with = "crate::serde_decimal")]
122    pub amount: Decimal,
123    /// How the item was selected into the sample.
124    pub selection_type: SelectionType,
125    /// Why this item was selected as a key item — populated only when
126    /// `selection_type == SelectionType::KeyItem`, so SDK consumers don't
127    /// need to cross-reference `SamplingPlan.key_items[]` to find out why.
128    /// `None` for representative-sample rows.
129    #[serde(default, skip_serializing_if = "Option::is_none")]
130    pub key_item_reason: Option<KeyItemReason>,
131    /// Whether the auditor has completed testing on this item.
132    pub tested: bool,
133    /// Whether a misstatement was found during testing.
134    pub misstatement_found: bool,
135    /// Monetary amount of any misstatement identified (None if none found).
136    #[serde(default, skip_serializing_if = "Option::is_none")]
137    pub misstatement_amount: Option<Decimal>,
138}
139
140// ---------------------------------------------------------------------------
141// Main sampling plan
142// ---------------------------------------------------------------------------
143
144/// Audit sampling plan for a single account area / assertion combination.
145///
146/// One plan is generated for each CRA at Moderate or High level, documenting
147/// the full ISA 530-compliant sampling design and execution.
148#[derive(Debug, Clone, Serialize, Deserialize)]
149pub struct SamplingPlan {
150    /// Unique identifier for this sampling plan (deterministic slug).
151    pub id: String,
152    /// Entity / company code.
153    pub entity_code: String,
154    /// Account area being tested (e.g. "Trade Receivables", "Revenue").
155    pub account_area: String,
156    /// Financial statement assertion being tested (e.g. "Existence").
157    pub assertion: String,
158    /// Sampling methodology chosen for this plan.
159    pub methodology: SamplingMethodology,
160    /// Total number of items in the population before key item extraction.
161    pub population_size: usize,
162    /// Total monetary value of the population.
163    #[serde(with = "crate::serde_decimal")]
164    pub population_value: Decimal,
165    /// Key items identified and extracted for 100% testing.
166    pub key_items: Vec<KeyItem>,
167    /// Total monetary value of all key items.
168    #[serde(with = "crate::serde_decimal")]
169    pub key_items_value: Decimal,
170    /// Monetary value of the residual population (population_value − key_items_value).
171    #[serde(with = "crate::serde_decimal")]
172    pub remaining_population_value: Decimal,
173    /// Number of representative sample items drawn from the residual population.
174    pub sample_size: usize,
175    /// Sampling interval = remaining_population_value / sample_size (for MUS / systematic).
176    #[serde(with = "crate::serde_decimal")]
177    pub sampling_interval: Decimal,
178    /// CRA level that drove this plan (links to `CombinedRiskAssessment.combined_risk`).
179    pub cra_level: String,
180    /// Tolerable error for this population (equals performance materiality from ISA 320).
181    #[serde(with = "crate::serde_decimal")]
182    pub tolerable_error: Decimal,
183}
184
185// ---------------------------------------------------------------------------
186// Tests
187// ---------------------------------------------------------------------------
188
189#[cfg(test)]
190#[allow(clippy::unwrap_used)]
191mod tests {
192    use super::*;
193    use rust_decimal_macros::dec;
194
195    #[test]
196    fn key_item_reason_display() {
197        assert_eq!(
198            KeyItemReason::AboveTolerableError.to_string(),
199            "Amount above tolerable error"
200        );
201    }
202
203    #[test]
204    fn sampling_methodology_display() {
205        assert_eq!(
206            SamplingMethodology::MonetaryUnitSampling.to_string(),
207            "Monetary Unit Sampling (MUS)"
208        );
209        assert_eq!(
210            SamplingMethodology::SystematicSelection.to_string(),
211            "Systematic Selection"
212        );
213    }
214
215    #[test]
216    fn sampling_plan_structure() {
217        let plan = SamplingPlan {
218            id: "SP-C001-TRADE_RECEIVABLES-Existence".into(),
219            entity_code: "C001".into(),
220            account_area: "Trade Receivables".into(),
221            assertion: "Existence".into(),
222            methodology: SamplingMethodology::MonetaryUnitSampling,
223            population_size: 500,
224            population_value: dec!(1_000_000),
225            key_items: vec![KeyItem {
226                item_id: "JE-001".into(),
227                amount: dec!(50_000),
228                reason: KeyItemReason::AboveTolerableError,
229            }],
230            key_items_value: dec!(50_000),
231            remaining_population_value: dec!(950_000),
232            sample_size: 25,
233            sampling_interval: dec!(38_000),
234            cra_level: "Moderate".into(),
235            tolerable_error: dec!(32_500),
236        };
237
238        assert_eq!(
239            plan.population_value - plan.key_items_value,
240            plan.remaining_population_value
241        );
242        assert_eq!(plan.key_items.len(), 1);
243        assert_eq!(plan.key_items[0].reason, KeyItemReason::AboveTolerableError);
244    }
245}