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