1use 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#[derive(Debug, Clone)]
17pub struct WorkpaperGeneratorConfig {
18 pub workpapers_per_section: (u32, u32),
20 pub population_size_range: (u64, u64),
22 pub sample_percentage_range: (f64, f64),
24 pub exception_rate_range: (f64, f64),
26 pub unsatisfactory_probability: f64,
28 pub first_review_delay_range: (u32, u32),
30 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
48pub struct WorkpaperGenerator {
50 rng: ChaCha8Rng,
52 config: WorkpaperGeneratorConfig,
54 section_counters: std::collections::HashMap<WorkpaperSection, u32>,
56}
57
58impl WorkpaperGenerator {
59 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 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 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 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 let (objective, assertions) = self.generate_objective_and_assertions(section);
121 wp = wp.with_objective(&objective, assertions);
122
123 let (procedure, procedure_type) = self.generate_procedure(section);
125 wp = wp.with_procedure(&procedure, procedure_type);
126
127 let (scope, population, sample, method) = self.generate_scope_and_sampling(section);
129 wp = wp.with_scope(scope, population, sample, method);
130
131 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 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 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 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 if self.rng.gen::<f64>() < 0.30 {
167 let note = self.generate_review_note();
168 wp.add_review_note(&reviewer, ¬e);
169 }
170
171 wp
172 }
173
174 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 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 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 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 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 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 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 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 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 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 all_workpapers.extend(self.generate_workpapers_for_phase(
512 engagement,
513 EngagementPhase::Planning,
514 engagement.planning_start,
515 team_members,
516 ));
517
518 all_workpapers.extend(self.generate_workpapers_for_phase(
520 engagement,
521 EngagementPhase::RiskAssessment,
522 engagement.planning_end,
523 team_members,
524 ));
525
526 all_workpapers.extend(self.generate_workpapers_for_phase(
528 engagement,
529 EngagementPhase::ControlTesting,
530 engagement.fieldwork_start,
531 team_members,
532 ));
533
534 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 all_workpapers.extend(self.generate_workpapers_for_phase(
544 engagement,
545 EngagementPhase::Completion,
546 engagement.completion_start,
547 team_members,
548 ));
549
550 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 assert!(workpapers.len() >= 18); 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 assert!(!wp.preparer_id.is_empty());
665
666 assert!(wp.reviewer_id.is_some());
668
669 assert!(wp.reviewer_date.unwrap() >= wp.preparer_date);
671 }
672}