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 {} items with no exceptions noted", sample_size)
437            }
438            WorkpaperConclusion::SatisfactoryWithExceptions => {
439                format!(
440                    "Tested {} items with {} exceptions noted. Exceptions were immaterial and have been evaluated",
441                    sample_size, exceptions
442                )
443            }
444            WorkpaperConclusion::Unsatisfactory => {
445                format!(
446                    "Tested {} items with {} exceptions noted. Exceptions represent material misstatement requiring adjustment",
447                    sample_size, exceptions
448                )
449            }
450            _ => format!("Tested {} items", sample_size),
451        };
452
453        (summary, exceptions, conclusion)
454    }
455
456    /// Select a team member based on role prefix.
457    fn select_team_member(&mut self, team_members: &[String], role_hint: &str) -> String {
458        let matching: Vec<&String> = team_members
459            .iter()
460            .filter(|m| m.to_lowercase().contains(role_hint))
461            .collect();
462
463        if let Some(&member) = matching.first() {
464            member.clone()
465        } else if !team_members.is_empty() {
466            let idx = self.rng.random_range(0..team_members.len());
467            team_members[idx].clone()
468        } else {
469            format!("{}001", role_hint.to_uppercase())
470        }
471    }
472
473    /// Generate a plausible auditor name.
474    fn generate_auditor_name(&mut self) -> String {
475        let first_names = [
476            "Michael", "Sarah", "David", "Jennifer", "Robert", "Emily", "James", "Amanda",
477        ];
478        let last_names = ["Smith", "Johnson", "Williams", "Brown", "Jones", "Davis"];
479
480        let first_idx = self.rng.random_range(0..first_names.len());
481        let last_idx = self.rng.random_range(0..last_names.len());
482
483        format!("{} {}", first_names[first_idx], last_names[last_idx])
484    }
485
486    /// Generate a review note comment.
487    fn generate_review_note(&mut self) -> String {
488        let notes = [
489            "Please expand on the rationale for sample selection",
490            "Cross-reference needed to risk assessment workpaper",
491            "Please document conclusion more clearly",
492            "Need to include population definition",
493            "Please add reference to prior year workpaper",
494            "Document discussion with management regarding exceptions",
495            "Clarify testing approach for this control",
496            "Add evidence reference for supporting documentation",
497        ];
498
499        let idx = self.rng.random_range(0..notes.len());
500        notes[idx].to_string()
501    }
502
503    /// Generate all workpapers for a complete engagement.
504    pub fn generate_complete_workpaper_set(
505        &mut self,
506        engagement: &AuditEngagement,
507        team_members: &[String],
508    ) -> Vec<Workpaper> {
509        let mut all_workpapers = Vec::new();
510
511        // Planning workpapers
512        all_workpapers.extend(self.generate_workpapers_for_phase(
513            engagement,
514            EngagementPhase::Planning,
515            engagement.planning_start,
516            team_members,
517        ));
518
519        // Risk assessment workpapers
520        all_workpapers.extend(self.generate_workpapers_for_phase(
521            engagement,
522            EngagementPhase::RiskAssessment,
523            engagement.planning_end,
524            team_members,
525        ));
526
527        // Control testing workpapers
528        all_workpapers.extend(self.generate_workpapers_for_phase(
529            engagement,
530            EngagementPhase::ControlTesting,
531            engagement.fieldwork_start,
532            team_members,
533        ));
534
535        // Substantive testing workpapers
536        all_workpapers.extend(self.generate_workpapers_for_phase(
537            engagement,
538            EngagementPhase::SubstantiveTesting,
539            engagement.fieldwork_start + Duration::days(14),
540            team_members,
541        ));
542
543        // Completion workpapers
544        all_workpapers.extend(self.generate_workpapers_for_phase(
545            engagement,
546            EngagementPhase::Completion,
547            engagement.completion_start,
548            team_members,
549        ));
550
551        // Reporting workpapers
552        all_workpapers.extend(self.generate_workpapers_for_phase(
553            engagement,
554            EngagementPhase::Reporting,
555            engagement.report_date - Duration::days(7),
556            team_members,
557        ));
558
559        all_workpapers
560    }
561}
562
563#[cfg(test)]
564#[allow(clippy::unwrap_used)]
565mod tests {
566    use super::*;
567    use crate::audit::test_helpers::create_test_engagement;
568
569    #[test]
570    fn test_workpaper_generation() {
571        let mut generator = WorkpaperGenerator::new(42);
572        let engagement = create_test_engagement();
573        let team = vec!["STAFF001".into(), "SENIOR001".into(), "MANAGER001".into()];
574
575        let wp = generator.generate_workpaper(
576            &engagement,
577            WorkpaperSection::SubstantiveTesting,
578            NaiveDate::from_ymd_opt(2026, 1, 15).unwrap(),
579            &team,
580        );
581
582        assert!(!wp.workpaper_ref.is_empty());
583        assert!(!wp.title.is_empty());
584        assert!(!wp.preparer_id.is_empty());
585    }
586
587    #[test]
588    fn test_phase_workpapers() {
589        let mut generator = WorkpaperGenerator::new(42);
590        let engagement = create_test_engagement();
591        let team = vec!["STAFF001".into(), "SENIOR001".into()];
592
593        let workpapers = generator.generate_workpapers_for_phase(
594            &engagement,
595            EngagementPhase::ControlTesting,
596            NaiveDate::from_ymd_opt(2026, 1, 15).unwrap(),
597            &team,
598        );
599
600        assert!(!workpapers.is_empty());
601        for wp in &workpapers {
602            assert_eq!(wp.section, WorkpaperSection::ControlTesting);
603        }
604    }
605
606    #[test]
607    fn test_complete_workpaper_set() {
608        let mut generator = WorkpaperGenerator::new(42);
609        let engagement = create_test_engagement();
610        let team = vec![
611            "STAFF001".into(),
612            "STAFF002".into(),
613            "SENIOR001".into(),
614            "MANAGER001".into(),
615        ];
616
617        let workpapers = generator.generate_complete_workpaper_set(&engagement, &team);
618
619        // Should have workpapers from all phases
620        assert!(workpapers.len() >= 18); // At least 3 per 6 phases
621
622        // Check we have workpapers from different sections
623        let sections: std::collections::HashSet<_> = workpapers.iter().map(|w| w.section).collect();
624        assert!(sections.len() >= 5);
625    }
626
627    #[test]
628    fn test_workpaper_review_chain() {
629        let mut generator = WorkpaperGenerator::new(42);
630        let engagement = create_test_engagement();
631        let team = vec!["STAFF001".into(), "SENIOR001".into(), "MANAGER001".into()];
632
633        let wp = generator.generate_workpaper(
634            &engagement,
635            WorkpaperSection::SubstantiveTesting,
636            NaiveDate::from_ymd_opt(2026, 1, 15).unwrap(),
637            &team,
638        );
639
640        // Should have preparer
641        assert!(!wp.preparer_id.is_empty());
642
643        // Should have first reviewer
644        assert!(wp.reviewer_id.is_some());
645
646        // First review date should be after preparer date
647        assert!(wp.reviewer_date.unwrap() >= wp.preparer_date);
648    }
649}