Skip to main content

datasynth_generators/audit/
finding_generator.rs

1//! Finding generator for audit engagements.
2//!
3//! Generates audit findings with condition/criteria/cause/effect structure,
4//! remediation plans, and cross-references per ISA 265.
5
6use chrono::Duration;
7use datasynth_core::utils::seeded_rng;
8use rand::RngExt;
9use rand_chacha::ChaCha8Rng;
10use rust_decimal::Decimal;
11
12use datasynth_core::models::audit::{
13    Assertion, AuditEngagement, AuditFinding, FindingSeverity, FindingStatus, FindingType,
14    MilestoneStatus, RemediationPlan, RemediationStatus, Workpaper,
15};
16
17/// A control available for linkage during finding generation.
18#[derive(Debug, Clone)]
19pub struct AvailableControl {
20    /// Control ID (external reference, e.g., "CTRL-001")
21    pub control_id: String,
22    /// Assertions this control addresses
23    pub assertions: Vec<Assertion>,
24    /// Process areas this control covers
25    pub process_areas: Vec<String>,
26}
27
28/// A risk assessment available for linkage during finding generation.
29#[derive(Debug, Clone)]
30pub struct AvailableRisk {
31    /// Risk ID (external reference, e.g., "RISK-001")
32    pub risk_id: String,
33    /// Engagement ID this risk belongs to
34    pub engagement_id: uuid::Uuid,
35    /// Account or process being assessed
36    pub account_or_process: String,
37}
38
39/// Configuration for finding generation.
40#[derive(Debug, Clone)]
41pub struct FindingGeneratorConfig {
42    /// Number of findings per engagement (min, max)
43    pub findings_per_engagement: (u32, u32),
44    /// Probability of material weakness
45    pub material_weakness_probability: f64,
46    /// Probability of significant deficiency
47    pub significant_deficiency_probability: f64,
48    /// Probability of misstatement finding
49    pub misstatement_probability: f64,
50    /// Probability of remediation plan
51    pub remediation_plan_probability: f64,
52    /// Probability of management agreement
53    pub management_agrees_probability: f64,
54    /// Misstatement amount range (min, max)
55    pub misstatement_range: (i64, i64),
56}
57
58impl Default for FindingGeneratorConfig {
59    fn default() -> Self {
60        Self {
61            findings_per_engagement: (3, 12),
62            material_weakness_probability: 0.05,
63            significant_deficiency_probability: 0.15,
64            misstatement_probability: 0.30,
65            remediation_plan_probability: 0.70,
66            management_agrees_probability: 0.85,
67            misstatement_range: (1_000, 500_000),
68        }
69    }
70}
71
72/// Generator for audit findings.
73pub struct FindingGenerator {
74    rng: ChaCha8Rng,
75    config: FindingGeneratorConfig,
76    finding_counter: u32,
77    fiscal_year: u16,
78    /// Optional template provider for user-supplied finding titles + narratives (v3.2.1+)
79    template_provider: Option<datasynth_core::templates::SharedTemplateProvider>,
80}
81
82impl FindingGenerator {
83    /// Create a new generator with the given seed.
84    pub fn new(seed: u64) -> Self {
85        Self {
86            rng: seeded_rng(seed, 0),
87            config: FindingGeneratorConfig::default(),
88            finding_counter: 0,
89            fiscal_year: 2025,
90            template_provider: None,
91        }
92    }
93
94    /// Create a new generator with custom configuration.
95    pub fn with_config(seed: u64, config: FindingGeneratorConfig) -> Self {
96        Self {
97            rng: seeded_rng(seed, 0),
98            config,
99            finding_counter: 0,
100            fiscal_year: 2025,
101            template_provider: None,
102        }
103    }
104
105    /// Set a template provider so user-supplied finding titles and
106    /// narratives override the embedded tables. (v3.2.1+)
107    ///
108    /// When unset, the existing inline match arms are used — behavior
109    /// is byte-identical to pre-v3.2.1.
110    pub fn set_template_provider(
111        &mut self,
112        provider: datasynth_core::templates::SharedTemplateProvider,
113    ) {
114        self.template_provider = Some(provider);
115    }
116
117    /// Canonical snake_case key for a [`FindingType`] variant, matching
118    /// the template YAML schema (`finding_titles.by_type.<key>`).
119    fn finding_type_to_key(finding_type: FindingType) -> &'static str {
120        match finding_type {
121            FindingType::MaterialWeakness => "material_weakness",
122            FindingType::SignificantDeficiency => "significant_deficiency",
123            FindingType::ControlDeficiency => "control_deficiency",
124            FindingType::MaterialMisstatement => "material_misstatement",
125            FindingType::ImmaterialMisstatement => "immaterial_misstatement",
126            FindingType::ComplianceException => "compliance_exception",
127            FindingType::ItDeficiency => "it_deficiency",
128            FindingType::OtherMatter => "other_matter",
129            FindingType::ProcessImprovement => "process_improvement",
130        }
131    }
132
133    /// Try to get a narrative section from the template provider and
134    /// substitute `{placeholder}` tokens with runtime values. Returns
135    /// `None` if the provider has no template for this key — the caller
136    /// then falls through to the embedded format! macros.
137    ///
138    /// Supported placeholders: `{account}`, `{amount}`.
139    fn try_template_narrative(
140        &mut self,
141        finding_type: FindingType,
142        section: &str,
143        account: &str,
144        amount: Option<i64>,
145    ) -> Option<String> {
146        let provider = self.template_provider.clone()?;
147        let key = Self::finding_type_to_key(finding_type);
148        let tpl = provider.get_finding_narrative(key, section, &mut self.rng)?;
149        let mut out = tpl.replace("{account}", account);
150        if let Some(amt) = amount {
151            out = out.replace("{amount}", &amt.to_string());
152        }
153        Some(out)
154    }
155
156    /// Generate findings for an engagement.
157    pub fn generate_findings_for_engagement(
158        &mut self,
159        engagement: &AuditEngagement,
160        workpapers: &[Workpaper],
161        team_members: &[String],
162    ) -> Vec<AuditFinding> {
163        self.fiscal_year = engagement.fiscal_year;
164
165        let count = self.rng.random_range(
166            self.config.findings_per_engagement.0..=self.config.findings_per_engagement.1,
167        );
168
169        let mut findings = Vec::with_capacity(count as usize);
170
171        for _ in 0..count {
172            let finding = self.generate_finding(engagement, workpapers, team_members);
173            findings.push(finding);
174        }
175
176        findings
177    }
178
179    /// Generate a single finding.
180    pub fn generate_finding(
181        &mut self,
182        engagement: &AuditEngagement,
183        workpapers: &[Workpaper],
184        team_members: &[String],
185    ) -> AuditFinding {
186        self.finding_counter += 1;
187
188        let finding_type = self.select_finding_type();
189        let (title, account) = self.generate_finding_title(finding_type);
190
191        let mut finding = AuditFinding::new(engagement.engagement_id, finding_type, &title);
192
193        finding.finding_ref = format!("FIND-{}-{:03}", self.fiscal_year, self.finding_counter);
194
195        // Generate condition, criteria, cause, effect
196        let (condition, criteria, cause, effect) = self.generate_ccce(finding_type, &account);
197        finding = finding.with_details(&condition, &criteria, &cause, &effect);
198
199        // Generate recommendation
200        let recommendation = self.generate_recommendation(finding_type, &account);
201        finding = finding.with_recommendation(&recommendation);
202
203        // Set severity based on type
204        finding.severity = self.determine_severity(finding_type, &finding);
205
206        // Add monetary impact for misstatements
207        if self.is_misstatement_type(finding_type) {
208            let (factual, projected, judgmental) = self.generate_misstatement_amounts();
209            finding = finding.with_misstatement(factual, projected, judgmental);
210
211            if let Some(f) = factual {
212                finding = finding.with_monetary_impact(f);
213            }
214        }
215
216        // Add assertions and accounts
217        finding.assertions_affected = self.select_assertions(finding_type);
218        finding.accounts_affected = vec![account.clone()];
219        finding.process_areas = self.select_process_areas(&account);
220
221        // Link to workpapers
222        if !workpapers.is_empty() {
223            let wp_count = self.rng.random_range(1..=3.min(workpapers.len()));
224            for _ in 0..wp_count {
225                let idx = self.rng.random_range(0..workpapers.len());
226                finding.workpaper_refs.push(workpapers[idx].workpaper_id);
227            }
228            // Set the primary workpaper_id to the first linked workpaper
229            if let Some(first_wp) = finding.workpaper_refs.first() {
230                finding.workpaper_id = Some(first_wp.to_string());
231            }
232        }
233
234        // Set identified by
235        let identifier = self.select_team_member(team_members, "senior");
236        finding.identified_by = identifier;
237        finding.identified_date =
238            engagement.fieldwork_start + Duration::days(self.rng.random_range(7..30));
239
240        // Maybe add review
241        if self.rng.random::<f64>() < 0.8 {
242            finding.reviewed_by = Some(self.select_team_member(team_members, "manager"));
243            finding.review_date =
244                Some(finding.identified_date + Duration::days(self.rng.random_range(3..10)));
245            finding.status = FindingStatus::PendingReview;
246        }
247
248        // Determine reporting requirements
249        finding.mark_for_reporting(
250            finding.finding_type.requires_sox_reporting() || finding.severity.score() >= 3,
251            finding.requires_governance_communication(),
252        );
253
254        // Maybe add management response
255        if self.rng.random::<f64>() < 0.7 {
256            let response_date =
257                finding.identified_date + Duration::days(self.rng.random_range(7..21));
258            let agrees = self.rng.random::<f64>() < self.config.management_agrees_probability;
259            let response = self.generate_management_response(finding_type, agrees);
260            finding.add_management_response(&response, agrees, response_date);
261
262            // Maybe add remediation plan
263            if agrees && self.rng.random::<f64>() < self.config.remediation_plan_probability {
264                let plan = self.generate_remediation_plan(&finding, &account);
265                finding.with_remediation_plan(plan);
266            }
267        }
268
269        finding
270    }
271
272    /// Generate findings for an engagement with control and risk context.
273    ///
274    /// This extended version populates `related_control_ids` and `related_risk_id`
275    /// by matching finding assertions/process areas against available controls and risks.
276    pub fn generate_findings_with_context(
277        &mut self,
278        engagement: &AuditEngagement,
279        workpapers: &[Workpaper],
280        team_members: &[String],
281        controls: &[AvailableControl],
282        risks: &[AvailableRisk],
283    ) -> Vec<AuditFinding> {
284        let mut findings =
285            self.generate_findings_for_engagement(engagement, workpapers, team_members);
286
287        for finding in &mut findings {
288            self.link_controls_and_risks(finding, controls, risks);
289        }
290
291        findings
292    }
293
294    /// Link a finding to related controls and risks based on assertion/process overlap.
295    fn link_controls_and_risks(
296        &mut self,
297        finding: &mut AuditFinding,
298        controls: &[AvailableControl],
299        risks: &[AvailableRisk],
300    ) {
301        // Link controls: find controls whose assertions overlap with this finding's assertions
302        let finding_assertions = &finding.assertions_affected;
303        let finding_process_areas = &finding.process_areas;
304
305        let mut matched_controls: Vec<&AvailableControl> = controls
306            .iter()
307            .filter(|ctrl| {
308                // Match if any assertion overlaps
309                let assertion_match = ctrl
310                    .assertions
311                    .iter()
312                    .any(|a| finding_assertions.contains(a));
313                // Or if any process area overlaps (case-insensitive)
314                let process_match = ctrl.process_areas.iter().any(|pa| {
315                    finding_process_areas
316                        .iter()
317                        .any(|fp| fp.to_lowercase().contains(&pa.to_lowercase()))
318                });
319                assertion_match || process_match
320            })
321            .collect();
322
323        // If no matches, pick 1-2 random controls (findings should still link to something)
324        if matched_controls.is_empty() && !controls.is_empty() {
325            let count = self.rng.random_range(1..=2.min(controls.len()));
326            for _ in 0..count {
327                let idx = self.rng.random_range(0..controls.len());
328                matched_controls.push(&controls[idx]);
329            }
330        }
331
332        // Cap at 3 related controls
333        if matched_controls.len() > 3 {
334            matched_controls.truncate(3);
335        }
336
337        finding.related_control_ids = matched_controls
338            .iter()
339            .map(|c| c.control_id.clone())
340            .collect();
341
342        // Link risk: find a risk in the same engagement whose account/process matches
343        let engagement_risks: Vec<&AvailableRisk> = risks
344            .iter()
345            .filter(|r| r.engagement_id == finding.engagement_id)
346            .collect();
347
348        if !engagement_risks.is_empty() {
349            // Try to find a risk whose account_or_process matches one of our affected accounts
350            let matching_risk = engagement_risks.iter().find(|r| {
351                finding.accounts_affected.iter().any(|a| {
352                    r.account_or_process
353                        .to_lowercase()
354                        .contains(&a.to_lowercase())
355                })
356            });
357
358            if let Some(risk) = matching_risk {
359                finding.related_risk_id = Some(risk.risk_id.clone());
360            } else {
361                // Fall back to a random risk from the same engagement
362                let idx = self.rng.random_range(0..engagement_risks.len());
363                finding.related_risk_id = Some(engagement_risks[idx].risk_id.clone());
364            }
365        }
366    }
367
368    /// Select finding type based on probabilities.
369    fn select_finding_type(&mut self) -> FindingType {
370        let r: f64 = self.rng.random();
371
372        if r < self.config.material_weakness_probability {
373            FindingType::MaterialWeakness
374        } else if r < self.config.material_weakness_probability
375            + self.config.significant_deficiency_probability
376        {
377            FindingType::SignificantDeficiency
378        } else if r < self.config.material_weakness_probability
379            + self.config.significant_deficiency_probability
380            + self.config.misstatement_probability
381        {
382            if self.rng.random::<f64>() < 0.3 {
383                FindingType::MaterialMisstatement
384            } else {
385                FindingType::ImmaterialMisstatement
386            }
387        } else {
388            let other_types = [
389                FindingType::ControlDeficiency,
390                FindingType::ComplianceException,
391                FindingType::OtherMatter,
392                FindingType::ItDeficiency,
393                FindingType::ProcessImprovement,
394            ];
395            let idx = self.rng.random_range(0..other_types.len());
396            other_types[idx]
397        }
398    }
399
400    /// Generate finding title and related account.
401    fn generate_finding_title(&mut self, finding_type: FindingType) -> (String, String) {
402        // v3.2.1+: prefer user-supplied titles via TemplateProvider.
403        if let Some(ref provider) = self.template_provider {
404            let key = Self::finding_type_to_key(finding_type);
405            if let Some(pair) = provider.get_finding_title(key, &mut self.rng) {
406                return pair;
407            }
408        }
409
410        match finding_type {
411            FindingType::MaterialWeakness => {
412                let titles = [
413                    (
414                        "Inadequate segregation of duties in revenue cycle",
415                        "Revenue",
416                    ),
417                    (
418                        "Lack of effective review of journal entries",
419                        "General Ledger",
420                    ),
421                    (
422                        "Insufficient IT general controls over financial applications",
423                        "IT Controls",
424                    ),
425                    (
426                        "Inadequate controls over financial close process",
427                        "Financial Close",
428                    ),
429                ];
430                let idx = self.rng.random_range(0..titles.len());
431                (titles[idx].0.into(), titles[idx].1.into())
432            }
433            FindingType::SignificantDeficiency => {
434                let titles = [
435                    (
436                        "Inadequate documentation of account reconciliations",
437                        "Accounts Receivable",
438                    ),
439                    (
440                        "Untimely review of vendor master file changes",
441                        "Accounts Payable",
442                    ),
443                    ("Incomplete fixed asset physical inventory", "Fixed Assets"),
444                    (
445                        "Lack of formal approval for manual journal entries",
446                        "General Ledger",
447                    ),
448                ];
449                let idx = self.rng.random_range(0..titles.len());
450                (titles[idx].0.into(), titles[idx].1.into())
451            }
452            FindingType::ControlDeficiency => {
453                let titles = [
454                    (
455                        "Missing secondary approval on expense reports",
456                        "Operating Expenses",
457                    ),
458                    ("Incomplete access review documentation", "IT Controls"),
459                    ("Delayed bank reconciliation preparation", "Cash"),
460                    ("Inconsistent inventory count procedures", "Inventory"),
461                ];
462                let idx = self.rng.random_range(0..titles.len());
463                (titles[idx].0.into(), titles[idx].1.into())
464            }
465            FindingType::MaterialMisstatement | FindingType::ImmaterialMisstatement => {
466                let titles = [
467                    ("Revenue cutoff error", "Revenue"),
468                    ("Inventory valuation adjustment", "Inventory"),
469                    (
470                        "Accounts receivable allowance understatement",
471                        "Accounts Receivable",
472                    ),
473                    ("Accrued liabilities understatement", "Accrued Liabilities"),
474                    ("Fixed asset depreciation calculation error", "Fixed Assets"),
475                ];
476                let idx = self.rng.random_range(0..titles.len());
477                (titles[idx].0.into(), titles[idx].1.into())
478            }
479            FindingType::ComplianceException => {
480                let titles = [
481                    ("Late filing of sales tax returns", "Tax"),
482                    ("Incomplete Form 1099 reporting", "Tax"),
483                    ("Non-compliance with debt covenant reporting", "Debt"),
484                ];
485                let idx = self.rng.random_range(0..titles.len());
486                (titles[idx].0.into(), titles[idx].1.into())
487            }
488            FindingType::ItDeficiency => {
489                let titles = [
490                    ("Excessive user access privileges", "IT Controls"),
491                    ("Inadequate password policy enforcement", "IT Controls"),
492                    ("Missing change management documentation", "IT Controls"),
493                    ("Incomplete disaster recovery testing", "IT Controls"),
494                ];
495                let idx = self.rng.random_range(0..titles.len());
496                (titles[idx].0.into(), titles[idx].1.into())
497            }
498            FindingType::OtherMatter | FindingType::ProcessImprovement => {
499                let titles = [
500                    (
501                        "Opportunity to improve month-end close efficiency",
502                        "Financial Close",
503                    ),
504                    (
505                        "Enhancement to vendor onboarding process",
506                        "Accounts Payable",
507                    ),
508                    (
509                        "Automation opportunity in reconciliation process",
510                        "General Ledger",
511                    ),
512                ];
513                let idx = self.rng.random_range(0..titles.len());
514                (titles[idx].0.into(), titles[idx].1.into())
515            }
516        }
517    }
518
519    /// Generate condition, criteria, cause, effect.
520    fn generate_ccce(
521        &mut self,
522        finding_type: FindingType,
523        account: &str,
524    ) -> (String, String, String, String) {
525        // v3.2.1+: if the user provided narrative templates, use them
526        // for any section they defined; fall back to the embedded
527        // format! macros per-section. All four sections must be
528        // template-provided to skip the embedded path; mixing is
529        // intentional so users can override one section without
530        // authoring all four.
531        let tpl_condition = self.try_template_narrative(finding_type, "condition", account, None);
532        let tpl_criteria = self.try_template_narrative(finding_type, "criteria", account, None);
533        let tpl_cause = self.try_template_narrative(finding_type, "cause", account, None);
534        let tpl_effect = self.try_template_narrative(finding_type, "effect", account, None);
535        if let (Some(c), Some(cr), Some(ca), Some(ef)) =
536            (&tpl_condition, &tpl_criteria, &tpl_cause, &tpl_effect)
537        {
538            return (c.clone(), cr.clone(), ca.clone(), ef.clone());
539        }
540
541        match finding_type {
542            FindingType::MaterialWeakness
543            | FindingType::SignificantDeficiency
544            | FindingType::ControlDeficiency => {
545                let condition = format!(
546                    "During our testing of {} controls, we noted that the control was not operating effectively. \
547                    Specifically, {} of {} items tested did not have evidence of the required control activity.",
548                    account,
549                    self.rng.random_range(2..8),
550                    self.rng.random_range(20..40)
551                );
552                let criteria = format!(
553                    "Company policy and SOX requirements mandate that all {account} transactions receive appropriate \
554                    review and approval prior to processing."
555                );
556                let cause = "Staffing constraints and competing priorities resulted in reduced focus on control execution.".into();
557                let effect = format!(
558                    "Transactions may be processed without appropriate oversight, increasing the risk of errors \
559                    or fraud in the {account} balance."
560                );
561                (condition, criteria, cause, effect)
562            }
563            FindingType::MaterialMisstatement | FindingType::ImmaterialMisstatement => {
564                let amount = self.rng.random_range(
565                    self.config.misstatement_range.0..self.config.misstatement_range.1,
566                );
567                let condition = format!(
568                    "Our testing identified a misstatement in {account} of approximately ${amount}. \
569                    The error resulted from incorrect application of accounting standards."
570                );
571                let criteria = "US GAAP and company accounting policy require accurate recording of all transactions.".into();
572                let cause =
573                    "Manual calculation error combined with inadequate review procedures.".into();
574                let effect = format!(
575                    "The {} balance was {} by ${}, which {}.",
576                    account,
577                    if self.rng.random::<bool>() {
578                        "overstated"
579                    } else {
580                        "understated"
581                    },
582                    amount,
583                    if finding_type == FindingType::MaterialMisstatement {
584                        "represents a material misstatement"
585                    } else {
586                        "is below materiality but has been communicated to management"
587                    }
588                );
589                (condition, criteria, cause, effect)
590            }
591            FindingType::ComplianceException => {
592                let condition = format!(
593                    "The Company did not comply with {account} regulatory requirements during the period under audit."
594                );
595                let criteria =
596                    "Applicable laws and regulations require timely and accurate compliance."
597                        .into();
598                let cause = "Lack of monitoring procedures to track compliance deadlines.".into();
599                let effect =
600                    "The Company may be subject to penalties or regulatory scrutiny.".into();
601                (condition, criteria, cause, effect)
602            }
603            _ => {
604                let condition =
605                    format!("We identified an opportunity to enhance the {account} process.");
606                let criteria =
607                    "Industry best practices suggest continuous improvement in control processes."
608                        .into();
609                let cause =
610                    "Current processes have not been updated to reflect operational changes."
611                        .into();
612                let effect =
613                    "Operational efficiency could be improved with process enhancements.".into();
614                (condition, criteria, cause, effect)
615            }
616        }
617    }
618
619    /// Generate recommendation.
620    fn generate_recommendation(&mut self, finding_type: FindingType, account: &str) -> String {
621        // v3.2.1+: try template provider first.
622        if let Some(rec) =
623            self.try_template_narrative(finding_type, "recommendation", account, None)
624        {
625            return rec;
626        }
627
628        match finding_type {
629            FindingType::MaterialWeakness | FindingType::SignificantDeficiency => {
630                format!(
631                    "We recommend that management: (1) Implement additional review procedures for {account} transactions, \
632                    (2) Document all control activities contemporaneously, and \
633                    (3) Provide additional training to personnel responsible for control execution."
634                )
635            }
636            FindingType::ControlDeficiency => {
637                format!(
638                    "We recommend that management strengthen the {account} control by ensuring timely execution \
639                    and documentation of all required review activities."
640                )
641            }
642            FindingType::MaterialMisstatement | FindingType::ImmaterialMisstatement => {
643                "We recommend that management record the proposed adjusting entry and implement \
644                additional review procedures to prevent similar errors in future periods.".into()
645            }
646            FindingType::ComplianceException => {
647                "We recommend that management implement a compliance calendar with automated reminders \
648                and establish monitoring procedures to ensure timely compliance.".into()
649            }
650            FindingType::ItDeficiency => {
651                "We recommend that IT management review and remediate the identified access control \
652                weaknesses and implement periodic access certification procedures.".into()
653            }
654            _ => {
655                format!(
656                    "We recommend that management evaluate the {account} process for potential \
657                    efficiency improvements and implement changes as appropriate."
658                )
659            }
660        }
661    }
662
663    /// Determine severity based on finding type and other factors.
664    fn determine_severity(
665        &mut self,
666        finding_type: FindingType,
667        _finding: &AuditFinding,
668    ) -> FindingSeverity {
669        let base_severity = finding_type.default_severity();
670
671        // Maybe adjust severity
672        if self.rng.random::<f64>() < 0.2 {
673            match base_severity {
674                FindingSeverity::Critical => FindingSeverity::High,
675                FindingSeverity::High => {
676                    if self.rng.random::<bool>() {
677                        FindingSeverity::Critical
678                    } else {
679                        FindingSeverity::Medium
680                    }
681                }
682                FindingSeverity::Medium => {
683                    if self.rng.random::<bool>() {
684                        FindingSeverity::High
685                    } else {
686                        FindingSeverity::Low
687                    }
688                }
689                FindingSeverity::Low => FindingSeverity::Medium,
690                FindingSeverity::Informational => FindingSeverity::Low,
691            }
692        } else {
693            base_severity
694        }
695    }
696
697    /// Check if finding type is a misstatement.
698    fn is_misstatement_type(&self, finding_type: FindingType) -> bool {
699        matches!(
700            finding_type,
701            FindingType::MaterialMisstatement | FindingType::ImmaterialMisstatement
702        )
703    }
704
705    /// Generate misstatement amounts.
706    fn generate_misstatement_amounts(
707        &mut self,
708    ) -> (Option<Decimal>, Option<Decimal>, Option<Decimal>) {
709        let factual = Decimal::new(
710            self.rng
711                .random_range(self.config.misstatement_range.0..self.config.misstatement_range.1),
712            0,
713        );
714
715        let projected = if self.rng.random::<f64>() < 0.5 {
716            Some(Decimal::new(
717                self.rng
718                    .random_range(0..self.config.misstatement_range.1 / 2),
719                0,
720            ))
721        } else {
722            None
723        };
724
725        let judgmental = if self.rng.random::<f64>() < 0.3 {
726            Some(Decimal::new(
727                self.rng
728                    .random_range(0..self.config.misstatement_range.1 / 4),
729                0,
730            ))
731        } else {
732            None
733        };
734
735        (Some(factual), projected, judgmental)
736    }
737
738    /// Select assertions affected.
739    fn select_assertions(&mut self, finding_type: FindingType) -> Vec<Assertion> {
740        let mut assertions = Vec::new();
741
742        match finding_type {
743            FindingType::MaterialMisstatement | FindingType::ImmaterialMisstatement => {
744                assertions.push(Assertion::Accuracy);
745                if self.rng.random::<bool>() {
746                    assertions.push(Assertion::ValuationAndAllocation);
747                }
748            }
749            FindingType::MaterialWeakness
750            | FindingType::SignificantDeficiency
751            | FindingType::ControlDeficiency => {
752                let possible = [
753                    Assertion::Occurrence,
754                    Assertion::Completeness,
755                    Assertion::Accuracy,
756                    Assertion::Classification,
757                ];
758                let count = self.rng.random_range(1..=3);
759                for _ in 0..count {
760                    let idx = self.rng.random_range(0..possible.len());
761                    if !assertions.contains(&possible[idx]) {
762                        assertions.push(possible[idx]);
763                    }
764                }
765            }
766            _ => {
767                assertions.push(Assertion::PresentationAndDisclosure);
768            }
769        }
770
771        assertions
772    }
773
774    /// Select process areas.
775    fn select_process_areas(&mut self, account: &str) -> Vec<String> {
776        let account_lower = account.to_lowercase();
777
778        if account_lower.contains("revenue") || account_lower.contains("receivable") {
779            vec!["Order to Cash".into(), "Revenue Recognition".into()]
780        } else if account_lower.contains("payable") || account_lower.contains("expense") {
781            vec!["Procure to Pay".into(), "Expense Management".into()]
782        } else if account_lower.contains("inventory") {
783            vec!["Inventory Management".into(), "Cost of Goods Sold".into()]
784        } else if account_lower.contains("fixed asset") {
785            vec!["Capital Asset Management".into()]
786        } else if account_lower.contains("it") {
787            vec![
788                "IT General Controls".into(),
789                "IT Application Controls".into(),
790            ]
791        } else if account_lower.contains("payroll") {
792            vec!["Hire to Retire".into(), "Payroll Processing".into()]
793        } else {
794            vec!["Financial Close".into()]
795        }
796    }
797
798    /// Generate management response.
799    fn generate_management_response(&mut self, finding_type: FindingType, agrees: bool) -> String {
800        if agrees {
801            match finding_type {
802                FindingType::MaterialWeakness | FindingType::SignificantDeficiency => {
803                    "Management agrees with the finding and has initiated a remediation plan to \
804                    address the identified control deficiency. We expect to complete remediation \
805                    prior to the next audit cycle."
806                        .into()
807                }
808                FindingType::MaterialMisstatement | FindingType::ImmaterialMisstatement => {
809                    "Management agrees with the proposed adjustment and will record the entry. \
810                    We have implemented additional review procedures to prevent similar errors."
811                        .into()
812                }
813                _ => "Management agrees with the observation and will implement the recommended \
814                    improvements as resources permit."
815                    .into(),
816            }
817        } else {
818            "Management respectfully disagrees with the finding. We believe that existing \
819            controls are adequate and operating effectively. We will provide additional \
820            documentation to support our position."
821                .into()
822        }
823    }
824
825    /// Generate remediation plan.
826    fn generate_remediation_plan(
827        &mut self,
828        finding: &AuditFinding,
829        account: &str,
830    ) -> RemediationPlan {
831        let target_date = finding.identified_date + Duration::days(self.rng.random_range(60..180));
832
833        let description = format!(
834            "Implement enhanced controls and monitoring procedures for {account} to address \
835            the identified deficiency. This includes updated policies, additional training, \
836            and implementation of automated controls where feasible."
837        );
838
839        let responsible_party = format!(
840            "{} Manager",
841            if account.to_lowercase().contains("it") {
842                "IT"
843            } else {
844                "Controller"
845            }
846        );
847
848        let mut plan = RemediationPlan::new(
849            finding.finding_id,
850            &description,
851            &responsible_party,
852            target_date,
853        );
854
855        plan.validation_approach =
856            "Auditor will test remediated controls during the next audit cycle.".into();
857
858        // Add milestones
859        let milestone_dates = [
860            (
861                finding.identified_date + Duration::days(30),
862                "Complete root cause analysis",
863            ),
864            (
865                finding.identified_date + Duration::days(60),
866                "Document updated control procedures",
867            ),
868            (
869                finding.identified_date + Duration::days(90),
870                "Implement control changes",
871            ),
872            (target_date, "Complete testing and validation"),
873        ];
874
875        for (date, desc) in milestone_dates {
876            plan.add_milestone(desc, date);
877        }
878
879        // Maybe mark some progress
880        if self.rng.random::<f64>() < 0.3 {
881            plan.status = RemediationStatus::InProgress;
882            if !plan.milestones.is_empty() {
883                plan.milestones[0].status = MilestoneStatus::Complete;
884                plan.milestones[0].completion_date = Some(plan.milestones[0].target_date);
885            }
886        }
887
888        plan
889    }
890
891    /// Select a team member.
892    fn select_team_member(&mut self, team_members: &[String], role_hint: &str) -> String {
893        let matching: Vec<&String> = team_members
894            .iter()
895            .filter(|m| m.to_lowercase().contains(role_hint))
896            .collect();
897
898        if let Some(&member) = matching.first() {
899            member.clone()
900        } else if !team_members.is_empty() {
901            let idx = self.rng.random_range(0..team_members.len());
902            team_members[idx].clone()
903        } else {
904            format!("{}001", role_hint.to_uppercase())
905        }
906    }
907}
908
909#[cfg(test)]
910#[allow(clippy::unwrap_used)]
911mod tests {
912    use super::*;
913    use crate::audit::test_helpers::create_test_engagement;
914
915    #[test]
916    fn test_finding_generation() {
917        let mut generator = FindingGenerator::new(42);
918        let engagement = create_test_engagement();
919        let team = vec!["STAFF001".into(), "SENIOR001".into(), "MANAGER001".into()];
920
921        let findings = generator.generate_findings_for_engagement(&engagement, &[], &team);
922
923        assert!(!findings.is_empty());
924        for finding in &findings {
925            assert!(!finding.condition.is_empty());
926            assert!(!finding.criteria.is_empty());
927            assert!(!finding.recommendation.is_empty());
928        }
929    }
930
931    #[test]
932    fn test_finding_types_distribution() {
933        let mut generator = FindingGenerator::new(42);
934        let engagement = create_test_engagement();
935        let team = vec!["STAFF001".into()];
936
937        // Generate many findings to check distribution
938        let config = FindingGeneratorConfig {
939            findings_per_engagement: (50, 50),
940            ..Default::default()
941        };
942        generator.config = config;
943
944        let findings = generator.generate_findings_for_engagement(&engagement, &[], &team);
945
946        let material_weaknesses = findings
947            .iter()
948            .filter(|f| f.finding_type == FindingType::MaterialWeakness)
949            .count();
950        let significant_deficiencies = findings
951            .iter()
952            .filter(|f| f.finding_type == FindingType::SignificantDeficiency)
953            .count();
954
955        // Material weaknesses should be rare
956        assert!(material_weaknesses < 10);
957        // Significant deficiencies should be more common than material weaknesses
958        assert!(significant_deficiencies > material_weaknesses);
959    }
960
961    #[test]
962    fn test_misstatement_finding() {
963        let config = FindingGeneratorConfig {
964            misstatement_probability: 1.0,
965            material_weakness_probability: 0.0,
966            significant_deficiency_probability: 0.0,
967            ..Default::default()
968        };
969        let mut generator = FindingGenerator::with_config(42, config);
970        let engagement = create_test_engagement();
971
972        let finding = generator.generate_finding(&engagement, &[], &["STAFF001".into()]);
973
974        assert!(finding.is_misstatement);
975        assert!(finding.factual_misstatement.is_some() || finding.projected_misstatement.is_some());
976    }
977
978    #[test]
979    fn test_remediation_plan() {
980        let config = FindingGeneratorConfig {
981            remediation_plan_probability: 1.0,
982            management_agrees_probability: 1.0,
983            ..Default::default()
984        };
985        let mut generator = FindingGenerator::with_config(42, config);
986        let engagement = create_test_engagement();
987
988        let findings =
989            generator.generate_findings_for_engagement(&engagement, &[], &["STAFF001".into()]);
990
991        // At least some findings should have remediation plans
992        let with_plans = findings
993            .iter()
994            .filter(|f| f.remediation_plan.is_some())
995            .count();
996        assert!(with_plans > 0);
997
998        for finding in findings.iter().filter(|f| f.remediation_plan.is_some()) {
999            let plan = finding.remediation_plan.as_ref().unwrap();
1000            assert!(!plan.description.is_empty());
1001            assert!(!plan.milestones.is_empty());
1002        }
1003    }
1004
1005    #[test]
1006    fn test_governance_communication() {
1007        let config = FindingGeneratorConfig {
1008            material_weakness_probability: 1.0,
1009            ..Default::default()
1010        };
1011        let mut generator = FindingGenerator::with_config(42, config);
1012        let engagement = create_test_engagement();
1013
1014        let finding = generator.generate_finding(&engagement, &[], &["STAFF001".into()]);
1015
1016        assert!(finding.report_to_governance);
1017        assert!(finding.include_in_management_letter);
1018    }
1019
1020    #[test]
1021    fn test_generate_findings_with_context_links_controls_and_risks() {
1022        use datasynth_core::models::audit::Assertion;
1023
1024        let mut generator = FindingGenerator::new(42);
1025        let engagement = create_test_engagement();
1026        let team = vec!["STAFF001".into(), "SENIOR001".into(), "MANAGER001".into()];
1027
1028        let controls = vec![
1029            AvailableControl {
1030                control_id: "CTRL-001".into(),
1031                assertions: vec![Assertion::Accuracy, Assertion::Completeness],
1032                process_areas: vec!["Revenue Recognition".into()],
1033            },
1034            AvailableControl {
1035                control_id: "CTRL-002".into(),
1036                assertions: vec![Assertion::Occurrence],
1037                process_areas: vec!["Procure to Pay".into()],
1038            },
1039        ];
1040
1041        let risks = vec![AvailableRisk {
1042            risk_id: "RISK-001".into(),
1043            engagement_id: engagement.engagement_id,
1044            account_or_process: "Revenue".into(),
1045        }];
1046
1047        let findings =
1048            generator.generate_findings_with_context(&engagement, &[], &team, &controls, &risks);
1049
1050        assert!(!findings.is_empty());
1051
1052        // Every finding should have at least one related control or risk
1053        for finding in &findings {
1054            let has_controls = !finding.related_control_ids.is_empty();
1055            let has_risk = finding.related_risk_id.is_some();
1056            assert!(
1057                has_controls || has_risk,
1058                "Finding {} should have related controls or risk",
1059                finding.finding_ref
1060            );
1061        }
1062
1063        // At least one finding should have a related risk from same engagement
1064        let with_risk = findings
1065            .iter()
1066            .filter(|f| f.related_risk_id.is_some())
1067            .count();
1068        assert!(with_risk > 0, "At least one finding should link to a risk");
1069    }
1070
1071    #[test]
1072    fn test_generate_findings_with_context_caps_controls_at_three() {
1073        use datasynth_core::models::audit::Assertion;
1074
1075        let config = FindingGeneratorConfig {
1076            findings_per_engagement: (5, 5),
1077            ..Default::default()
1078        };
1079        let mut generator = FindingGenerator::with_config(42, config);
1080        let engagement = create_test_engagement();
1081        let team = vec!["STAFF001".into()];
1082
1083        // Create many controls covering all assertions
1084        let controls: Vec<AvailableControl> = (0..10)
1085            .map(|i| AvailableControl {
1086                control_id: format!("CTRL-{:03}", i),
1087                assertions: vec![
1088                    Assertion::Accuracy,
1089                    Assertion::Completeness,
1090                    Assertion::Occurrence,
1091                    Assertion::Classification,
1092                ],
1093                process_areas: vec![
1094                    "Revenue Recognition".into(),
1095                    "Procure to Pay".into(),
1096                    "Financial Close".into(),
1097                ],
1098            })
1099            .collect();
1100
1101        let findings =
1102            generator.generate_findings_with_context(&engagement, &[], &team, &controls, &[]);
1103
1104        for finding in &findings {
1105            assert!(
1106                finding.related_control_ids.len() <= 3,
1107                "Finding {} has {} controls, expected max 3",
1108                finding.finding_ref,
1109                finding.related_control_ids.len()
1110            );
1111        }
1112    }
1113
1114    #[test]
1115    fn test_workpaper_id_populated_from_workpaper_refs() {
1116        let mut generator = FindingGenerator::new(42);
1117        let engagement = create_test_engagement();
1118        let team = vec!["STAFF001".into()];
1119
1120        // Create a workpaper to reference
1121        use datasynth_core::models::audit::{Workpaper, WorkpaperSection};
1122        let workpaper = Workpaper::new(
1123            engagement.engagement_id,
1124            "WP-001",
1125            "Test Workpaper",
1126            WorkpaperSection::ControlTesting,
1127        );
1128
1129        let findings = generator.generate_findings_for_engagement(&engagement, &[workpaper], &team);
1130
1131        // All findings should have at least one workpaper ref, and workpaper_id should be set
1132        for finding in &findings {
1133            assert!(
1134                !finding.workpaper_refs.is_empty(),
1135                "Finding should have workpaper refs when workpapers provided"
1136            );
1137            assert!(
1138                finding.workpaper_id.is_some(),
1139                "Finding should have workpaper_id set when workpaper_refs is populated"
1140            );
1141        }
1142    }
1143}