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)]
399#[allow(clippy::unwrap_used)]
400mod tests {
401    use super::*;
402
403    #[test]
404    fn test_engagement_generation() {
405        let mut generator = AuditEngagementGenerator::new(42);
406        let period_end = NaiveDate::from_ymd_opt(2025, 12, 31).unwrap();
407        let revenue = Decimal::new(100_000_000, 0); // $100M
408
409        let engagement = generator.generate_engagement(
410            "ENTITY001",
411            "Test Company Inc.",
412            2025,
413            period_end,
414            revenue,
415            None,
416        );
417
418        assert_eq!(engagement.fiscal_year, 2025);
419        assert_eq!(engagement.engagement_type, EngagementType::AnnualAudit);
420        assert!(engagement.materiality > Decimal::ZERO);
421        assert!(engagement.performance_materiality <= engagement.materiality);
422        assert!(!engagement.engagement_partner_id.is_empty());
423        assert!(!engagement.team_member_ids.is_empty());
424    }
425
426    #[test]
427    fn test_batch_generation() {
428        let mut generator = AuditEngagementGenerator::new(42);
429
430        let companies = vec![
431            CompanyInfo {
432                entity_id: "ENTITY001".into(),
433                name: "Company A".into(),
434                period_end_date: NaiveDate::from_ymd_opt(2025, 12, 31).unwrap(),
435                total_revenue: Decimal::new(50_000_000, 0),
436                engagement_type: None,
437            },
438            CompanyInfo {
439                entity_id: "ENTITY002".into(),
440                name: "Company B".into(),
441                period_end_date: NaiveDate::from_ymd_opt(2025, 12, 31).unwrap(),
442                total_revenue: Decimal::new(75_000_000, 0),
443                engagement_type: Some(EngagementType::IntegratedAudit),
444            },
445        ];
446
447        let engagements = generator.generate_engagements_batch(&companies, 2025);
448
449        assert_eq!(engagements.len(), 2);
450        assert_eq!(
451            engagements[1].engagement_type,
452            EngagementType::IntegratedAudit
453        );
454    }
455
456    #[test]
457    fn test_phase_advancement() {
458        let mut generator = AuditEngagementGenerator::new(42);
459        let period_end = NaiveDate::from_ymd_opt(2025, 12, 31).unwrap();
460
461        let mut engagement = generator.generate_engagement(
462            "ENTITY001",
463            "Test Company",
464            2025,
465            period_end,
466            Decimal::new(100_000_000, 0),
467            None,
468        );
469
470        // During planning phase (planning starts ~90 days before period_end)
471        // Use a date close to planning_start to ensure we're in planning
472        generator.advance_engagement_phase(&mut engagement, period_end - Duration::days(85));
473        assert_eq!(engagement.current_phase, EngagementPhase::Planning);
474
475        // Between planning and fieldwork should be risk assessment
476        generator.advance_engagement_phase(&mut engagement, period_end - Duration::days(30));
477        assert_eq!(engagement.current_phase, EngagementPhase::RiskAssessment);
478
479        // After fieldwork start
480        generator.advance_engagement_phase(&mut engagement, period_end + Duration::days(10));
481        assert!(matches!(
482            engagement.current_phase,
483            EngagementPhase::ControlTesting | EngagementPhase::SubstantiveTesting
484        ));
485    }
486
487    #[test]
488    fn test_materiality_calculation() {
489        let mut generator = AuditEngagementGenerator::new(42);
490        let revenue = Decimal::new(100_000_000, 0); // $100M
491
492        let engagement = generator.generate_engagement(
493            "ENTITY001",
494            "Test Company",
495            2025,
496            NaiveDate::from_ymd_opt(2025, 12, 31).unwrap(),
497            revenue,
498            None,
499        );
500
501        // Materiality should be between 0.3% and 1% of revenue
502        let min_materiality = revenue * Decimal::try_from(0.003).unwrap();
503        let max_materiality = revenue * Decimal::try_from(0.010).unwrap();
504
505        assert!(engagement.materiality >= min_materiality);
506        assert!(engagement.materiality <= max_materiality);
507
508        // Performance materiality should be 50-75% of materiality
509        assert!(
510            engagement.performance_materiality
511                >= engagement.materiality * Decimal::try_from(0.50).unwrap()
512        );
513        assert!(
514            engagement.performance_materiality
515                <= engagement.materiality * Decimal::try_from(0.75).unwrap()
516        );
517    }
518}