Skip to main content

datasynth_generators/audit/
internal_audit_generator.rs

1//! Internal audit generator per ISA 610.
2//!
3//! Generates an internal audit function record and associated reports for an
4//! audit engagement.  The external auditor's reliance extent is determined
5//! probabilistically from the configured ratios.
6
7use chrono::{Duration, NaiveDate};
8use datasynth_core::utils::seeded_rng;
9use rand::Rng;
10use rand_chacha::ChaCha8Rng;
11use uuid::Uuid;
12
13/// Generate a UUID from the seeded RNG so output is fully deterministic.
14fn rng_uuid(rng: &mut ChaCha8Rng) -> Uuid {
15    let mut bytes = [0u8; 16];
16    rng.fill(&mut bytes);
17    // Stamp as UUID v4 (version + variant bits).
18    bytes[6] = (bytes[6] & 0x0f) | 0x40;
19    bytes[8] = (bytes[8] & 0x3f) | 0x80;
20    Uuid::from_bytes(bytes)
21}
22
23use datasynth_core::models::audit::{
24    ActionPlan, ActionPlanStatus, AuditEngagement, CompetenceRating, IaAssessment,
25    IaRecommendation, IaReportRating, IaReportStatus, IaWorkAssessment, InternalAuditFunction,
26    InternalAuditReport, ObjectivityRating, RecommendationPriority, RelianceExtent, ReportingLine,
27};
28
29/// Configuration for internal audit function and report generation (ISA 610).
30#[derive(Debug, Clone)]
31pub struct InternalAuditGeneratorConfig {
32    /// Number of IA reports to generate per engagement (min, max).
33    pub reports_per_function: (u32, u32),
34    /// Fraction of engagements where no internal audit function exists.
35    pub no_reliance_ratio: f64,
36    /// Fraction of engagements where limited reliance is placed.
37    pub limited_reliance_ratio: f64,
38    /// Fraction of engagements where significant reliance is placed.
39    pub significant_reliance_ratio: f64,
40    /// Fraction of engagements where full reliance is placed.
41    pub full_reliance_ratio: f64,
42    /// Number of recommendations per IA report (min, max).
43    pub recommendations_per_report: (u32, u32),
44}
45
46impl Default for InternalAuditGeneratorConfig {
47    fn default() -> Self {
48        Self {
49            reports_per_function: (2, 5),
50            no_reliance_ratio: 0.20,
51            limited_reliance_ratio: 0.50,
52            significant_reliance_ratio: 0.25,
53            full_reliance_ratio: 0.05,
54            recommendations_per_report: (1, 4),
55        }
56    }
57}
58
59/// Generator for internal audit function records and IA reports per ISA 610.
60pub struct InternalAuditGenerator {
61    /// Seeded random number generator.
62    rng: ChaCha8Rng,
63    /// Configuration.
64    config: InternalAuditGeneratorConfig,
65}
66
67impl InternalAuditGenerator {
68    /// Create a new generator with the given seed and default configuration.
69    pub fn new(seed: u64) -> Self {
70        Self {
71            rng: seeded_rng(seed, 0),
72            config: InternalAuditGeneratorConfig::default(),
73        }
74    }
75
76    /// Create a new generator with custom configuration.
77    pub fn with_config(seed: u64, config: InternalAuditGeneratorConfig) -> Self {
78        Self {
79            rng: seeded_rng(seed, 0),
80            config,
81        }
82    }
83
84    /// Generate an internal audit function and its associated reports.
85    ///
86    /// Returns `(function, reports)`.  When the reliance extent is
87    /// `NoReliance`, the reports vec is empty — the entity either has no
88    /// internal audit function or the external auditor has decided not to use
89    /// its work at all.
90    pub fn generate(
91        &mut self,
92        engagement: &AuditEngagement,
93    ) -> (InternalAuditFunction, Vec<InternalAuditReport>) {
94        // Determine reliance extent from configured ratios.
95        let roll: f64 = self.rng.random();
96        let no_cutoff = self.config.no_reliance_ratio;
97        let limited_cutoff = no_cutoff + self.config.limited_reliance_ratio;
98        let significant_cutoff = limited_cutoff + self.config.significant_reliance_ratio;
99        // Anything above significant_cutoff → FullReliance.
100
101        let reliance = if roll < no_cutoff {
102            RelianceExtent::NoReliance
103        } else if roll < limited_cutoff {
104            RelianceExtent::LimitedReliance
105        } else if roll < significant_cutoff {
106            RelianceExtent::SignificantReliance
107        } else {
108            RelianceExtent::FullReliance
109        };
110
111        // Derive objectivity / competence ratings consistent with reliance level.
112        let (objectivity, competence, assessment) = match reliance {
113            RelianceExtent::NoReliance => (
114                ObjectivityRating::Low,
115                CompetenceRating::Low,
116                IaAssessment::Ineffective,
117            ),
118            RelianceExtent::LimitedReliance => (
119                ObjectivityRating::Moderate,
120                CompetenceRating::Moderate,
121                IaAssessment::PartiallyEffective,
122            ),
123            RelianceExtent::SignificantReliance => (
124                ObjectivityRating::High,
125                CompetenceRating::Moderate,
126                IaAssessment::LargelyEffective,
127            ),
128            RelianceExtent::FullReliance => (
129                ObjectivityRating::High,
130                CompetenceRating::High,
131                IaAssessment::FullyEffective,
132            ),
133        };
134
135        // Reporting line — AuditCommittee is most common.
136        let reporting_line = self.pick_reporting_line();
137
138        // Staff count: 3–15 proportional to reliance.
139        let staff_count: u32 = match reliance {
140            RelianceExtent::NoReliance => self.rng.random_range(1_u32..=4_u32),
141            RelianceExtent::LimitedReliance => self.rng.random_range(3_u32..=8_u32),
142            RelianceExtent::SignificantReliance => self.rng.random_range(6_u32..=12_u32),
143            RelianceExtent::FullReliance => self.rng.random_range(10_u32..=15_u32),
144        };
145
146        // Annual plan coverage: 40–95%.
147        let coverage: f64 = self.rng.random_range(0.40_f64..0.95_f64);
148
149        // Quality assurance programme present for significant/full reliance.
150        let quality_assurance = matches!(
151            reliance,
152            RelianceExtent::SignificantReliance | RelianceExtent::FullReliance
153        );
154
155        let head_name = self.head_of_ia_name();
156        let qualifications = self.ia_qualifications(&reliance);
157
158        let mut function =
159            InternalAuditFunction::new(engagement.engagement_id, "Internal Audit", &head_name);
160        // Override the UUID so output is fully deterministic.
161        let func_id = rng_uuid(&mut self.rng);
162        function.function_id = func_id;
163        function.function_ref = format!("IAF-{}", &func_id.simple().to_string()[..8]);
164        function.reporting_line = reporting_line;
165        function.staff_count = staff_count;
166        function.annual_plan_coverage = coverage;
167        function.quality_assurance = quality_assurance;
168        function.isa_610_assessment = assessment;
169        function.objectivity_rating = objectivity;
170        function.competence_rating = competence;
171        function.systematic_discipline = !matches!(reliance, RelianceExtent::NoReliance);
172        function.reliance_extent = reliance;
173        function.head_of_ia_qualifications = qualifications;
174        function.direct_assistance = matches!(
175            reliance,
176            RelianceExtent::SignificantReliance | RelianceExtent::FullReliance
177        ) && self.rng.random::<f64>() < 0.40;
178
179        if reliance == RelianceExtent::NoReliance {
180            return (function, Vec::new());
181        }
182
183        // Generate IA reports.
184        let report_count = self
185            .rng
186            .random_range(self.config.reports_per_function.0..=self.config.reports_per_function.1)
187            as usize;
188
189        let mut reports = Vec::with_capacity(report_count);
190        let audit_areas = self.audit_areas();
191        // Shuffle audit areas to pick distinct ones per report.
192        let area_count = audit_areas.len();
193
194        for i in 0..report_count {
195            let area = audit_areas[i % area_count];
196            let report_title = format!("{} — Internal Audit Review", area);
197
198            // Dates within the engagement period.
199            let fieldwork_days = (engagement.fieldwork_end - engagement.fieldwork_start)
200                .num_days()
201                .max(1);
202            let offset = self.rng.random_range(0_i64..fieldwork_days);
203            let report_date = engagement.fieldwork_start + Duration::days(offset);
204
205            // Period covered by the IA review: the engagement fiscal year.
206            let period_start =
207                NaiveDate::from_ymd_opt(engagement.fiscal_year as i32, 1, 1).unwrap_or(report_date);
208            let period_end = engagement.period_end_date;
209
210            let mut report = InternalAuditReport::new(
211                engagement.engagement_id,
212                function.function_id,
213                &report_title,
214                area,
215                report_date,
216                period_start,
217                period_end,
218            );
219            // Override UUID for determinism.
220            let report_id = rng_uuid(&mut self.rng);
221            report.report_id = report_id;
222            report.report_ref = format!("IAR-{}", &report_id.simple().to_string()[..8]);
223
224            // Set scope / methodology text.
225            report.scope_description =
226                format!("Review of {} processes and controls for the period.", area);
227            report.methodology =
228                "Risk-based audit approach with control testing and data analytics.".to_string();
229
230            // Findings and ratings.
231            let findings: u32 = self.rng.random_range(1_u32..=8_u32);
232            let high_risk: u32 = self.rng.random_range(0_u32..=(findings.min(2)));
233            report.findings_count = findings;
234            report.high_risk_findings = high_risk;
235            report.overall_rating = self.pick_report_rating(high_risk, findings);
236            report.status = self.pick_report_status();
237
238            // Generate recommendations.
239            let rec_count = self.rng.random_range(
240                self.config.recommendations_per_report.0..=self.config.recommendations_per_report.1,
241            ) as usize;
242            let mut recommendations = Vec::with_capacity(rec_count);
243            let mut action_plans = Vec::with_capacity(rec_count);
244
245            for _ in 0..rec_count {
246                let priority = self.pick_priority(high_risk);
247                let description = self.recommendation_description(area, priority);
248                let management_response = Some(
249                    "Management accepts recommendation and will implement by target date."
250                        .to_string(),
251                );
252
253                let rec = IaRecommendation {
254                    recommendation_id: rng_uuid(&mut self.rng),
255                    description,
256                    priority,
257                    management_response,
258                };
259
260                // Action plan for each recommendation.
261                let days_to_implement: i64 = match priority {
262                    RecommendationPriority::Critical => self.rng.random_range(30_i64..=60_i64),
263                    RecommendationPriority::High => self.rng.random_range(60_i64..=90_i64),
264                    RecommendationPriority::Medium => self.rng.random_range(90_i64..=180_i64),
265                    RecommendationPriority::Low => self.rng.random_range(180_i64..=365_i64),
266                };
267                let target_date = report_date + Duration::days(days_to_implement);
268                let plan_status = self.pick_action_plan_status();
269
270                let plan = ActionPlan {
271                    plan_id: rng_uuid(&mut self.rng),
272                    recommendation_id: rec.recommendation_id,
273                    description: format!(
274                        "Implement corrective action for: {}",
275                        &rec.description[..rec.description.len().min(60)]
276                    ),
277                    responsible_party: self.responsible_party(),
278                    target_date,
279                    status: plan_status,
280                };
281
282                action_plans.push(plan);
283                recommendations.push(rec);
284            }
285
286            report.recommendations = recommendations;
287            report.management_action_plans = action_plans;
288
289            // External auditor's assessment.
290            report.external_auditor_assessment = Some(match reliance {
291                RelianceExtent::LimitedReliance => IaWorkAssessment::PartiallyReliable,
292                RelianceExtent::SignificantReliance => IaWorkAssessment::Reliable,
293                RelianceExtent::FullReliance => IaWorkAssessment::Reliable,
294                RelianceExtent::NoReliance => IaWorkAssessment::Unreliable,
295            });
296
297            // Populate reliance areas on the function from report audit areas.
298            if !function.reliance_areas.contains(&area.to_string()) {
299                function.reliance_areas.push(area.to_string());
300            }
301
302            reports.push(report);
303        }
304
305        (function, reports)
306    }
307
308    // -------------------------------------------------------------------------
309    // Private helpers
310    // -------------------------------------------------------------------------
311
312    fn pick_reporting_line(&mut self) -> ReportingLine {
313        // AuditCommittee 60%, Board 15%, CFO 15%, CEO 10%.
314        let roll: f64 = self.rng.random();
315        if roll < 0.60 {
316            ReportingLine::AuditCommittee
317        } else if roll < 0.75 {
318            ReportingLine::Board
319        } else if roll < 0.90 {
320            ReportingLine::CFO
321        } else {
322            ReportingLine::CEO
323        }
324    }
325
326    fn head_of_ia_name(&mut self) -> String {
327        let names = [
328            "Sarah Mitchell",
329            "David Chen",
330            "Emma Thompson",
331            "James Rodriguez",
332            "Olivia Patel",
333            "Michael Clarke",
334            "Amira Hassan",
335            "Robert Nielsen",
336            "Priya Sharma",
337            "Thomas Becker",
338        ];
339        let idx = self.rng.random_range(0..names.len());
340        names[idx].to_string()
341    }
342
343    fn ia_qualifications(&mut self, reliance: &RelianceExtent) -> Vec<String> {
344        let all_quals = ["CIA", "CISA", "CPA", "CA", "ACCA", "CRISC"];
345        let count: usize = match reliance {
346            RelianceExtent::NoReliance | RelianceExtent::LimitedReliance => {
347                self.rng.random_range(0_usize..=1_usize)
348            }
349            RelianceExtent::SignificantReliance => self.rng.random_range(1_usize..=2_usize),
350            RelianceExtent::FullReliance => self.rng.random_range(2_usize..=3_usize),
351        };
352        let mut quals = Vec::new();
353        let mut remaining: Vec<&str> = all_quals.to_vec();
354        for _ in 0..count {
355            if remaining.is_empty() {
356                break;
357            }
358            let idx = self.rng.random_range(0..remaining.len());
359            quals.push(remaining.remove(idx).to_string());
360        }
361        quals
362    }
363
364    fn audit_areas(&self) -> Vec<&'static str> {
365        vec![
366            "Revenue Cycle",
367            "IT General Controls",
368            "Procurement & Payables",
369            "Payroll & Human Resources",
370            "Treasury & Cash Management",
371            "Financial Reporting",
372            "Compliance & Regulatory",
373            "Inventory & Supply Chain",
374            "Fixed Assets",
375            "Tax Compliance",
376            "Information Security",
377            "Governance & Risk Management",
378        ]
379    }
380
381    fn pick_report_rating(&mut self, high_risk: u32, findings: u32) -> IaReportRating {
382        if high_risk >= 2 || findings >= 6 {
383            IaReportRating::Unsatisfactory
384        } else if high_risk >= 1 || findings >= 3 {
385            // 70% NeedsImprovement, 30% Unsatisfactory.
386            if self.rng.random::<f64>() < 0.70 {
387                IaReportRating::NeedsImprovement
388            } else {
389                IaReportRating::Unsatisfactory
390            }
391        } else {
392            // Mostly satisfactory.
393            if self.rng.random::<f64>() < 0.80 {
394                IaReportRating::Satisfactory
395            } else {
396                IaReportRating::NeedsImprovement
397            }
398        }
399    }
400
401    fn pick_report_status(&mut self) -> IaReportStatus {
402        let roll: f64 = self.rng.random();
403        if roll < 0.65 {
404            IaReportStatus::Final
405        } else {
406            IaReportStatus::Draft
407        }
408    }
409
410    fn pick_priority(&mut self, high_risk: u32) -> RecommendationPriority {
411        let roll: f64 = self.rng.random();
412        if high_risk >= 1 && roll < 0.15 {
413            RecommendationPriority::Critical
414        } else if roll < 0.30 {
415            RecommendationPriority::High
416        } else if roll < 0.70 {
417            RecommendationPriority::Medium
418        } else {
419            RecommendationPriority::Low
420        }
421    }
422
423    fn pick_action_plan_status(&mut self) -> ActionPlanStatus {
424        let roll: f64 = self.rng.random();
425        if roll < 0.40 {
426            ActionPlanStatus::Open
427        } else if roll < 0.65 {
428            ActionPlanStatus::InProgress
429        } else if roll < 0.85 {
430            ActionPlanStatus::Implemented
431        } else {
432            ActionPlanStatus::Overdue
433        }
434    }
435
436    fn recommendation_description(&self, area: &str, priority: RecommendationPriority) -> String {
437        let suffix = match priority {
438            RecommendationPriority::Critical => {
439                "Immediate remediation required to address critical control failure."
440            }
441            RecommendationPriority::High => {
442                "Strengthen controls to reduce risk exposure within the next quarter."
443            }
444            RecommendationPriority::Medium => {
445                "Enhance monitoring procedures and update process documentation."
446            }
447            RecommendationPriority::Low => {
448                "Implement process improvement to increase efficiency and control effectiveness."
449            }
450        };
451        format!("{}: {}", area, suffix)
452    }
453
454    fn responsible_party(&mut self) -> String {
455        let parties = [
456            "Finance Director",
457            "Head of Compliance",
458            "IT Manager",
459            "Operations Manager",
460            "Controller",
461            "CFO",
462            "Risk Manager",
463            "HR Director",
464        ];
465        let idx = self.rng.random_range(0..parties.len());
466        parties[idx].to_string()
467    }
468}
469
470// =============================================================================
471// Tests
472// =============================================================================
473
474#[cfg(test)]
475#[allow(clippy::unwrap_used)]
476mod tests {
477    use super::*;
478    use crate::audit::test_helpers::create_test_engagement;
479
480    fn make_gen(seed: u64) -> InternalAuditGenerator {
481        InternalAuditGenerator::new(seed)
482    }
483
484    // -------------------------------------------------------------------------
485
486    /// Generator produces an IA function for any engagement.
487    #[test]
488    fn test_generates_ia_function() {
489        let engagement = create_test_engagement();
490        let mut gen = make_gen(42);
491        let (function, _) = gen.generate(&engagement);
492
493        assert_eq!(function.engagement_id, engagement.engagement_id);
494        assert!(!function.head_of_ia.is_empty());
495        assert!(!function.department_name.is_empty());
496        assert!(function.function_ref.starts_with("IAF-"));
497    }
498
499    /// With NoReliance, the reports vec must be empty.
500    #[test]
501    fn test_no_reliance_empty_reports() {
502        let engagement = create_test_engagement();
503        // Force NoReliance by setting the ratio to 1.0.
504        let config = InternalAuditGeneratorConfig {
505            no_reliance_ratio: 1.0,
506            limited_reliance_ratio: 0.0,
507            significant_reliance_ratio: 0.0,
508            full_reliance_ratio: 0.0,
509            ..Default::default()
510        };
511        let mut gen = InternalAuditGenerator::with_config(10, config);
512        let (function, reports) = gen.generate(&engagement);
513
514        assert_eq!(function.reliance_extent, RelianceExtent::NoReliance);
515        assert!(reports.is_empty(), "NoReliance should produce zero reports");
516    }
517
518    /// Report count is within the configured range when reliance > NoReliance.
519    #[test]
520    fn test_reports_within_range() {
521        let engagement = create_test_engagement();
522        // Force full reliance so we always get reports.
523        let config = InternalAuditGeneratorConfig {
524            no_reliance_ratio: 0.0,
525            limited_reliance_ratio: 0.0,
526            significant_reliance_ratio: 0.0,
527            full_reliance_ratio: 1.0,
528            reports_per_function: (2, 5),
529            ..Default::default()
530        };
531        let mut gen = InternalAuditGenerator::with_config(7, config);
532        let (_, reports) = gen.generate(&engagement);
533
534        assert!(
535            reports.len() >= 2 && reports.len() <= 5,
536            "expected 2..=5 reports, got {}",
537            reports.len()
538        );
539    }
540
541    /// Every report has at least one recommendation.
542    #[test]
543    fn test_recommendations_generated() {
544        let engagement = create_test_engagement();
545        let config = InternalAuditGeneratorConfig {
546            no_reliance_ratio: 0.0,
547            limited_reliance_ratio: 1.0,
548            ..Default::default()
549        };
550        let mut gen = InternalAuditGenerator::with_config(55, config);
551        let (_, reports) = gen.generate(&engagement);
552
553        for report in &reports {
554            assert!(
555                !report.recommendations.is_empty(),
556                "report '{}' should have at least one recommendation",
557                report.report_ref
558            );
559            // Each recommendation must have a matching action plan.
560            assert_eq!(
561                report.recommendations.len(),
562                report.management_action_plans.len(),
563                "recommendation/action-plan count mismatch in report '{}'",
564                report.report_ref
565            );
566        }
567    }
568
569    /// Same seed must produce identical output.
570    #[test]
571    fn test_deterministic() {
572        let engagement = create_test_engagement();
573
574        let (func_a, reports_a) = {
575            let mut gen = make_gen(999);
576            gen.generate(&engagement)
577        };
578        let (func_b, reports_b) = {
579            let mut gen = make_gen(999);
580            gen.generate(&engagement)
581        };
582
583        assert_eq!(func_a.reliance_extent, func_b.reliance_extent);
584        assert_eq!(func_a.head_of_ia, func_b.head_of_ia);
585        assert_eq!(func_a.staff_count, func_b.staff_count);
586        assert_eq!(reports_a.len(), reports_b.len());
587        for (a, b) in reports_a.iter().zip(reports_b.iter()) {
588            assert_eq!(a.report_ref, b.report_ref);
589            assert_eq!(a.audit_area, b.audit_area);
590            assert_eq!(a.overall_rating, b.overall_rating);
591            assert_eq!(a.findings_count, b.findings_count);
592        }
593    }
594}