datasynth_generators/audit/
sox_generator.rs1use 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#[derive(Debug, Clone)]
37pub struct SoxGeneratorInput {
38 pub company_code: String,
40 pub company_name: String,
42 pub fiscal_year: u16,
44 pub period_end: NaiveDate,
46 pub findings: Vec<AuditFinding>,
48 pub ceo_name: String,
50 pub cfo_name: String,
52 pub materiality_threshold: Decimal,
54 pub revenue_percent: Decimal,
56 pub assets_percent: Decimal,
58 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
86pub struct SoxGenerator {
92 rng: ChaCha8Rng,
93}
94
95impl SoxGenerator {
96 pub fn new(seed: u64) -> Self {
98 Self {
99 rng: seeded_rng(seed, 0x302_404),
100 }
101 }
102
103 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 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 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 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 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 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 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 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 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); assessment.key_controls_tested = key_controls;
296 assessment.key_controls_effective = effective.min(key_controls);
297
298 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 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
351fn 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#[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 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]; let (_, assessment) = gen.generate(&input);
467 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}