Skip to main content

datasynth_generators/audit/
engagement_generator.rs

1//! Audit engagement generator.
2//!
3//! Generates complete audit engagements including risk assessments,
4//! workpapers, evidence, findings, and professional judgments.
5
6use chrono::{Duration, NaiveDate};
7use datasynth_core::utils::seeded_rng;
8use rand::RngExt;
9use rand_chacha::ChaCha8Rng;
10use rust_decimal::Decimal;
11
12use datasynth_core::models::audit::{
13    AuditEngagement, EngagementPhase, EngagementStatus, EngagementType, RiskLevel,
14};
15
16/// Configuration for audit engagement generation.
17#[derive(Debug, Clone)]
18pub struct AuditEngagementConfig {
19    /// Default engagement type
20    pub default_engagement_type: EngagementType,
21    /// Materiality percentage range (min, max)
22    pub materiality_percentage_range: (f64, f64),
23    /// Performance materiality factor (e.g., 0.50-0.75)
24    pub performance_materiality_factor_range: (f64, f64),
25    /// Clearly trivial factor (e.g., 0.03-0.05)
26    pub clearly_trivial_factor_range: (f64, f64),
27    /// Planning phase duration in days (min, max)
28    pub planning_duration_range: (u32, u32),
29    /// Fieldwork phase duration in days (min, max)
30    pub fieldwork_duration_range: (u32, u32),
31    /// Completion phase duration in days (min, max)
32    pub completion_duration_range: (u32, u32),
33    /// Team size range (min, max)
34    pub team_size_range: (u32, u32),
35    /// Probability of high fraud risk
36    pub high_fraud_risk_probability: f64,
37    /// Probability of significant risks
38    pub significant_risk_probability: f64,
39}
40
41impl Default for AuditEngagementConfig {
42    fn default() -> Self {
43        Self {
44            default_engagement_type: EngagementType::AnnualAudit,
45            materiality_percentage_range: (0.003, 0.010), // 0.3% to 1% of base
46            performance_materiality_factor_range: (0.50, 0.75),
47            clearly_trivial_factor_range: (0.03, 0.05),
48            planning_duration_range: (14, 30),
49            fieldwork_duration_range: (30, 60),
50            completion_duration_range: (14, 21),
51            team_size_range: (3, 8),
52            high_fraud_risk_probability: 0.15,
53            significant_risk_probability: 0.30,
54        }
55    }
56}
57
58/// Generator for audit engagements and related data.
59pub struct AuditEngagementGenerator {
60    /// Random number generator
61    rng: ChaCha8Rng,
62    /// Configuration
63    config: AuditEngagementConfig,
64    /// Counter for engagement references
65    engagement_counter: u32,
66}
67
68impl AuditEngagementGenerator {
69    /// Create a new generator with the given seed.
70    pub fn new(seed: u64) -> Self {
71        Self {
72            rng: seeded_rng(seed, 0),
73            config: AuditEngagementConfig::default(),
74            engagement_counter: 0,
75        }
76    }
77
78    /// Create a new generator with custom configuration.
79    pub fn with_config(seed: u64, config: AuditEngagementConfig) -> Self {
80        Self {
81            rng: seeded_rng(seed, 0),
82            config,
83            engagement_counter: 0,
84        }
85    }
86
87    /// v3.3.2: override internal config with the user-facing audit
88    /// schema configuration.
89    ///
90    /// Maps `AuditTeamConfig.{min_team_size, max_team_size}` onto the
91    /// generator's `team_size_range`. The `specialist_probability` is
92    /// surfaced via `specialist_probability_override` so downstream
93    /// team-composition code can honour it without a full refactor.
94    pub fn set_team_config(&mut self, team_config: &datasynth_config::AuditTeamConfig) {
95        self.config.team_size_range = (
96            team_config.min_team_size as u32,
97            team_config.max_team_size as u32,
98        );
99        // specialist_probability is currently not surfaced to the
100        // generator — reserved for a future v3.4 refactor that
101        // introduces specialist roles. Kept here as a no-op assignment
102        // for completeness so we can see the config wiring line-count.
103        let _ = team_config.specialist_probability;
104    }
105
106    /// v3.3.2: draw an engagement type honoring the user-configured
107    /// distribution from `AuditEngagementTypesConfig`.
108    ///
109    /// Maps the 5 schema probabilities onto the 5 `EngagementType`
110    /// variants the model exposes (AnnualAudit / IntegratedAudit /
111    /// ReviewEngagement / AgreedUponProcedures, with SOX ICFR rolled
112    /// into IntegratedAudit since ASC 404 + financial-statement audit
113    /// is always run as integrated). The rolled-in SOX probability is
114    /// summed with `integrated`; `financial_statement` maps to
115    /// `AnnualAudit`.
116    pub fn draw_engagement_type(
117        &mut self,
118        types: &datasynth_config::AuditEngagementTypesConfig,
119    ) -> EngagementType {
120        let roll: f64 = self.rng.random();
121        let mut acc = types.financial_statement;
122        if roll < acc {
123            return EngagementType::AnnualAudit;
124        }
125        acc += types.sox_icfr + types.integrated;
126        if roll < acc {
127            return EngagementType::IntegratedAudit;
128        }
129        acc += types.review;
130        if roll < acc {
131            return EngagementType::ReviewEngagement;
132        }
133        EngagementType::AgreedUponProcedures
134    }
135
136    /// Generate an audit engagement for a company.
137    pub fn generate_engagement(
138        &mut self,
139        client_entity_id: &str,
140        client_name: &str,
141        fiscal_year: u16,
142        period_end_date: NaiveDate,
143        total_revenue: Decimal,
144        engagement_type: Option<EngagementType>,
145    ) -> AuditEngagement {
146        self.engagement_counter += 1;
147
148        let eng_type = engagement_type.unwrap_or(self.config.default_engagement_type);
149
150        // Calculate materiality
151        let materiality_pct = self.rng.random_range(
152            self.config.materiality_percentage_range.0..=self.config.materiality_percentage_range.1,
153        );
154        let materiality = total_revenue * Decimal::try_from(materiality_pct).unwrap_or_default();
155
156        let perf_mat_factor = self.rng.random_range(
157            self.config.performance_materiality_factor_range.0
158                ..=self.config.performance_materiality_factor_range.1,
159        );
160
161        let trivial_factor = self.rng.random_range(
162            self.config.clearly_trivial_factor_range.0..=self.config.clearly_trivial_factor_range.1,
163        );
164
165        // Generate timeline
166        let timeline = self.generate_timeline(period_end_date);
167
168        // Generate team
169        let (partner_id, partner_name, manager_id, manager_name, team_members) =
170            self.generate_team();
171
172        // Determine risk levels
173        let (overall_risk, fraud_risk, significant_count) = self.generate_risk_profile();
174
175        let mut engagement = AuditEngagement::new(
176            client_entity_id,
177            client_name,
178            eng_type,
179            fiscal_year,
180            period_end_date,
181        );
182
183        engagement.engagement_ref = format!("AUD-{}-{:04}", fiscal_year, self.engagement_counter);
184
185        engagement = engagement.with_materiality(
186            materiality,
187            perf_mat_factor,
188            trivial_factor,
189            "Total Revenue",
190            materiality_pct,
191        );
192
193        engagement = engagement.with_timeline(
194            timeline.planning_start,
195            timeline.planning_end,
196            timeline.fieldwork_start,
197            timeline.fieldwork_end,
198            timeline.completion_start,
199            timeline.report_date,
200        );
201
202        engagement = engagement.with_team(
203            &partner_id,
204            &partner_name,
205            &manager_id,
206            &manager_name,
207            team_members,
208        );
209
210        engagement.overall_audit_risk = overall_risk;
211        engagement.fraud_risk_level = fraud_risk;
212        engagement.significant_risk_count = significant_count;
213
214        // Set initial status
215        engagement.status = EngagementStatus::Planning;
216        engagement.current_phase = EngagementPhase::Planning;
217
218        engagement
219    }
220
221    /// Generate an engagement timeline based on period end date.
222    fn generate_timeline(&mut self, period_end_date: NaiveDate) -> EngagementTimeline {
223        // Planning typically starts 3-4 months before year end
224        let planning_duration = self.rng.random_range(
225            self.config.planning_duration_range.0..=self.config.planning_duration_range.1,
226        );
227        let fieldwork_duration = self.rng.random_range(
228            self.config.fieldwork_duration_range.0..=self.config.fieldwork_duration_range.1,
229        );
230        let completion_duration = self.rng.random_range(
231            self.config.completion_duration_range.0..=self.config.completion_duration_range.1,
232        );
233
234        // Planning starts ~90 days before period end
235        let planning_start = period_end_date - Duration::days(90);
236        let planning_end = planning_start + Duration::days(planning_duration as i64);
237
238        // Fieldwork starts after period end
239        let fieldwork_start = period_end_date + Duration::days(5);
240        let fieldwork_end = fieldwork_start + Duration::days(fieldwork_duration as i64);
241
242        // Completion follows fieldwork
243        let completion_start = fieldwork_end + Duration::days(1);
244        let report_date = completion_start + Duration::days(completion_duration as i64);
245
246        EngagementTimeline {
247            planning_start,
248            planning_end,
249            fieldwork_start,
250            fieldwork_end,
251            completion_start,
252            report_date,
253        }
254    }
255
256    /// Generate engagement team.
257    fn generate_team(&mut self) -> (String, String, String, String, Vec<String>) {
258        let team_size = self
259            .rng
260            .random_range(self.config.team_size_range.0..=self.config.team_size_range.1)
261            as usize;
262
263        // Partner
264        let partner_num = self.rng.random_range(1..=20);
265        let partner_id = format!("PARTNER{partner_num:03}");
266        let partner_name = self.generate_auditor_name(partner_num);
267
268        // Manager
269        let manager_num = self.rng.random_range(1..=50);
270        let manager_id = format!("MANAGER{manager_num:03}");
271        let manager_name = self.generate_auditor_name(manager_num + 100);
272
273        // Team members (seniors and staff)
274        let mut team_members = Vec::with_capacity(team_size);
275        for i in 0..team_size {
276            let member_num = self.rng.random_range(1..=200);
277            if i < team_size / 2 {
278                team_members.push(format!("SENIOR{member_num:03}"));
279            } else {
280                team_members.push(format!("STAFF{member_num:03}"));
281            }
282        }
283
284        (
285            partner_id,
286            partner_name,
287            manager_id,
288            manager_name,
289            team_members,
290        )
291    }
292
293    /// Generate a plausible auditor name.
294    fn generate_auditor_name(&mut self, seed: u32) -> String {
295        let first_names = [
296            "Michael",
297            "Sarah",
298            "David",
299            "Jennifer",
300            "Robert",
301            "Emily",
302            "James",
303            "Amanda",
304            "William",
305            "Jessica",
306            "John",
307            "Ashley",
308            "Daniel",
309            "Nicole",
310            "Christopher",
311            "Michelle",
312        ];
313        let last_names = [
314            "Smith", "Johnson", "Williams", "Brown", "Jones", "Davis", "Miller", "Wilson", "Moore",
315            "Taylor", "Anderson", "Thomas", "Jackson", "White", "Harris", "Martin",
316        ];
317
318        let first_idx = (seed as usize) % first_names.len();
319        let last_idx = ((seed as usize) / first_names.len()) % last_names.len();
320
321        format!("{} {}", first_names[first_idx], last_names[last_idx])
322    }
323
324    /// Generate risk profile for the engagement.
325    fn generate_risk_profile(&mut self) -> (RiskLevel, RiskLevel, u32) {
326        // Determine fraud risk
327        let fraud_risk = if self.rng.random::<f64>() < self.config.high_fraud_risk_probability {
328            RiskLevel::High
329        } else if self.rng.random::<f64>() < 0.40 {
330            RiskLevel::Medium
331        } else {
332            RiskLevel::Low
333        };
334
335        // Determine significant risk count (typically 2-8)
336        let significant_count =
337            if self.rng.random::<f64>() < self.config.significant_risk_probability {
338                self.rng.random_range(2..=8)
339            } else {
340                self.rng.random_range(0..=2)
341            };
342
343        // Overall risk is influenced by both
344        let overall_risk = if fraud_risk == RiskLevel::High || significant_count > 5 {
345            RiskLevel::High
346        } else if fraud_risk == RiskLevel::Medium || significant_count > 2 {
347            RiskLevel::Medium
348        } else {
349            RiskLevel::Low
350        };
351
352        (overall_risk, fraud_risk, significant_count)
353    }
354
355    /// Generate multiple engagements for a batch of companies.
356    pub fn generate_engagements_batch(
357        &mut self,
358        companies: &[CompanyInfo],
359        fiscal_year: u16,
360    ) -> Vec<AuditEngagement> {
361        companies
362            .iter()
363            .map(|company| {
364                self.generate_engagement(
365                    &company.entity_id,
366                    &company.name,
367                    fiscal_year,
368                    company.period_end_date,
369                    company.total_revenue,
370                    company.engagement_type,
371                )
372            })
373            .collect()
374    }
375
376    /// Advance an engagement to the next phase based on current date.
377    pub fn advance_engagement_phase(
378        &mut self,
379        engagement: &mut AuditEngagement,
380        current_date: NaiveDate,
381    ) {
382        // Determine what phase we should be in based on dates
383        let new_phase = if current_date < engagement.planning_end {
384            EngagementPhase::Planning
385        } else if current_date < engagement.fieldwork_start {
386            EngagementPhase::RiskAssessment
387        } else if current_date < engagement.fieldwork_end {
388            // During fieldwork, alternate between control testing and substantive
389            let days_into_fieldwork = (current_date - engagement.fieldwork_start).num_days();
390            let fieldwork_duration =
391                (engagement.fieldwork_end - engagement.fieldwork_start).num_days();
392
393            if days_into_fieldwork < fieldwork_duration / 3 {
394                EngagementPhase::ControlTesting
395            } else {
396                EngagementPhase::SubstantiveTesting
397            }
398        } else if current_date < engagement.report_date {
399            EngagementPhase::Completion
400        } else {
401            EngagementPhase::Reporting
402        };
403
404        if new_phase != engagement.current_phase {
405            engagement.current_phase = new_phase;
406
407            // Update status based on phase
408            engagement.status = match new_phase {
409                EngagementPhase::Planning | EngagementPhase::RiskAssessment => {
410                    EngagementStatus::Planning
411                }
412                EngagementPhase::ControlTesting | EngagementPhase::SubstantiveTesting => {
413                    EngagementStatus::InProgress
414                }
415                EngagementPhase::Completion => EngagementStatus::UnderReview,
416                EngagementPhase::Reporting => EngagementStatus::PendingSignOff,
417            };
418        }
419    }
420}
421
422/// Timeline for an engagement.
423#[derive(Debug, Clone)]
424struct EngagementTimeline {
425    planning_start: NaiveDate,
426    planning_end: NaiveDate,
427    fieldwork_start: NaiveDate,
428    fieldwork_end: NaiveDate,
429    completion_start: NaiveDate,
430    report_date: NaiveDate,
431}
432
433/// Information about a company for engagement generation.
434#[derive(Debug, Clone)]
435pub struct CompanyInfo {
436    /// Entity ID
437    pub entity_id: String,
438    /// Company name
439    pub name: String,
440    /// Period end date
441    pub period_end_date: NaiveDate,
442    /// Total revenue for materiality calculation
443    pub total_revenue: Decimal,
444    /// Optional specific engagement type
445    pub engagement_type: Option<EngagementType>,
446}
447
448#[cfg(test)]
449#[allow(clippy::unwrap_used)]
450mod tests {
451    use super::*;
452
453    #[test]
454    fn test_engagement_generation() {
455        let mut generator = AuditEngagementGenerator::new(42);
456        let period_end = NaiveDate::from_ymd_opt(2025, 12, 31).unwrap();
457        let revenue = Decimal::new(100_000_000, 0); // $100M
458
459        let engagement = generator.generate_engagement(
460            "ENTITY001",
461            "Test Company Inc.",
462            2025,
463            period_end,
464            revenue,
465            None,
466        );
467
468        assert_eq!(engagement.fiscal_year, 2025);
469        assert_eq!(engagement.engagement_type, EngagementType::AnnualAudit);
470        assert!(engagement.materiality > Decimal::ZERO);
471        assert!(engagement.performance_materiality <= engagement.materiality);
472        assert!(!engagement.engagement_partner_id.is_empty());
473        assert!(!engagement.team_member_ids.is_empty());
474    }
475
476    #[test]
477    fn test_batch_generation() {
478        let mut generator = AuditEngagementGenerator::new(42);
479
480        let companies = vec![
481            CompanyInfo {
482                entity_id: "ENTITY001".into(),
483                name: "Company A".into(),
484                period_end_date: NaiveDate::from_ymd_opt(2025, 12, 31).unwrap(),
485                total_revenue: Decimal::new(50_000_000, 0),
486                engagement_type: None,
487            },
488            CompanyInfo {
489                entity_id: "ENTITY002".into(),
490                name: "Company B".into(),
491                period_end_date: NaiveDate::from_ymd_opt(2025, 12, 31).unwrap(),
492                total_revenue: Decimal::new(75_000_000, 0),
493                engagement_type: Some(EngagementType::IntegratedAudit),
494            },
495        ];
496
497        let engagements = generator.generate_engagements_batch(&companies, 2025);
498
499        assert_eq!(engagements.len(), 2);
500        assert_eq!(
501            engagements[1].engagement_type,
502            EngagementType::IntegratedAudit
503        );
504    }
505
506    #[test]
507    fn test_phase_advancement() {
508        let mut generator = AuditEngagementGenerator::new(42);
509        let period_end = NaiveDate::from_ymd_opt(2025, 12, 31).unwrap();
510
511        let mut engagement = generator.generate_engagement(
512            "ENTITY001",
513            "Test Company",
514            2025,
515            period_end,
516            Decimal::new(100_000_000, 0),
517            None,
518        );
519
520        // During planning phase (planning starts ~90 days before period_end)
521        // Use a date close to planning_start to ensure we're in planning
522        generator.advance_engagement_phase(&mut engagement, period_end - Duration::days(85));
523        assert_eq!(engagement.current_phase, EngagementPhase::Planning);
524
525        // Between planning and fieldwork should be risk assessment
526        generator.advance_engagement_phase(&mut engagement, period_end - Duration::days(30));
527        assert_eq!(engagement.current_phase, EngagementPhase::RiskAssessment);
528
529        // After fieldwork start
530        generator.advance_engagement_phase(&mut engagement, period_end + Duration::days(10));
531        assert!(matches!(
532            engagement.current_phase,
533            EngagementPhase::ControlTesting | EngagementPhase::SubstantiveTesting
534        ));
535    }
536
537    #[test]
538    fn test_materiality_calculation() {
539        let mut generator = AuditEngagementGenerator::new(42);
540        let revenue = Decimal::new(100_000_000, 0); // $100M
541
542        let engagement = generator.generate_engagement(
543            "ENTITY001",
544            "Test Company",
545            2025,
546            NaiveDate::from_ymd_opt(2025, 12, 31).unwrap(),
547            revenue,
548            None,
549        );
550
551        // Materiality should be between 0.3% and 1% of revenue
552        let min_materiality = revenue * Decimal::try_from(0.003).unwrap();
553        let max_materiality = revenue * Decimal::try_from(0.010).unwrap();
554
555        assert!(engagement.materiality >= min_materiality);
556        assert!(engagement.materiality <= max_materiality);
557
558        // Performance materiality should be 50-75% of materiality
559        assert!(
560            engagement.performance_materiality
561                >= engagement.materiality * Decimal::try_from(0.50).unwrap()
562        );
563        assert!(
564            engagement.performance_materiality
565                <= engagement.materiality * Decimal::try_from(0.75).unwrap()
566        );
567    }
568}