Skip to main content

datasynth_generators/audit/
sox_generator.rs

1//! SOX 302 / 404 Assessment Generator.
2//!
3//! Produces:
4//! - [`Sox302Certification`] — CEO and CFO certifications (SOX Section 302).
5//!   Generated for every US-listed entity per fiscal year.
6//! - [`Sox404Assessment`] — Management's assessment of ICFR (SOX Section 404).
7//!   Effectiveness is determined from the audit findings already generated:
8//!   any open material weakness results in an "ineffective" conclusion.
9//!
10//! # Usage
11//! ```ignore
12//! use datasynth_generators::audit::sox_generator::{SoxGenerator, SoxGeneratorInput};
13//!
14//! let mut gen = SoxGenerator::new(42);
15//! let (certs, assessment) = gen.generate(&input);
16//! ```
17
18use chrono::NaiveDate;
19use datasynth_core::models::audit::{AuditFinding, FindingStatus, FindingType};
20use datasynth_core::utils::seeded_rng;
21use datasynth_standards::regulatory::sox::{
22    CertifierRole, ControlDeficiency as SoxControlDeficiency, DeficiencyClassificationSummary,
23    MaterialWeakness, RemediationAction, RemediationStatus, ScopeConclusion, ScopedEntity,
24    SignificantDeficiency, Sox302Certification, Sox404Assessment,
25};
26use rand::Rng;
27use rand_chacha::ChaCha8Rng;
28use rust_decimal::Decimal;
29use uuid::Uuid;
30
31// ---------------------------------------------------------------------------
32// Public types
33// ---------------------------------------------------------------------------
34
35/// Input required to generate SOX artefacts for one entity.
36#[derive(Debug, Clone)]
37pub struct SoxGeneratorInput {
38    /// Entity / company code.
39    pub company_code: String,
40    /// Entity name (used in certification text).
41    pub company_name: String,
42    /// Fiscal year being certified/assessed.
43    pub fiscal_year: u16,
44    /// Period end date.
45    pub period_end: NaiveDate,
46    /// All audit findings raised for this entity.
47    pub findings: Vec<AuditFinding>,
48    /// CEO name.
49    pub ceo_name: String,
50    /// CFO name.
51    pub cfo_name: String,
52    /// Materiality threshold used in the audit.
53    pub materiality_threshold: Decimal,
54    /// Percentage of consolidated revenue represented by this entity.
55    pub revenue_percent: Decimal,
56    /// Percentage of consolidated assets represented by this entity.
57    pub assets_percent: Decimal,
58    /// Key account captions in scope.
59    pub significant_accounts: Vec<String>,
60}
61
62impl Default for SoxGeneratorInput {
63    fn default() -> Self {
64        Self {
65            company_code: "C000".into(),
66            company_name: "Example Corp".into(),
67            fiscal_year: 2024,
68            period_end: NaiveDate::from_ymd_opt(2024, 12, 31).unwrap_or_default(),
69            findings: Vec::new(),
70            ceo_name: "John Smith".into(),
71            cfo_name: "Jane Doe".into(),
72            materiality_threshold: Decimal::from(100_000),
73            revenue_percent: Decimal::from(100),
74            assets_percent: Decimal::from(100),
75            significant_accounts: vec![
76                "Revenue".into(),
77                "Accounts Receivable".into(),
78                "Inventory".into(),
79                "Fixed Assets".into(),
80                "Accounts Payable".into(),
81            ],
82        }
83    }
84}
85
86// ---------------------------------------------------------------------------
87// Generator
88// ---------------------------------------------------------------------------
89
90/// Generates SOX Section 302 certifications and Section 404 ICFR assessments.
91pub struct SoxGenerator {
92    rng: ChaCha8Rng,
93}
94
95impl SoxGenerator {
96    /// Create a new generator with the given seed.
97    pub fn new(seed: u64) -> Self {
98        Self {
99            rng: seeded_rng(seed, 0x302_404),
100        }
101    }
102
103    /// Generate SOX 302 certifications (CEO + CFO) and a SOX 404 assessment.
104    ///
105    /// Returns `(certifications, sox404_assessment)`.
106    pub fn generate(
107        &mut self,
108        input: &SoxGeneratorInput,
109    ) -> (Vec<Sox302Certification>, Sox404Assessment) {
110        let certs = self.generate_302_certifications(input);
111        let assessment = self.generate_404_assessment(input);
112        (certs, assessment)
113    }
114
115    /// Generate for a batch of entities.
116    pub fn generate_batch(
117        &mut self,
118        inputs: &[SoxGeneratorInput],
119    ) -> Vec<(Vec<Sox302Certification>, Sox404Assessment)> {
120        inputs.iter().map(|i| self.generate(i)).collect()
121    }
122
123    // -----------------------------------------------------------------------
124    // SOX 302
125    // -----------------------------------------------------------------------
126
127    fn generate_302_certifications(
128        &mut self,
129        input: &SoxGeneratorInput,
130    ) -> Vec<Sox302Certification> {
131        let material_weaknesses: Vec<Uuid> = input
132            .findings
133            .iter()
134            .filter(|f| is_material_weakness_open(f))
135            .map(|f| f.finding_id)
136            .collect();
137
138        let significant_deficiencies: Vec<Uuid> = input
139            .findings
140            .iter()
141            .filter(|f| is_significant_deficiency_open(f))
142            .map(|f| f.finding_id)
143            .collect();
144
145        let controls_effective = material_weaknesses.is_empty();
146        let fraud_disclosed = input.findings.iter().any(|f| {
147            matches!(f.finding_type, FindingType::MaterialMisstatement) && f.report_to_governance
148        });
149
150        // Certification date = period end + ~60 days (typical 10-K filing window).
151        let cert_days: i64 = self.rng.random_range(55i64..=70);
152        let cert_date = input.period_end + chrono::Duration::days(cert_days);
153
154        let roles = [
155            (CertifierRole::Ceo, &input.ceo_name),
156            (CertifierRole::Cfo, &input.cfo_name),
157        ];
158
159        let mut certs = Vec::with_capacity(2);
160
161        for (role, name) in &roles {
162            let mut cert = Sox302Certification::new(
163                &input.company_code,
164                input.fiscal_year,
165                input.period_end,
166                *role,
167                name.as_str(),
168            );
169
170            cert.certification_date = cert_date;
171            cert.disclosure_controls_effective = controls_effective;
172            cert.internal_control_designed_effectively = controls_effective;
173            cert.material_weaknesses = material_weaknesses.clone();
174            cert.significant_deficiencies = significant_deficiencies.clone();
175
176            if fraud_disclosed {
177                cert.fraud_disclosed = true;
178                cert.fraud_description = Some(
179                    "Certain material misstatements requiring restatement were identified \
180                     during the audit and have been disclosed to the audit committee."
181                        .into(),
182                );
183            }
184
185            if !material_weaknesses.is_empty() {
186                cert.no_material_misstatement = false;
187                cert.fairly_presented = false;
188            }
189
190            cert.generate_certification_text();
191            certs.push(cert);
192        }
193
194        certs
195    }
196
197    // -----------------------------------------------------------------------
198    // SOX 404
199    // -----------------------------------------------------------------------
200
201    fn generate_404_assessment(&mut self, input: &SoxGeneratorInput) -> Sox404Assessment {
202        let assessment_days: i64 = self.rng.random_range(55i64..=75);
203        let assessment_date = input.period_end + chrono::Duration::days(assessment_days);
204
205        let mut assessment =
206            Sox404Assessment::new(&input.company_code, input.fiscal_year, assessment_date);
207
208        assessment.materiality_threshold = input.materiality_threshold;
209
210        // Scope — this entity covers 100% of itself.
211        assessment.scope.push(ScopedEntity {
212            entity_code: input.company_code.clone(),
213            entity_name: input.company_name.clone(),
214            revenue_percent: input.revenue_percent,
215            assets_percent: input.assets_percent,
216            scope_conclusion: ScopeConclusion::InScope,
217            significant_accounts: input.significant_accounts.clone(),
218        });
219
220        // Classify findings into deficiency tiers.
221        let mut material_weaknesses: Vec<MaterialWeakness> = Vec::new();
222        let mut significant_deficiencies: Vec<SignificantDeficiency> = Vec::new();
223        let mut control_deficiencies: Vec<SoxControlDeficiency> = Vec::new();
224
225        for finding in &input.findings {
226            match finding.finding_type {
227                FindingType::MaterialWeakness if is_open(finding) => {
228                    let mut mw = MaterialWeakness::new(&finding.title, finding.identified_date);
229                    mw.affected_controls = finding.related_control_ids.clone();
230                    mw.affected_accounts = finding.accounts_affected.clone();
231                    mw.root_cause = finding.cause.clone();
232                    mw.potential_misstatement = finding.monetary_impact;
233                    mw.related_finding_ids = vec![finding.finding_id];
234                    mw.remediated_by_year_end = matches!(
235                        finding.status,
236                        FindingStatus::Closed | FindingStatus::PendingValidation
237                    );
238                    if mw.remediated_by_year_end {
239                        mw.remediation_date = Some(input.period_end);
240                    }
241                    material_weaknesses.push(mw);
242                }
243                FindingType::SignificantDeficiency => {
244                    let mut sd =
245                        SignificantDeficiency::new(&finding.title, finding.identified_date);
246                    sd.affected_controls = finding.related_control_ids.clone();
247                    sd.affected_accounts = finding.accounts_affected.clone();
248                    sd.remediated = matches!(finding.status, FindingStatus::Closed);
249                    significant_deficiencies.push(sd);
250                }
251                FindingType::ControlDeficiency | FindingType::ItDeficiency => {
252                    control_deficiencies.push(SoxControlDeficiency {
253                        deficiency_id: Uuid::now_v7(),
254                        description: finding.title.clone(),
255                        affected_control: finding
256                            .related_control_ids
257                            .first()
258                            .cloned()
259                            .unwrap_or_else(|| "CTRL-UNKNOWN".into()),
260                        identification_date: finding.identified_date,
261                        remediated: matches!(finding.status, FindingStatus::Closed),
262                    });
263                }
264                _ => {}
265            }
266        }
267
268        // Populate deficiency classification summary.
269        let total_deficiencies = (material_weaknesses.len()
270            + significant_deficiencies.len()
271            + control_deficiencies.len()) as u32;
272        let remediated = (material_weaknesses
273            .iter()
274            .filter(|m| m.remediated_by_year_end)
275            .count()
276            + significant_deficiencies
277                .iter()
278                .filter(|s| s.remediated)
279                .count()
280            + control_deficiencies.iter().filter(|c| c.remediated).count())
281            as u32;
282
283        assessment.deficiency_classification = DeficiencyClassificationSummary {
284            deficiencies_identified: total_deficiencies,
285            control_deficiencies: control_deficiencies.len() as u32,
286            significant_deficiencies: significant_deficiencies.len() as u32,
287            material_weaknesses: material_weaknesses.len() as u32,
288            remediated,
289        };
290
291        // Key controls tested (approximate: 15 per significant account, min 30).
292        let key_controls = (input.significant_accounts.len() * 15).max(30);
293        let defective = material_weaknesses.len() + significant_deficiencies.len();
294        let effective = key_controls.saturating_sub(defective * 3); // rough 3-per-finding impact
295        assessment.key_controls_tested = key_controls;
296        assessment.key_controls_effective = effective.min(key_controls);
297
298        // Populate remediation actions for open material weaknesses.
299        for mw in &material_weaknesses {
300            if !mw.remediated_by_year_end {
301                let target = assessment_date + chrono::Duration::days(180);
302                assessment.remediation_actions.push(RemediationAction {
303                    action_id: Uuid::now_v7(),
304                    deficiency_id: mw.weakness_id,
305                    description: format!(
306                        "Implement enhanced controls and monitoring to address: {}",
307                        mw.description
308                    ),
309                    responsible_party: "Controller / VP Finance".into(),
310                    target_date: target,
311                    completion_date: None,
312                    status: RemediationStatus::InProgress,
313                    remediation_tested: false,
314                    remediation_effective: false,
315                });
316            }
317        }
318
319        assessment.material_weaknesses = material_weaknesses;
320        assessment.significant_deficiencies = significant_deficiencies;
321        assessment.control_deficiencies = control_deficiencies;
322
323        // Effectiveness conclusion (driven by material weaknesses).
324        assessment.evaluate_effectiveness();
325
326        assessment.management_conclusion = if assessment.icfr_effective {
327            format!(
328                "Based on our assessment using the COSO 2013 framework, management concludes \
329                 that {} maintained effective internal control over financial reporting as of \
330                 {}. No material weaknesses were identified.",
331                input.company_name, input.period_end
332            )
333        } else {
334            let count = assessment.material_weaknesses.len();
335            format!(
336                "Based on our assessment using the COSO 2013 framework, management concludes \
337                 that {} did not maintain effective internal control over financial reporting as \
338                 of {} due to {} material weakness{}. See Management's Report for details.",
339                input.company_name,
340                input.period_end,
341                count,
342                if count == 1 { "" } else { "es" }
343            )
344        };
345
346        assessment.management_report_date = assessment_date;
347        assessment
348    }
349}
350
351// ---------------------------------------------------------------------------
352// Internal helpers
353// ---------------------------------------------------------------------------
354
355fn is_open(f: &AuditFinding) -> bool {
356    !matches!(
357        f.status,
358        FindingStatus::Closed | FindingStatus::NotApplicable
359    )
360}
361
362fn is_material_weakness_open(f: &AuditFinding) -> bool {
363    matches!(f.finding_type, FindingType::MaterialWeakness) && is_open(f)
364}
365
366fn is_significant_deficiency_open(f: &AuditFinding) -> bool {
367    matches!(f.finding_type, FindingType::SignificantDeficiency) && is_open(f)
368}
369
370// ---------------------------------------------------------------------------
371// Unit tests
372// ---------------------------------------------------------------------------
373
374#[cfg(test)]
375#[allow(clippy::unwrap_used)]
376mod tests {
377    use super::*;
378
379    fn minimal_input() -> SoxGeneratorInput {
380        SoxGeneratorInput::default()
381    }
382
383    #[test]
384    fn test_certifications_produced_for_ceo_and_cfo() {
385        let mut gen = SoxGenerator::new(42);
386        let (certs, _) = gen.generate(&minimal_input());
387        assert_eq!(certs.len(), 2);
388        let roles: Vec<CertifierRole> = certs.iter().map(|c| c.certifier_role).collect();
389        assert!(roles.contains(&CertifierRole::Ceo));
390        assert!(roles.contains(&CertifierRole::Cfo));
391    }
392
393    #[test]
394    fn test_effective_when_no_material_weaknesses() {
395        let mut gen = SoxGenerator::new(42);
396        let (certs, assessment) = gen.generate(&minimal_input());
397        assert!(assessment.icfr_effective);
398        assert!(certs.iter().all(|c| c.disclosure_controls_effective));
399    }
400
401    #[test]
402    fn test_ineffective_when_material_weakness_present() {
403        use datasynth_core::models::audit::{AuditFinding, FindingType};
404
405        let mut gen = SoxGenerator::new(42);
406        let mut input = minimal_input();
407
408        let eng_id = Uuid::new_v4();
409        let finding = AuditFinding::new(eng_id, FindingType::MaterialWeakness, "SoD gap");
410        input.findings = vec![finding];
411
412        let (certs, assessment) = gen.generate(&input);
413        assert!(!assessment.icfr_effective);
414        assert!(!assessment.material_weaknesses.is_empty());
415        assert!(!certs[0].disclosure_controls_effective);
416    }
417
418    #[test]
419    fn test_assessment_conclusion_text_matches_effectiveness() {
420        let mut gen = SoxGenerator::new(42);
421        let (_, assessment) = gen.generate(&minimal_input());
422        assert!(assessment.management_conclusion.contains("effective"));
423    }
424
425    #[test]
426    fn test_significant_deficiency_does_not_make_ineffective() {
427        use datasynth_core::models::audit::{AuditFinding, FindingType};
428
429        let mut gen = SoxGenerator::new(42);
430        let mut input = minimal_input();
431
432        let eng_id = Uuid::new_v4();
433        let finding = AuditFinding::new(
434            eng_id,
435            FindingType::SignificantDeficiency,
436            "Reconciliation gap",
437        );
438        input.findings = vec![finding];
439
440        let (_, assessment) = gen.generate(&input);
441        // A significant deficiency alone does NOT make ICFR ineffective.
442        assert!(assessment.icfr_effective);
443        assert!(!assessment.significant_deficiencies.is_empty());
444    }
445
446    #[test]
447    fn test_certifications_have_non_empty_text() {
448        let mut gen = SoxGenerator::new(42);
449        let (certs, _) = gen.generate(&minimal_input());
450        for cert in &certs {
451            assert!(!cert.certification_text.is_empty());
452        }
453    }
454
455    #[test]
456    fn test_remediation_action_generated_for_open_mw() {
457        use datasynth_core::models::audit::{AuditFinding, FindingType};
458
459        let mut gen = SoxGenerator::new(42);
460        let mut input = minimal_input();
461
462        let eng_id = Uuid::new_v4();
463        let finding = AuditFinding::new(eng_id, FindingType::MaterialWeakness, "GL access");
464        input.findings = vec![finding]; // status = Draft (open)
465
466        let (_, assessment) = gen.generate(&input);
467        // One open material weakness → one remediation action expected.
468        assert!(!assessment.remediation_actions.is_empty());
469    }
470
471    #[test]
472    fn test_batch_generate_returns_correct_count() {
473        let mut gen = SoxGenerator::new(42);
474        let inputs: Vec<SoxGeneratorInput> = (0..3)
475            .map(|i| SoxGeneratorInput {
476                company_code: format!("C{:03}", i),
477                ..SoxGeneratorInput::default()
478            })
479            .collect();
480        let results = gen.generate_batch(&inputs);
481        assert_eq!(results.len(), 3);
482    }
483}