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