Skip to main content

datasynth_generators/audit/
risk_generator.rs

1//! Risk assessment generator for audit engagements.
2//!
3//! Generates risk assessments with fraud risk factors, planned responses,
4//! and cross-references per ISA 315 and ISA 330.
5
6use chrono::Duration;
7use datasynth_core::utils::seeded_rng;
8use rand::Rng;
9use rand_chacha::ChaCha8Rng;
10
11use datasynth_core::models::audit::{
12    Assertion, AuditEngagement, DetectionRisk, EngagementPhase, FraudRiskFactor,
13    FraudTriangleElement, PlannedResponse, ResponseNature, ResponseProcedureType, ResponseStatus,
14    ResponseTiming, RiskAssessment, RiskCategory, RiskLevel, RiskReviewStatus, Trend,
15};
16
17/// Configuration for risk assessment generation.
18#[derive(Debug, Clone)]
19pub struct RiskAssessmentGeneratorConfig {
20    /// Number of risks per engagement (min, max)
21    pub risks_per_engagement: (u32, u32),
22    /// Probability of significant risk designation
23    pub significant_risk_probability: f64,
24    /// Probability of high inherent risk
25    pub high_inherent_risk_probability: f64,
26    /// Probability of fraud risk factors
27    pub fraud_factor_probability: f64,
28    /// Number of planned responses per risk (min, max)
29    pub responses_per_risk: (u32, u32),
30}
31
32impl Default for RiskAssessmentGeneratorConfig {
33    fn default() -> Self {
34        Self {
35            risks_per_engagement: (8, 20),
36            significant_risk_probability: 0.20,
37            high_inherent_risk_probability: 0.25,
38            fraud_factor_probability: 0.30,
39            responses_per_risk: (1, 4),
40        }
41    }
42}
43
44/// Generator for risk assessments.
45pub struct RiskAssessmentGenerator {
46    rng: ChaCha8Rng,
47    config: RiskAssessmentGeneratorConfig,
48    risk_counter: u32,
49}
50
51impl RiskAssessmentGenerator {
52    /// Create a new generator with the given seed.
53    pub fn new(seed: u64) -> Self {
54        Self {
55            rng: seeded_rng(seed, 0),
56            config: RiskAssessmentGeneratorConfig::default(),
57            risk_counter: 0,
58        }
59    }
60
61    /// Create a new generator with custom configuration.
62    pub fn with_config(seed: u64, config: RiskAssessmentGeneratorConfig) -> Self {
63        Self {
64            rng: seeded_rng(seed, 0),
65            config,
66            risk_counter: 0,
67        }
68    }
69
70    /// Generate risk assessments for an engagement.
71    pub fn generate_risks_for_engagement(
72        &mut self,
73        engagement: &AuditEngagement,
74        team_members: &[String],
75        accounts: &[String],
76    ) -> Vec<RiskAssessment> {
77        let count = self
78            .rng
79            .random_range(self.config.risks_per_engagement.0..=self.config.risks_per_engagement.1);
80
81        let mut risks = Vec::with_capacity(count as usize);
82
83        // Always include presumed risks per ISA 240
84        risks.push(self.generate_revenue_fraud_risk(engagement, team_members));
85        risks.push(self.generate_management_override_risk(engagement, team_members));
86
87        // Generate additional risks for various accounts/processes
88        let risk_areas = self.get_risk_areas(accounts);
89        for area in risk_areas.iter().take((count - 2) as usize) {
90            let risk = self.generate_risk_assessment(engagement, area, team_members);
91            risks.push(risk);
92        }
93
94        risks
95    }
96
97    /// Generate a single risk assessment.
98    pub fn generate_risk_assessment(
99        &mut self,
100        engagement: &AuditEngagement,
101        account_or_process: &str,
102        team_members: &[String],
103    ) -> RiskAssessment {
104        self.risk_counter += 1;
105
106        let risk_category = self.select_risk_category();
107        let (description, assertion) =
108            self.generate_risk_description(account_or_process, risk_category);
109
110        let mut risk = RiskAssessment::new(
111            engagement.engagement_id,
112            risk_category,
113            account_or_process,
114            &description,
115        );
116
117        risk.risk_ref = format!("RISK-{:04}", self.risk_counter);
118
119        // Set assertion if applicable
120        if let Some(assertion) = assertion {
121            risk = risk.with_assertion(assertion);
122        }
123
124        // Set risk levels (also computes continuous scores via model)
125        let inherent_risk = self.generate_inherent_risk();
126        let control_risk = self.generate_control_risk(&inherent_risk);
127        risk = risk.with_risk_levels(inherent_risk, control_risk);
128
129        // Override default risk_name with a more descriptive category-specific name
130        risk.risk_name = self.generate_risk_name(account_or_process, risk_category, inherent_risk);
131
132        // Maybe mark as significant
133        if self.rng.random::<f64>() < self.config.significant_risk_probability
134            || matches!(
135                risk.risk_of_material_misstatement,
136                RiskLevel::High | RiskLevel::Significant
137            )
138        {
139            let rationale = self.generate_significant_risk_rationale(risk_category);
140            risk = risk.mark_significant(&rationale);
141        }
142
143        // Add fraud risk factors if applicable
144        if self.rng.random::<f64>() < self.config.fraud_factor_probability {
145            let factors = self.generate_fraud_factors();
146            for factor in factors {
147                risk.add_fraud_factor(factor);
148            }
149        }
150
151        // Set response nature and timing
152        risk.response_nature = self.select_response_nature(&risk);
153        risk.response_timing = self.select_response_timing(engagement);
154        risk.response_extent = self.generate_response_extent(&risk);
155
156        // Add planned responses
157        let response_count = self
158            .rng
159            .random_range(self.config.responses_per_risk.0..=self.config.responses_per_risk.1);
160        for _ in 0..response_count {
161            let response = self.generate_planned_response(&risk, team_members, engagement);
162            risk.add_response(response);
163        }
164
165        // Set assessor
166        let assessor = self.select_team_member(team_members, "senior");
167        risk = risk.with_assessed_by(&assessor, engagement.planning_start + Duration::days(7));
168
169        // Maybe add review
170        if self.rng.random::<f64>() < 0.8 {
171            risk.review_status = RiskReviewStatus::Approved;
172            risk.reviewer_id = Some(self.select_team_member(team_members, "manager"));
173            risk.review_date = Some(engagement.planning_start + Duration::days(14));
174        }
175
176        risk
177    }
178
179    /// Generate presumed revenue fraud risk per ISA 240.
180    fn generate_revenue_fraud_risk(
181        &mut self,
182        engagement: &AuditEngagement,
183        team_members: &[String],
184    ) -> RiskAssessment {
185        self.risk_counter += 1;
186
187        let mut risk = RiskAssessment::new(
188            engagement.engagement_id,
189            RiskCategory::FraudRisk,
190            "Revenue Recognition",
191            "Presumed fraud risk in revenue recognition per ISA 240.26",
192        );
193
194        risk.risk_ref = format!("RISK-{:04}", self.risk_counter);
195        risk = risk.with_assertion(Assertion::Occurrence);
196        risk = risk.with_risk_levels(RiskLevel::High, RiskLevel::Medium);
197        risk.risk_name = "Revenue Recognition Fraud Risk [High]".into();
198        risk = risk.mark_significant("Presumed fraud risk per ISA 240 - revenue recognition");
199        risk.presumed_revenue_fraud_risk = true;
200
201        // Add fraud triangle factors
202        risk.add_fraud_factor(FraudRiskFactor::new(
203            FraudTriangleElement::Pressure,
204            "Management compensation tied to revenue targets",
205            75,
206            "Compensation plan review",
207        ));
208        risk.add_fraud_factor(FraudRiskFactor::new(
209            FraudTriangleElement::Opportunity,
210            "Complex revenue arrangements with multiple performance obligations",
211            60,
212            "Contract review",
213        ));
214
215        risk.response_nature = ResponseNature::SubstantiveOnly;
216        risk.response_timing = ResponseTiming::YearEnd;
217        risk.response_extent = "Extended substantive testing with increased sample sizes".into();
218
219        // Add responses
220        risk.add_response(PlannedResponse::new(
221            "Test revenue cutoff at year-end",
222            ResponseProcedureType::TestOfDetails,
223            Assertion::Cutoff,
224            &self.select_team_member(team_members, "senior"),
225            engagement.fieldwork_start + Duration::days(14),
226        ));
227        risk.add_response(PlannedResponse::new(
228            "Confirm significant revenue transactions with customers",
229            ResponseProcedureType::Confirmation,
230            Assertion::Occurrence,
231            &self.select_team_member(team_members, "staff"),
232            engagement.fieldwork_start + Duration::days(21),
233        ));
234        risk.add_response(PlannedResponse::new(
235            "Perform analytical procedures on revenue trends",
236            ResponseProcedureType::AnalyticalProcedure,
237            Assertion::Completeness,
238            &self.select_team_member(team_members, "senior"),
239            engagement.fieldwork_start + Duration::days(7),
240        ));
241
242        let assessor = self.select_team_member(team_members, "manager");
243        risk = risk.with_assessed_by(&assessor, engagement.planning_start);
244        risk.review_status = RiskReviewStatus::Approved;
245        risk.reviewer_id = Some(engagement.engagement_partner_id.clone());
246        risk.review_date = Some(engagement.planning_start + Duration::days(7));
247
248        risk
249    }
250
251    /// Generate presumed management override risk per ISA 240.
252    fn generate_management_override_risk(
253        &mut self,
254        engagement: &AuditEngagement,
255        team_members: &[String],
256    ) -> RiskAssessment {
257        self.risk_counter += 1;
258
259        let mut risk = RiskAssessment::new(
260            engagement.engagement_id,
261            RiskCategory::FraudRisk,
262            "Management Override of Controls",
263            "Presumed risk of management override of controls per ISA 240.31",
264        );
265
266        risk.risk_ref = format!("RISK-{:04}", self.risk_counter);
267        risk = risk.with_risk_levels(RiskLevel::High, RiskLevel::High);
268        risk.risk_name = "Management Override of Controls Risk [High]".into();
269        risk = risk.mark_significant("Presumed fraud risk per ISA 240 - management override");
270        risk.presumed_management_override = true;
271
272        risk.add_fraud_factor(FraudRiskFactor::new(
273            FraudTriangleElement::Opportunity,
274            "Management has ability to override controls",
275            80,
276            "Control environment assessment",
277        ));
278        risk.add_fraud_factor(FraudRiskFactor::new(
279            FraudTriangleElement::Rationalization,
280            "Tone at the top may not emphasize ethical behavior",
281            50,
282            "Governance inquiries",
283        ));
284
285        risk.response_nature = ResponseNature::SubstantiveOnly;
286        risk.response_timing = ResponseTiming::YearEnd;
287        risk.response_extent = "Mandatory procedures per ISA 240.32-34".into();
288
289        // ISA 240 required responses
290        risk.add_response(PlannedResponse::new(
291            "Test appropriateness of journal entries and adjustments",
292            ResponseProcedureType::TestOfDetails,
293            Assertion::Accuracy,
294            &self.select_team_member(team_members, "senior"),
295            engagement.fieldwork_start + Duration::days(28),
296        ));
297        risk.add_response(PlannedResponse::new(
298            "Review accounting estimates for bias",
299            ResponseProcedureType::AnalyticalProcedure,
300            Assertion::ValuationAndAllocation,
301            &self.select_team_member(team_members, "manager"),
302            engagement.fieldwork_start + Duration::days(35),
303        ));
304        risk.add_response(PlannedResponse::new(
305            "Evaluate business rationale for significant unusual transactions",
306            ResponseProcedureType::Inquiry,
307            Assertion::Occurrence,
308            &self.select_team_member(team_members, "manager"),
309            engagement.fieldwork_start + Duration::days(42),
310        ));
311
312        let assessor = self.select_team_member(team_members, "manager");
313        risk = risk.with_assessed_by(&assessor, engagement.planning_start);
314        risk.review_status = RiskReviewStatus::Approved;
315        risk.reviewer_id = Some(engagement.engagement_partner_id.clone());
316        risk.review_date = Some(engagement.planning_start + Duration::days(7));
317
318        risk
319    }
320
321    /// Get risk areas based on accounts.
322    fn get_risk_areas(&mut self, accounts: &[String]) -> Vec<String> {
323        let mut areas: Vec<String> = if accounts.is_empty() {
324            vec![
325                "Cash and Cash Equivalents".into(),
326                "Accounts Receivable".into(),
327                "Inventory".into(),
328                "Property, Plant and Equipment".into(),
329                "Accounts Payable".into(),
330                "Accrued Liabilities".into(),
331                "Long-term Debt".into(),
332                "Revenue".into(),
333                "Cost of Sales".into(),
334                "Operating Expenses".into(),
335                "Payroll and Benefits".into(),
336                "Income Taxes".into(),
337                "Related Party Transactions".into(),
338                "Financial Statement Disclosures".into(),
339                "IT General Controls".into(),
340            ]
341        } else {
342            accounts.to_vec()
343        };
344
345        // Shuffle and return
346        for i in (1..areas.len()).rev() {
347            let j = self.rng.random_range(0..=i);
348            areas.swap(i, j);
349        }
350        areas
351    }
352
353    /// Select risk category.
354    fn select_risk_category(&mut self) -> RiskCategory {
355        let categories = [
356            (RiskCategory::AssertionLevel, 0.50),
357            (RiskCategory::FinancialStatementLevel, 0.15),
358            (RiskCategory::EstimateRisk, 0.10),
359            (RiskCategory::ItGeneralControl, 0.10),
360            (RiskCategory::RelatedParty, 0.05),
361            (RiskCategory::GoingConcern, 0.05),
362            (RiskCategory::RegulatoryCompliance, 0.05),
363        ];
364
365        let r: f64 = self.rng.random();
366        let mut cumulative = 0.0;
367        for (category, probability) in categories {
368            cumulative += probability;
369            if r < cumulative {
370                return category;
371            }
372        }
373        RiskCategory::AssertionLevel
374    }
375
376    /// Generate a meaningful risk name from a category-specific pool.
377    fn generate_risk_name(
378        &mut self,
379        account_or_process: &str,
380        category: RiskCategory,
381        inherent_risk: RiskLevel,
382    ) -> String {
383        let qualifier = match category {
384            RiskCategory::AssertionLevel => {
385                let qualifiers = [
386                    "Accuracy",
387                    "Completeness",
388                    "Valuation",
389                    "Existence",
390                    "Cutoff",
391                    "Classification",
392                ];
393                let idx = self.rng.random_range(0..qualifiers.len());
394                qualifiers[idx]
395            }
396            RiskCategory::FinancialStatementLevel => "Pervasive",
397            RiskCategory::FraudRisk => "Fraud",
398            RiskCategory::GoingConcern => "Going Concern",
399            RiskCategory::RelatedParty => "Related Party",
400            RiskCategory::EstimateRisk => {
401                let qualifiers = ["Estimation Uncertainty", "Fair Value", "Impairment"];
402                let idx = self.rng.random_range(0..qualifiers.len());
403                qualifiers[idx]
404            }
405            RiskCategory::ItGeneralControl => "ITGC",
406            RiskCategory::RegulatoryCompliance => "Compliance",
407        };
408
409        format!(
410            "{} {} Risk [{:?}]",
411            account_or_process, qualifier, inherent_risk
412        )
413    }
414
415    /// Generate risk description and assertion.
416    fn generate_risk_description(
417        &mut self,
418        account_or_process: &str,
419        category: RiskCategory,
420    ) -> (String, Option<Assertion>) {
421        let assertions = [
422            (Assertion::Existence, "existence"),
423            (Assertion::Completeness, "completeness"),
424            (Assertion::Accuracy, "accuracy"),
425            (Assertion::ValuationAndAllocation, "valuation"),
426            (Assertion::Cutoff, "cutoff"),
427            (Assertion::RightsAndObligations, "rights and obligations"),
428            (
429                Assertion::PresentationAndDisclosure,
430                "presentation and disclosure",
431            ),
432        ];
433
434        let idx = self.rng.random_range(0..assertions.len());
435        let (assertion, assertion_name) = assertions[idx];
436
437        let description = match category {
438            RiskCategory::AssertionLevel => {
439                format!(
440                    "Risk that {account_or_process} is materially misstated due to {assertion_name}"
441                )
442            }
443            RiskCategory::FinancialStatementLevel => {
444                format!(
445                    "Pervasive risk affecting {account_or_process} due to control environment weaknesses"
446                )
447            }
448            RiskCategory::EstimateRisk => {
449                format!(
450                    "Risk of material misstatement in {account_or_process} estimates due to estimation uncertainty"
451                )
452            }
453            RiskCategory::ItGeneralControl => {
454                format!(
455                    "IT general control risk affecting {account_or_process} data integrity and processing"
456                )
457            }
458            RiskCategory::RelatedParty => {
459                format!(
460                    "Risk of undisclosed related party transactions affecting {account_or_process}"
461                )
462            }
463            RiskCategory::GoingConcern => {
464                "Risk that the entity may not continue as a going concern".into()
465            }
466            RiskCategory::RegulatoryCompliance => {
467                format!(
468                    "Risk of non-compliance with laws and regulations affecting {account_or_process}"
469                )
470            }
471            RiskCategory::FraudRisk => {
472                format!("Fraud risk in {account_or_process}")
473            }
474        };
475
476        let assertion_opt = match category {
477            RiskCategory::AssertionLevel | RiskCategory::EstimateRisk => Some(assertion),
478            _ => None,
479        };
480
481        (description, assertion_opt)
482    }
483
484    /// Generate inherent risk level.
485    fn generate_inherent_risk(&mut self) -> RiskLevel {
486        if self.rng.random::<f64>() < self.config.high_inherent_risk_probability {
487            RiskLevel::High
488        } else if self.rng.random::<f64>() < 0.5 {
489            RiskLevel::Medium
490        } else {
491            RiskLevel::Low
492        }
493    }
494
495    /// Generate control risk based on inherent risk.
496    fn generate_control_risk(&mut self, inherent_risk: &RiskLevel) -> RiskLevel {
497        // Control risk tends to be correlated with inherent risk
498        match inherent_risk {
499            RiskLevel::High | RiskLevel::Significant => {
500                if self.rng.random::<f64>() < 0.6 {
501                    RiskLevel::High
502                } else {
503                    RiskLevel::Medium
504                }
505            }
506            RiskLevel::Medium => {
507                if self.rng.random::<f64>() < 0.4 {
508                    RiskLevel::Medium
509                } else if self.rng.random::<f64>() < 0.7 {
510                    RiskLevel::Low
511                } else {
512                    RiskLevel::High
513                }
514            }
515            RiskLevel::Low => {
516                if self.rng.random::<f64>() < 0.7 {
517                    RiskLevel::Low
518                } else {
519                    RiskLevel::Medium
520                }
521            }
522        }
523    }
524
525    /// Generate significant risk rationale.
526    fn generate_significant_risk_rationale(&mut self, category: RiskCategory) -> String {
527        match category {
528            RiskCategory::FraudRisk => {
529                "Fraud risk requiring special audit consideration per ISA 240".into()
530            }
531            RiskCategory::EstimateRisk => {
532                "High estimation uncertainty requiring special audit consideration per ISA 540"
533                    .into()
534            }
535            RiskCategory::RelatedParty => {
536                "Related party transactions outside normal course of business per ISA 550".into()
537            }
538            RiskCategory::GoingConcern => {
539                "Significant doubt about going concern per ISA 570".into()
540            }
541            _ => {
542                let rationales = [
543                    "High inherent risk combined with weak control environment",
544                    "Significant management judgment involved",
545                    "Complex transactions requiring specialized knowledge",
546                    "History of misstatements in this area",
547                    "New accounting standard implementation",
548                ];
549                let idx = self.rng.random_range(0..rationales.len());
550                rationales[idx].into()
551            }
552        }
553    }
554
555    /// Generate fraud risk factors.
556    fn generate_fraud_factors(&mut self) -> Vec<FraudRiskFactor> {
557        let mut factors = Vec::new();
558        let count = self.rng.random_range(1..=3);
559
560        let pressure_indicators = [
561            "Financial pressure from debt covenants",
562            "Compensation tied to financial targets",
563            "Industry decline affecting profitability",
564            "Unrealistic budget expectations",
565        ];
566
567        let opportunity_indicators = [
568            "Weak segregation of duties",
569            "Lack of independent oversight",
570            "Complex organizational structure",
571            "Inadequate monitoring of controls",
572        ];
573
574        let rationalization_indicators = [
575            "History of management explanations for variances",
576            "Aggressive accounting policies",
577            "Frequent disputes with auditors",
578            "Strained relationship with regulators",
579        ];
580
581        for _ in 0..count {
582            let element = match self.rng.random_range(0..3) {
583                0 => {
584                    let idx = self.rng.random_range(0..pressure_indicators.len());
585                    FraudRiskFactor::new(
586                        FraudTriangleElement::Pressure,
587                        pressure_indicators[idx],
588                        self.rng.random_range(40..90),
589                        "Risk assessment procedures",
590                    )
591                }
592                1 => {
593                    let idx = self.rng.random_range(0..opportunity_indicators.len());
594                    FraudRiskFactor::new(
595                        FraudTriangleElement::Opportunity,
596                        opportunity_indicators[idx],
597                        self.rng.random_range(40..90),
598                        "Control environment assessment",
599                    )
600                }
601                _ => {
602                    let idx = self.rng.random_range(0..rationalization_indicators.len());
603                    FraudRiskFactor::new(
604                        FraudTriangleElement::Rationalization,
605                        rationalization_indicators[idx],
606                        self.rng.random_range(30..70),
607                        "Management inquiries",
608                    )
609                }
610            };
611
612            let trend = match self.rng.random_range(0..3) {
613                0 => Trend::Increasing,
614                1 => Trend::Stable,
615                _ => Trend::Decreasing,
616            };
617
618            factors.push(element.with_trend(trend));
619        }
620
621        factors
622    }
623
624    /// Select response nature based on risk.
625    fn select_response_nature(&mut self, risk: &RiskAssessment) -> ResponseNature {
626        match risk.risk_of_material_misstatement {
627            RiskLevel::High | RiskLevel::Significant => ResponseNature::SubstantiveOnly,
628            RiskLevel::Medium => {
629                if self.rng.random::<f64>() < 0.6 {
630                    ResponseNature::Combined
631                } else {
632                    ResponseNature::SubstantiveOnly
633                }
634            }
635            RiskLevel::Low => {
636                if self.rng.random::<f64>() < 0.4 {
637                    ResponseNature::ControlsReliance
638                } else {
639                    ResponseNature::Combined
640                }
641            }
642        }
643    }
644
645    /// Select response timing.
646    fn select_response_timing(&mut self, engagement: &AuditEngagement) -> ResponseTiming {
647        match engagement.current_phase {
648            EngagementPhase::Planning | EngagementPhase::RiskAssessment => {
649                if self.rng.random::<f64>() < 0.3 {
650                    ResponseTiming::Interim
651                } else {
652                    ResponseTiming::YearEnd
653                }
654            }
655            _ => ResponseTiming::YearEnd,
656        }
657    }
658
659    /// Generate response extent description.
660    fn generate_response_extent(&mut self, risk: &RiskAssessment) -> String {
661        match risk.required_detection_risk() {
662            DetectionRisk::Low => {
663                "Extended testing with larger sample sizes and unpredictable procedures".into()
664            }
665            DetectionRisk::Medium => {
666                "Moderate sample sizes with standard testing procedures".into()
667            }
668            DetectionRisk::High => {
669                "Reduced testing extent with reliance on analytical procedures".into()
670            }
671        }
672    }
673
674    /// Generate a planned response.
675    fn generate_planned_response(
676        &mut self,
677        risk: &RiskAssessment,
678        team_members: &[String],
679        engagement: &AuditEngagement,
680    ) -> PlannedResponse {
681        let procedure_type = self.select_procedure_type(&risk.response_nature);
682        let assertion = risk.assertion.unwrap_or_else(|| self.random_assertion());
683        let procedure =
684            self.generate_procedure_description(procedure_type, &risk.account_or_process);
685
686        let days_offset = self.rng.random_range(7..45);
687        let target_date = engagement.fieldwork_start + Duration::days(days_offset);
688
689        let mut response = PlannedResponse::new(
690            &procedure,
691            procedure_type,
692            assertion,
693            &self.select_team_member(team_members, "staff"),
694            target_date,
695        );
696
697        // Maybe mark as in progress or complete
698        if self.rng.random::<f64>() < 0.2 {
699            response.status = ResponseStatus::InProgress;
700        }
701
702        response
703    }
704
705    /// Select procedure type based on response nature.
706    fn select_procedure_type(&mut self, nature: &ResponseNature) -> ResponseProcedureType {
707        match nature {
708            ResponseNature::ControlsReliance => {
709                if self.rng.random::<f64>() < 0.7 {
710                    ResponseProcedureType::TestOfControls
711                } else {
712                    ResponseProcedureType::Inquiry
713                }
714            }
715            ResponseNature::SubstantiveOnly => {
716                let types = [
717                    ResponseProcedureType::TestOfDetails,
718                    ResponseProcedureType::AnalyticalProcedure,
719                    ResponseProcedureType::Confirmation,
720                    ResponseProcedureType::PhysicalInspection,
721                ];
722                let idx = self.rng.random_range(0..types.len());
723                types[idx]
724            }
725            ResponseNature::Combined => {
726                let types = [
727                    ResponseProcedureType::TestOfControls,
728                    ResponseProcedureType::TestOfDetails,
729                    ResponseProcedureType::AnalyticalProcedure,
730                ];
731                let idx = self.rng.random_range(0..types.len());
732                types[idx]
733            }
734        }
735    }
736
737    /// Generate procedure description.
738    fn generate_procedure_description(
739        &mut self,
740        procedure_type: ResponseProcedureType,
741        account: &str,
742    ) -> String {
743        match procedure_type {
744            ResponseProcedureType::TestOfControls => {
745                format!("Test operating effectiveness of controls over {account}")
746            }
747            ResponseProcedureType::TestOfDetails => {
748                format!(
749                    "Select sample of {account} transactions and vouch to supporting documentation"
750                )
751            }
752            ResponseProcedureType::AnalyticalProcedure => {
753                format!("Perform analytical procedures on {account} and investigate variances")
754            }
755            ResponseProcedureType::Confirmation => {
756                format!("Send confirmations for {account} balances")
757            }
758            ResponseProcedureType::PhysicalInspection => {
759                format!("Physically inspect {account} items")
760            }
761            ResponseProcedureType::Inquiry => {
762                format!("Inquire of management regarding {account} processes")
763            }
764        }
765    }
766
767    /// Select a team member.
768    fn select_team_member(&mut self, team_members: &[String], role_hint: &str) -> String {
769        let matching: Vec<&String> = team_members
770            .iter()
771            .filter(|m| m.to_lowercase().contains(role_hint))
772            .collect();
773
774        if let Some(&member) = matching.first() {
775            member.clone()
776        } else if !team_members.is_empty() {
777            let idx = self.rng.random_range(0..team_members.len());
778            team_members[idx].clone()
779        } else {
780            format!("{}001", role_hint.to_uppercase())
781        }
782    }
783
784    /// Generate a random assertion.
785    fn random_assertion(&mut self) -> Assertion {
786        let assertions = [
787            Assertion::Occurrence,
788            Assertion::Completeness,
789            Assertion::Accuracy,
790            Assertion::Cutoff,
791            Assertion::Existence,
792            Assertion::ValuationAndAllocation,
793        ];
794        let idx = self.rng.random_range(0..assertions.len());
795        assertions[idx]
796    }
797}
798
799#[cfg(test)]
800#[allow(clippy::unwrap_used)]
801mod tests {
802    use super::*;
803    use crate::audit::test_helpers::create_test_engagement;
804
805    #[test]
806    fn test_risk_generation() {
807        let mut generator = RiskAssessmentGenerator::new(42);
808        let engagement = create_test_engagement();
809        let team = vec!["STAFF001".into(), "SENIOR001".into(), "MANAGER001".into()];
810
811        let risks = generator.generate_risks_for_engagement(&engagement, &team, &[]);
812
813        assert!(risks.len() >= 2); // At least presumed risks
814
815        // Check for presumed risks
816        let has_revenue_fraud = risks.iter().any(|r| r.presumed_revenue_fraud_risk);
817        let has_mgmt_override = risks.iter().any(|r| r.presumed_management_override);
818        assert!(has_revenue_fraud);
819        assert!(has_mgmt_override);
820    }
821
822    #[test]
823    fn test_significant_risk() {
824        let mut generator = RiskAssessmentGenerator::new(42);
825        let engagement = create_test_engagement();
826        let team = vec!["STAFF001".into()];
827
828        let risks = generator.generate_risks_for_engagement(&engagement, &team, &[]);
829
830        // Presumed risks should be significant
831        let significant_risks: Vec<_> = risks.iter().filter(|r| r.is_significant_risk).collect();
832        assert!(significant_risks.len() >= 2);
833    }
834
835    #[test]
836    fn test_planned_responses() {
837        let mut generator = RiskAssessmentGenerator::new(42);
838        let engagement = create_test_engagement();
839        let team = vec!["STAFF001".into(), "SENIOR001".into()];
840
841        let risks = generator.generate_risks_for_engagement(&engagement, &team, &[]);
842
843        for risk in &risks {
844            assert!(!risk.planned_response.is_empty());
845        }
846    }
847
848    #[test]
849    fn test_fraud_factors() {
850        let config = RiskAssessmentGeneratorConfig {
851            fraud_factor_probability: 1.0,
852            ..Default::default()
853        };
854        let mut generator = RiskAssessmentGenerator::with_config(42, config);
855        let engagement = create_test_engagement();
856
857        let _risk =
858            generator.generate_risk_assessment(&engagement, "Inventory", &["STAFF001".into()]);
859
860        // May or may not have fraud factors depending on risk category
861        // But presumed risks always have them
862    }
863
864    #[test]
865    fn test_detection_risk() {
866        let mut generator = RiskAssessmentGenerator::new(42);
867        let engagement = create_test_engagement();
868
869        let risks = generator.generate_risks_for_engagement(&engagement, &["STAFF001".into()], &[]);
870
871        for risk in &risks {
872            let detection_risk = risk.required_detection_risk();
873            // High ROMM should require low detection risk
874            if matches!(
875                risk.risk_of_material_misstatement,
876                RiskLevel::High | RiskLevel::Significant
877            ) {
878                assert_eq!(detection_risk, DetectionRisk::Low);
879            }
880        }
881    }
882}