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