Skip to main content

datasynth_generators/audit/
workpaper_generator.rs

1//! Workpaper generator for audit engagements.
2//!
3//! Generates audit workpapers with appropriate procedures, sampling,
4//! results, and review sign-offs per ISA 230.
5
6use chrono::{Duration, NaiveDate};
7use datasynth_core::utils::seeded_rng;
8use rand::Rng;
9use rand_chacha::ChaCha8Rng;
10
11use datasynth_core::models::audit::{
12    Assertion, AuditEngagement, EngagementPhase, ProcedureType, RiskLevel, SamplingMethod,
13    Workpaper, WorkpaperConclusion, WorkpaperScope, WorkpaperSection, WorkpaperStatus,
14};
15
16/// Configuration for workpaper generation.
17#[derive(Debug, Clone)]
18pub struct WorkpaperGeneratorConfig {
19    /// Number of workpapers per section (min, max)
20    pub workpapers_per_section: (u32, u32),
21    /// Population size range for testing (min, max)
22    pub population_size_range: (u64, u64),
23    /// Sample size as percentage of population (min, max)
24    pub sample_percentage_range: (f64, f64),
25    /// Exception rate range (min, max)
26    pub exception_rate_range: (f64, f64),
27    /// Probability of unsatisfactory conclusion
28    pub unsatisfactory_probability: f64,
29    /// Days between preparation and first review (min, max)
30    pub first_review_delay_range: (u32, u32),
31    /// Days between first and second review (min, max)
32    pub second_review_delay_range: (u32, u32),
33}
34
35impl Default for WorkpaperGeneratorConfig {
36    fn default() -> Self {
37        Self {
38            workpapers_per_section: (3, 10),
39            population_size_range: (100, 10000),
40            sample_percentage_range: (0.01, 0.10),
41            exception_rate_range: (0.0, 0.08),
42            unsatisfactory_probability: 0.05,
43            first_review_delay_range: (1, 5),
44            second_review_delay_range: (1, 3),
45        }
46    }
47}
48
49/// Generator for audit workpapers.
50pub struct WorkpaperGenerator {
51    /// Random number generator
52    rng: ChaCha8Rng,
53    /// Configuration
54    config: WorkpaperGeneratorConfig,
55    /// Counter per section for references
56    section_counters: std::collections::HashMap<WorkpaperSection, u32>,
57}
58
59impl WorkpaperGenerator {
60    /// Create a new generator with the given seed.
61    pub fn new(seed: u64) -> Self {
62        Self {
63            rng: seeded_rng(seed, 0),
64            config: WorkpaperGeneratorConfig::default(),
65            section_counters: std::collections::HashMap::new(),
66        }
67    }
68
69    /// Create a new generator with custom configuration.
70    pub fn with_config(seed: u64, config: WorkpaperGeneratorConfig) -> Self {
71        Self {
72            rng: seeded_rng(seed, 0),
73            config,
74            section_counters: std::collections::HashMap::new(),
75        }
76    }
77
78    /// Generate workpapers for an engagement phase.
79    pub fn generate_workpapers_for_phase(
80        &mut self,
81        engagement: &AuditEngagement,
82        phase: EngagementPhase,
83        phase_date: NaiveDate,
84        team_members: &[String],
85    ) -> Vec<Workpaper> {
86        let section = match phase {
87            EngagementPhase::Planning => WorkpaperSection::Planning,
88            EngagementPhase::RiskAssessment => WorkpaperSection::RiskAssessment,
89            EngagementPhase::ControlTesting => WorkpaperSection::ControlTesting,
90            EngagementPhase::SubstantiveTesting => WorkpaperSection::SubstantiveTesting,
91            EngagementPhase::Completion => WorkpaperSection::Completion,
92            EngagementPhase::Reporting => WorkpaperSection::Reporting,
93        };
94
95        let count = self.rng.random_range(
96            self.config.workpapers_per_section.0..=self.config.workpapers_per_section.1,
97        );
98
99        (0..count)
100            .map(|_| self.generate_workpaper(engagement, section, phase_date, team_members))
101            .collect()
102    }
103
104    /// Generate a single workpaper.
105    pub fn generate_workpaper(
106        &mut self,
107        engagement: &AuditEngagement,
108        section: WorkpaperSection,
109        base_date: NaiveDate,
110        team_members: &[String],
111    ) -> Workpaper {
112        let counter = self.section_counters.entry(section).or_insert(0);
113        *counter += 1;
114
115        let workpaper_ref = format!("{}-{:03}", section.reference_prefix(), counter);
116        let title = self.generate_workpaper_title(section);
117
118        let mut wp = Workpaper::new(engagement.engagement_id, &workpaper_ref, &title, section);
119
120        // Set objective and assertions
121        let (objective, assertions) = self.generate_objective_and_assertions(section);
122        wp = wp.with_objective(&objective, assertions);
123
124        // Set procedure
125        let (procedure, procedure_type) = self.generate_procedure(section);
126        wp = wp.with_procedure(&procedure, procedure_type);
127
128        // Set scope and sampling
129        let (scope, population, sample, method) = self.generate_scope_and_sampling(section);
130        wp = wp.with_scope(scope, population, sample, method);
131
132        // Set results
133        let (summary, exceptions, conclusion) =
134            self.generate_results(sample, &engagement.overall_audit_risk);
135        wp = wp.with_results(&summary, exceptions, conclusion);
136
137        wp.risk_level_addressed = engagement.overall_audit_risk;
138
139        // Set preparer
140        let preparer = self.select_team_member(team_members, "staff");
141        let preparer_name = self.generate_auditor_name();
142        wp = wp.with_preparer(&preparer, &preparer_name, base_date);
143
144        // Add first review
145        let first_review_delay = self.rng.random_range(
146            self.config.first_review_delay_range.0..=self.config.first_review_delay_range.1,
147        );
148        let first_review_date = base_date + Duration::days(first_review_delay as i64);
149        let reviewer = self.select_team_member(team_members, "senior");
150        let reviewer_name = self.generate_auditor_name();
151        wp.add_first_review(&reviewer, &reviewer_name, first_review_date);
152
153        // Maybe add second review
154        if self.rng.random::<f64>() < 0.7 {
155            let second_review_delay = self.rng.random_range(
156                self.config.second_review_delay_range.0..=self.config.second_review_delay_range.1,
157            );
158            let second_review_date = first_review_date + Duration::days(second_review_delay as i64);
159            let second_reviewer = self.select_team_member(team_members, "manager");
160            let second_reviewer_name = self.generate_auditor_name();
161            wp.add_second_review(&second_reviewer, &second_reviewer_name, second_review_date);
162        } else {
163            wp.status = WorkpaperStatus::FirstReviewComplete;
164        }
165
166        // Maybe add review notes
167        if self.rng.random::<f64>() < 0.30 {
168            let note = self.generate_review_note();
169            wp.add_review_note(&reviewer, &note);
170        }
171
172        wp
173    }
174
175    /// Generate workpaper title based on section.
176    fn generate_workpaper_title(&mut self, section: WorkpaperSection) -> String {
177        let titles = match section {
178            WorkpaperSection::Planning => vec![
179                "Engagement Planning Memo",
180                "Understanding the Entity and Environment",
181                "Materiality Assessment",
182                "Preliminary Analytical Procedures",
183                "Risk Assessment Summary",
184                "Audit Strategy and Approach",
185                "Staffing and Resource Plan",
186                "Client Acceptance Procedures",
187            ],
188            WorkpaperSection::RiskAssessment => vec![
189                "Business Risk Assessment",
190                "Fraud Risk Evaluation",
191                "IT General Controls Assessment",
192                "Internal Control Evaluation",
193                "Significant Account Identification",
194                "Risk of Material Misstatement Assessment",
195                "Related Party Risk Assessment",
196                "Going Concern Assessment",
197            ],
198            WorkpaperSection::ControlTesting => vec![
199                "Revenue Recognition Controls Testing",
200                "Purchase and Payables Controls Testing",
201                "Treasury and Cash Controls Testing",
202                "Payroll Controls Testing",
203                "Fixed Asset Controls Testing",
204                "Inventory Controls Testing",
205                "IT Application Controls Testing",
206                "Entity Level Controls Testing",
207            ],
208            WorkpaperSection::SubstantiveTesting => vec![
209                "Revenue Cutoff Testing",
210                "Accounts Receivable Confirmation",
211                "Inventory Observation and Testing",
212                "Fixed Asset Verification",
213                "Accounts Payable Completeness",
214                "Expense Testing",
215                "Debt and Interest Testing",
216                "Bank Reconciliation Review",
217                "Journal Entry Testing",
218                "Analytical Procedures - Revenue",
219                "Analytical Procedures - Expenses",
220            ],
221            WorkpaperSection::Completion => vec![
222                "Subsequent Events Review",
223                "Management Representation Letter",
224                "Attorney Letter Summary",
225                "Going Concern Evaluation",
226                "Summary of Uncorrected Misstatements",
227                "Summary of Audit Differences",
228                "Completion Checklist",
229            ],
230            WorkpaperSection::Reporting => vec![
231                "Draft Financial Statements Review",
232                "Disclosure Checklist",
233                "Communication with Those Charged with Governance",
234                "Report Issuance Checklist",
235            ],
236            WorkpaperSection::PermanentFile => vec![
237                "Chart of Accounts",
238                "Organization Structure",
239                "Key Contracts Summary",
240                "Related Party Identification",
241            ],
242        };
243
244        let idx = self.rng.random_range(0..titles.len());
245        titles[idx].to_string()
246    }
247
248    /// Generate objective and assertions for a section.
249    fn generate_objective_and_assertions(
250        &mut self,
251        section: WorkpaperSection,
252    ) -> (String, Vec<Assertion>) {
253        match section {
254            WorkpaperSection::Planning | WorkpaperSection::RiskAssessment => (
255                "Understand the entity and assess risks of material misstatement".into(),
256                vec![],
257            ),
258            WorkpaperSection::ControlTesting => (
259                "Test the operating effectiveness of key controls".into(),
260                Assertion::transaction_assertions(),
261            ),
262            WorkpaperSection::SubstantiveTesting => {
263                let assertions = if self.rng.random::<f64>() < 0.5 {
264                    Assertion::transaction_assertions()
265                } else {
266                    Assertion::balance_assertions()
267                };
268                (
269                    "Obtain sufficient appropriate audit evidence regarding account balances"
270                        .into(),
271                    assertions,
272                )
273            }
274            WorkpaperSection::Completion => (
275                "Complete all required completion procedures".into(),
276                vec![
277                    Assertion::Completeness,
278                    Assertion::PresentationAndDisclosure,
279                ],
280            ),
281            WorkpaperSection::Reporting => (
282                "Ensure compliance with reporting requirements".into(),
283                vec![Assertion::PresentationAndDisclosure],
284            ),
285            WorkpaperSection::PermanentFile => {
286                ("Maintain permanent file documentation".into(), vec![])
287            }
288        }
289    }
290
291    /// Generate procedure description and type.
292    fn generate_procedure(&mut self, section: WorkpaperSection) -> (String, ProcedureType) {
293        match section {
294            WorkpaperSection::Planning | WorkpaperSection::RiskAssessment => (
295                "Performed inquiries and reviewed documentation".into(),
296                ProcedureType::InquiryObservation,
297            ),
298            WorkpaperSection::ControlTesting => {
299                let procedures = [
300                    (
301                        "Selected a sample of transactions and tested the control operation",
302                        ProcedureType::TestOfControls,
303                    ),
304                    (
305                        "Observed the control being performed by personnel",
306                        ProcedureType::InquiryObservation,
307                    ),
308                    (
309                        "Inspected documentation of control performance",
310                        ProcedureType::Inspection,
311                    ),
312                    (
313                        "Reperformed the control procedure",
314                        ProcedureType::Reperformance,
315                    ),
316                ];
317                let idx = self.rng.random_range(0..procedures.len());
318                (procedures[idx].0.into(), procedures[idx].1)
319            }
320            WorkpaperSection::SubstantiveTesting => {
321                let procedures = [
322                    (
323                        "Selected a sample and agreed details to supporting documentation",
324                        ProcedureType::SubstantiveTest,
325                    ),
326                    (
327                        "Sent confirmations and agreed responses to records",
328                        ProcedureType::Confirmation,
329                    ),
330                    (
331                        "Recalculated amounts and agreed to supporting schedules",
332                        ProcedureType::Recalculation,
333                    ),
334                    (
335                        "Performed analytical procedures and investigated variances",
336                        ProcedureType::AnalyticalProcedures,
337                    ),
338                    (
339                        "Inspected physical assets and documentation",
340                        ProcedureType::Inspection,
341                    ),
342                ];
343                let idx = self.rng.random_range(0..procedures.len());
344                (procedures[idx].0.into(), procedures[idx].1)
345            }
346            WorkpaperSection::Completion | WorkpaperSection::Reporting => (
347                "Reviewed documentation and performed inquiries".into(),
348                ProcedureType::InquiryObservation,
349            ),
350            WorkpaperSection::PermanentFile => (
351                "Compiled and organized permanent file documentation".into(),
352                ProcedureType::Inspection,
353            ),
354        }
355    }
356
357    /// Generate scope and sampling details.
358    fn generate_scope_and_sampling(
359        &mut self,
360        section: WorkpaperSection,
361    ) -> (WorkpaperScope, u64, u32, SamplingMethod) {
362        let scope = WorkpaperScope {
363            coverage_percentage: self.rng.random_range(70.0..100.0),
364            period_start: None,
365            period_end: None,
366            limitations: Vec::new(),
367        };
368
369        match section {
370            WorkpaperSection::ControlTesting | WorkpaperSection::SubstantiveTesting => {
371                let population = self.rng.random_range(
372                    self.config.population_size_range.0..=self.config.population_size_range.1,
373                );
374                let sample_pct = self.rng.random_range(
375                    self.config.sample_percentage_range.0..=self.config.sample_percentage_range.1,
376                );
377                let sample = ((population as f64 * sample_pct).max(25.0) as u32).min(200);
378
379                let method = if self.rng.random::<f64>() < 0.4 {
380                    SamplingMethod::StatisticalRandom
381                } else if self.rng.random::<f64>() < 0.3 {
382                    SamplingMethod::MonetaryUnit
383                } else {
384                    SamplingMethod::Judgmental
385                };
386
387                (scope, population, sample, method)
388            }
389            _ => (scope, 0, 0, SamplingMethod::Judgmental),
390        }
391    }
392
393    /// Generate test results.
394    fn generate_results(
395        &mut self,
396        sample_size: u32,
397        risk_level: &RiskLevel,
398    ) -> (String, u32, WorkpaperConclusion) {
399        if sample_size == 0 {
400            return (
401                "Procedures completed without exception".into(),
402                0,
403                WorkpaperConclusion::Satisfactory,
404            );
405        }
406
407        // Higher risk = higher chance of exceptions
408        let exception_probability = match risk_level {
409            RiskLevel::Low => 0.10,
410            RiskLevel::Medium => 0.25,
411            RiskLevel::High | RiskLevel::Significant => 0.40,
412        };
413
414        let has_exceptions = self.rng.random::<f64>() < exception_probability;
415
416        let (exceptions, conclusion) = if has_exceptions {
417            let exception_rate = self.rng.random_range(
418                self.config.exception_rate_range.0..=self.config.exception_rate_range.1,
419            );
420            let exceptions =
421                ((sample_size as f64 * exception_rate).max(1.0) as u32).min(sample_size);
422
423            let conclusion = if self.rng.random::<f64>() < self.config.unsatisfactory_probability {
424                WorkpaperConclusion::Unsatisfactory
425            } else {
426                WorkpaperConclusion::SatisfactoryWithExceptions
427            };
428
429            (exceptions, conclusion)
430        } else {
431            (0, WorkpaperConclusion::Satisfactory)
432        };
433
434        let summary = match conclusion {
435            WorkpaperConclusion::Satisfactory => {
436                format!("Tested {sample_size} items with no exceptions noted")
437            }
438            WorkpaperConclusion::SatisfactoryWithExceptions => {
439                format!(
440                    "Tested {sample_size} items with {exceptions} exceptions noted. Exceptions were immaterial and have been evaluated"
441                )
442            }
443            WorkpaperConclusion::Unsatisfactory => {
444                format!(
445                    "Tested {sample_size} items with {exceptions} exceptions noted. Exceptions represent material misstatement requiring adjustment"
446                )
447            }
448            _ => format!("Tested {sample_size} items"),
449        };
450
451        (summary, exceptions, conclusion)
452    }
453
454    /// Select a team member based on role prefix.
455    fn select_team_member(&mut self, team_members: &[String], role_hint: &str) -> String {
456        let matching: Vec<&String> = team_members
457            .iter()
458            .filter(|m| m.to_lowercase().contains(role_hint))
459            .collect();
460
461        if let Some(&member) = matching.first() {
462            member.clone()
463        } else if !team_members.is_empty() {
464            let idx = self.rng.random_range(0..team_members.len());
465            team_members[idx].clone()
466        } else {
467            format!("{}001", role_hint.to_uppercase())
468        }
469    }
470
471    /// Generate a plausible auditor name.
472    fn generate_auditor_name(&mut self) -> String {
473        let first_names = [
474            "Michael", "Sarah", "David", "Jennifer", "Robert", "Emily", "James", "Amanda",
475        ];
476        let last_names = ["Smith", "Johnson", "Williams", "Brown", "Jones", "Davis"];
477
478        let first_idx = self.rng.random_range(0..first_names.len());
479        let last_idx = self.rng.random_range(0..last_names.len());
480
481        format!("{} {}", first_names[first_idx], last_names[last_idx])
482    }
483
484    /// Generate a review note comment.
485    fn generate_review_note(&mut self) -> String {
486        let notes = [
487            "Please expand on the rationale for sample selection",
488            "Cross-reference needed to risk assessment workpaper",
489            "Please document conclusion more clearly",
490            "Need to include population definition",
491            "Please add reference to prior year workpaper",
492            "Document discussion with management regarding exceptions",
493            "Clarify testing approach for this control",
494            "Add evidence reference for supporting documentation",
495        ];
496
497        let idx = self.rng.random_range(0..notes.len());
498        notes[idx].to_string()
499    }
500
501    /// Generate all workpapers for a complete engagement.
502    pub fn generate_complete_workpaper_set(
503        &mut self,
504        engagement: &AuditEngagement,
505        team_members: &[String],
506    ) -> Vec<Workpaper> {
507        let mut all_workpapers = Vec::new();
508
509        // Planning workpapers
510        all_workpapers.extend(self.generate_workpapers_for_phase(
511            engagement,
512            EngagementPhase::Planning,
513            engagement.planning_start,
514            team_members,
515        ));
516
517        // Risk assessment workpapers
518        all_workpapers.extend(self.generate_workpapers_for_phase(
519            engagement,
520            EngagementPhase::RiskAssessment,
521            engagement.planning_end,
522            team_members,
523        ));
524
525        // Control testing workpapers
526        all_workpapers.extend(self.generate_workpapers_for_phase(
527            engagement,
528            EngagementPhase::ControlTesting,
529            engagement.fieldwork_start,
530            team_members,
531        ));
532
533        // Substantive testing workpapers
534        all_workpapers.extend(self.generate_workpapers_for_phase(
535            engagement,
536            EngagementPhase::SubstantiveTesting,
537            engagement.fieldwork_start + Duration::days(14),
538            team_members,
539        ));
540
541        // Completion workpapers
542        all_workpapers.extend(self.generate_workpapers_for_phase(
543            engagement,
544            EngagementPhase::Completion,
545            engagement.completion_start,
546            team_members,
547        ));
548
549        // Reporting workpapers
550        all_workpapers.extend(self.generate_workpapers_for_phase(
551            engagement,
552            EngagementPhase::Reporting,
553            engagement.report_date - Duration::days(7),
554            team_members,
555        ));
556
557        all_workpapers
558    }
559}
560
561#[cfg(test)]
562#[allow(clippy::unwrap_used)]
563mod tests {
564    use super::*;
565    use crate::audit::test_helpers::create_test_engagement;
566
567    #[test]
568    fn test_workpaper_generation() {
569        let mut generator = WorkpaperGenerator::new(42);
570        let engagement = create_test_engagement();
571        let team = vec!["STAFF001".into(), "SENIOR001".into(), "MANAGER001".into()];
572
573        let wp = generator.generate_workpaper(
574            &engagement,
575            WorkpaperSection::SubstantiveTesting,
576            NaiveDate::from_ymd_opt(2026, 1, 15).unwrap(),
577            &team,
578        );
579
580        assert!(!wp.workpaper_ref.is_empty());
581        assert!(!wp.title.is_empty());
582        assert!(!wp.preparer_id.is_empty());
583    }
584
585    #[test]
586    fn test_phase_workpapers() {
587        let mut generator = WorkpaperGenerator::new(42);
588        let engagement = create_test_engagement();
589        let team = vec!["STAFF001".into(), "SENIOR001".into()];
590
591        let workpapers = generator.generate_workpapers_for_phase(
592            &engagement,
593            EngagementPhase::ControlTesting,
594            NaiveDate::from_ymd_opt(2026, 1, 15).unwrap(),
595            &team,
596        );
597
598        assert!(!workpapers.is_empty());
599        for wp in &workpapers {
600            assert_eq!(wp.section, WorkpaperSection::ControlTesting);
601        }
602    }
603
604    #[test]
605    fn test_complete_workpaper_set() {
606        let mut generator = WorkpaperGenerator::new(42);
607        let engagement = create_test_engagement();
608        let team = vec![
609            "STAFF001".into(),
610            "STAFF002".into(),
611            "SENIOR001".into(),
612            "MANAGER001".into(),
613        ];
614
615        let workpapers = generator.generate_complete_workpaper_set(&engagement, &team);
616
617        // Should have workpapers from all phases
618        assert!(workpapers.len() >= 18); // At least 3 per 6 phases
619
620        // Check we have workpapers from different sections
621        let sections: std::collections::HashSet<_> = workpapers.iter().map(|w| w.section).collect();
622        assert!(sections.len() >= 5);
623    }
624
625    #[test]
626    fn test_workpaper_review_chain() {
627        let mut generator = WorkpaperGenerator::new(42);
628        let engagement = create_test_engagement();
629        let team = vec!["STAFF001".into(), "SENIOR001".into(), "MANAGER001".into()];
630
631        let wp = generator.generate_workpaper(
632            &engagement,
633            WorkpaperSection::SubstantiveTesting,
634            NaiveDate::from_ymd_opt(2026, 1, 15).unwrap(),
635            &team,
636        );
637
638        // Should have preparer
639        assert!(!wp.preparer_id.is_empty());
640
641        // Should have first reviewer
642        assert!(wp.reviewer_id.is_some());
643
644        // First review date should be after preparer date
645        assert!(wp.reviewer_date.unwrap() >= wp.preparer_date);
646    }
647}