Skip to main content

datasynth_generators/compliance/
finding_generator.rs

1//! Compliance finding generator.
2//!
3//! Generates compliance findings tied to audit procedures, with
4//! deficiency classification per SOX/ISA and remediation tracking.
5
6use chrono::NaiveDate;
7use rand::Rng;
8use rand_chacha::ChaCha8Rng;
9use rust_decimal::Decimal;
10
11use datasynth_core::models::compliance::{
12    ComplianceAssertion, ComplianceFinding, DeficiencyLevel, FindingSeverity, RemediationStatus,
13    StandardId,
14};
15use datasynth_core::utils::seeded_rng;
16
17use super::procedure_generator::AuditProcedureRecord;
18
19/// Configuration for compliance finding generation.
20#[derive(Debug, Clone)]
21pub struct ComplianceFindingGeneratorConfig {
22    /// Rate of findings per procedure (0.0-1.0)
23    pub finding_rate: f64,
24    /// Rate of material weaknesses among findings
25    pub material_weakness_rate: f64,
26    /// Rate of significant deficiencies among findings
27    pub significant_deficiency_rate: f64,
28    /// Whether to generate remediation plans
29    pub generate_remediation: bool,
30}
31
32impl Default for ComplianceFindingGeneratorConfig {
33    fn default() -> Self {
34        Self {
35            finding_rate: 0.05,
36            material_weakness_rate: 0.02,
37            significant_deficiency_rate: 0.08,
38            generate_remediation: true,
39        }
40    }
41}
42
43/// Finding templates with condition/criteria/cause/effect structure.
44const FINDING_TEMPLATES: &[(&str, &str, &str)] = &[
45    (
46        "Revenue cutoff exception",
47        "Revenue was recognized in the incorrect period due to delayed shipment recording",
48        "Cutoff",
49    ),
50    (
51        "Three-way match failure",
52        "Purchase order, goods receipt, and invoice amounts did not agree within tolerance",
53        "Accuracy",
54    ),
55    (
56        "Segregation of duties violation",
57        "Same user created and approved the transaction, violating SoD policy",
58        "Occurrence",
59    ),
60    (
61        "Inadequate journal entry review",
62        "Manual journal entries were posted without required supervisory approval",
63        "Occurrence",
64    ),
65    (
66        "Inventory valuation discrepancy",
67        "Physical inventory count differed from book records by more than tolerable threshold",
68        "ValuationAndAllocation",
69    ),
70    (
71        "Fixed asset existence",
72        "Selected fixed assets could not be physically verified during inspection",
73        "Existence",
74    ),
75    (
76        "Related party disclosure gap",
77        "Related party transactions were not fully disclosed in the financial statements",
78        "CompletenessDisclosure",
79    ),
80    (
81        "Lease classification error",
82        "Operating lease incorrectly classified as finance lease under ASC 842/IFRS 16",
83        "Classification",
84    ),
85    (
86        "Revenue recognition timing",
87        "Performance obligation satisfied over time incorrectly recognized at point in time",
88        "Accuracy",
89    ),
90    (
91        "Bank reconciliation delay",
92        "Bank reconciliations not completed within 5 business days of month-end",
93        "Timeliness",
94    ),
95];
96
97/// Generator for compliance findings.
98pub struct ComplianceFindingGenerator {
99    rng: ChaCha8Rng,
100    config: ComplianceFindingGeneratorConfig,
101    counter: u32,
102}
103
104impl ComplianceFindingGenerator {
105    /// Creates a new generator.
106    pub fn new(seed: u64) -> Self {
107        Self {
108            rng: seeded_rng(seed, 0),
109            config: ComplianceFindingGeneratorConfig::default(),
110            counter: 0,
111        }
112    }
113
114    /// Creates a generator with custom configuration.
115    pub fn with_config(seed: u64, config: ComplianceFindingGeneratorConfig) -> Self {
116        Self {
117            rng: seeded_rng(seed, 0),
118            config,
119            counter: 0,
120        }
121    }
122
123    /// Generates findings for a set of audit procedures.
124    pub fn generate_findings(
125        &mut self,
126        procedures: &[AuditProcedureRecord],
127        company_code: &str,
128        reference_date: NaiveDate,
129    ) -> Vec<ComplianceFinding> {
130        let mut findings = Vec::new();
131
132        for procedure in procedures {
133            if self.rng.random::<f64>() > self.config.finding_rate {
134                continue;
135            }
136
137            self.counter += 1;
138            let template_idx = self.counter as usize % FINDING_TEMPLATES.len();
139            let (title, description, assertion_str) = FINDING_TEMPLATES[template_idx];
140
141            let deficiency_level = self.determine_deficiency_level();
142            let severity = match deficiency_level {
143                DeficiencyLevel::MaterialWeakness => FindingSeverity::High,
144                DeficiencyLevel::SignificantDeficiency => FindingSeverity::Moderate,
145                DeficiencyLevel::ControlDeficiency => FindingSeverity::Low,
146            };
147
148            let assertion = match assertion_str {
149                "Occurrence" => ComplianceAssertion::Occurrence,
150                "Completeness" => ComplianceAssertion::Completeness,
151                "Accuracy" => ComplianceAssertion::Accuracy,
152                "Cutoff" => ComplianceAssertion::Cutoff,
153                "Classification" => ComplianceAssertion::Classification,
154                "Existence" => ComplianceAssertion::Existence,
155                "ValuationAndAllocation" => ComplianceAssertion::ValuationAndAllocation,
156                "CompletenessDisclosure" => ComplianceAssertion::CompletenessDisclosure,
157                "Timeliness" => ComplianceAssertion::Timeliness,
158                _ => ComplianceAssertion::Occurrence,
159            };
160
161            let standard_id = StandardId::parse(&procedure.standard_id);
162
163            let financial_impact = if matches!(
164                deficiency_level,
165                DeficiencyLevel::MaterialWeakness | DeficiencyLevel::SignificantDeficiency
166            ) {
167                let amount = self.rng.random_range(5_000i64..500_000i64);
168                Some(Decimal::from(amount))
169            } else {
170                None
171            };
172
173            let remediation_status = if self.config.generate_remediation {
174                let r: f64 = self.rng.random();
175                if r < 0.3 {
176                    RemediationStatus::Remediated
177                } else if r < 0.7 {
178                    RemediationStatus::InProgress
179                } else {
180                    RemediationStatus::Open
181                }
182            } else {
183                RemediationStatus::Open
184            };
185
186            let is_repeat = self.rng.random::<f64>() < 0.15;
187
188            let mut finding = ComplianceFinding::new(
189                company_code,
190                title,
191                severity,
192                deficiency_level,
193                reference_date,
194            )
195            .with_description(description)
196            .identified_by(&procedure.procedure_id)
197            .with_assertion(assertion)
198            .with_standard(standard_id)
199            .with_remediation(remediation_status);
200
201            if is_repeat {
202                finding = finding.as_repeat();
203            }
204
205            if let Some(impact) = financial_impact {
206                finding.financial_impact = Some(impact);
207            }
208
209            findings.push(finding);
210        }
211
212        findings
213    }
214
215    fn determine_deficiency_level(&mut self) -> DeficiencyLevel {
216        let r: f64 = self.rng.random();
217        if r < self.config.material_weakness_rate {
218            DeficiencyLevel::MaterialWeakness
219        } else if r < self.config.material_weakness_rate + self.config.significant_deficiency_rate {
220            DeficiencyLevel::SignificantDeficiency
221        } else {
222            DeficiencyLevel::ControlDeficiency
223        }
224    }
225}
226
227#[cfg(test)]
228#[allow(clippy::unwrap_used)]
229mod tests {
230    use super::*;
231    use crate::compliance::ProcedureGenerator;
232    use datasynth_standards::registry::StandardRegistry;
233
234    #[test]
235    fn test_generate_findings() {
236        let registry = StandardRegistry::with_built_in();
237        let date = NaiveDate::from_ymd_opt(2025, 6, 30).unwrap();
238
239        let mut proc_gen = ProcedureGenerator::new(42);
240        let procedures = proc_gen.generate_procedures(&registry, "US", date);
241
242        // Use a high finding rate for testing
243        let config = ComplianceFindingGeneratorConfig {
244            finding_rate: 1.0, // 100% for test
245            ..Default::default()
246        };
247        let mut finding_gen = ComplianceFindingGenerator::with_config(42, config);
248        let findings = finding_gen.generate_findings(&procedures, "C001", date);
249
250        assert!(!findings.is_empty(), "Should generate findings");
251        for f in &findings {
252            assert_eq!(f.company_code, "C001");
253            assert!(!f.related_standards.is_empty());
254        }
255    }
256}