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