Skip to main content

datasynth_generators/audit/
component_audit_generator.rs

1//! ISA 600 Component Audit Generator.
2//!
3//! Generates group audit artefacts following ISA 600 (Special Considerations —
4//! Audits of Group Financial Statements):
5//!
6//! - Component auditor records (one per jurisdiction)
7//! - Group audit plan with materiality allocations
8//! - Component instructions (one per entity)
9//! - Component auditor reports (one per entity, including misstatements)
10
11use std::collections::HashMap;
12
13use chrono::{Duration, NaiveDate};
14use datasynth_config::schema::CompanyConfig;
15use datasynth_core::models::audit::component_audit::{
16    AllocationBasis, CompetenceLevel, ComponentAuditSnapshot, ComponentAuditor,
17    ComponentAuditorReport, ComponentInstruction, ComponentMaterialityAllocation, ComponentScope,
18    GroupAuditPlan, GroupRiskLevel, Misstatement, MisstatementType,
19};
20use datasynth_core::utils::seeded_rng;
21use rand::Rng;
22use rand_chacha::ChaCha8Rng;
23use rust_decimal::Decimal;
24
25/// Generates ISA 600 group audit artefacts.
26pub struct ComponentAuditGenerator {
27    rng: ChaCha8Rng,
28}
29
30impl ComponentAuditGenerator {
31    /// Create a new generator with the given seed.
32    pub fn new(seed: u64) -> Self {
33        Self {
34            rng: seeded_rng(seed, 0x600),
35        }
36    }
37
38    /// Generate a full `ComponentAuditSnapshot` for a group of companies.
39    ///
40    /// # Arguments
41    /// * `companies` – all companies in the configuration
42    /// * `group_materiality` – group-level materiality from the engagement
43    /// * `engagement_id` – ID of the parent audit engagement
44    /// * `period_end` – period end date (component deadlines are derived from this)
45    pub fn generate(
46        &mut self,
47        companies: &[CompanyConfig],
48        group_materiality: Decimal,
49        engagement_id: &str,
50        period_end: NaiveDate,
51    ) -> ComponentAuditSnapshot {
52        if companies.is_empty() {
53            return ComponentAuditSnapshot::default();
54        }
55
56        // ----------------------------------------------------------------
57        // 1. Group companies by country → one auditor per jurisdiction
58        // ----------------------------------------------------------------
59        // Map: country → list of entity codes
60        let mut jurisdiction_map: HashMap<String, Vec<String>> = HashMap::new();
61        for company in companies {
62            jurisdiction_map
63                .entry(company.country.clone())
64                .or_default()
65                .push(company.code.clone());
66        }
67
68        // Build a stable, sorted list of jurisdictions for deterministic output.
69        let mut jurisdictions: Vec<String> = jurisdiction_map.keys().cloned().collect();
70        jurisdictions.sort();
71
72        // ----------------------------------------------------------------
73        // 2. Create one ComponentAuditor per jurisdiction
74        // ----------------------------------------------------------------
75        let mut auditor_id_counter: u32 = 0;
76        // Map: country → auditor_id (for instruction assignment)
77        let mut country_to_auditor_id: HashMap<String, String> = HashMap::new();
78        let mut component_auditors: Vec<ComponentAuditor> = Vec::new();
79
80        for country in &jurisdictions {
81            auditor_id_counter += 1;
82            let auditor_id = format!("CA-{country}-{auditor_id_counter:04}");
83
84            let firm_name = format!("Audit Firm {country}");
85
86            // Competence: 90% satisfactory, 8% requires supervision, 2% unsatisfactory
87            let competence = {
88                let r: f64 = self.rng.random();
89                if r < 0.90 {
90                    CompetenceLevel::Satisfactory
91                } else if r < 0.98 {
92                    CompetenceLevel::RequiresSupervision
93                } else {
94                    CompetenceLevel::Unsatisfactory
95                }
96            };
97
98            let assigned_entities = jurisdiction_map.get(country).cloned().unwrap_or_default();
99
100            country_to_auditor_id.insert(country.clone(), auditor_id.clone());
101
102            component_auditors.push(ComponentAuditor {
103                id: auditor_id,
104                firm_name,
105                jurisdiction: country.clone(),
106                independence_confirmed: self.rng.random::<f64>() > 0.02, // 98% confirmed
107                competence_assessment: competence,
108                assigned_entities,
109            });
110        }
111
112        // ----------------------------------------------------------------
113        // 3. Determine entity weights from config volume_weight field.
114        // ----------------------------------------------------------------
115        // Use each company's volume_weight field. If all weights are equal
116        // (e.g. all 1.0 from defaults), fall back to count-based equal weighting
117        // so that later entities are not artificially penalised.
118        let n = companies.len();
119        let raw_weights: Vec<f64> = companies.iter().map(|c| c.volume_weight).collect();
120        let weights: Vec<f64> = {
121            let all_equal = raw_weights
122                .iter()
123                .all(|&w| (w - raw_weights[0]).abs() < f64::EPSILON);
124            if all_equal {
125                // Equal weighting: every entity has weight 1.0
126                vec![1.0f64; n]
127            } else {
128                raw_weights
129            }
130        };
131        let total_weight: f64 = weights.iter().sum();
132
133        // ----------------------------------------------------------------
134        // 4. Build materiality allocations per entity
135        // ----------------------------------------------------------------
136        let mut component_allocations: Vec<ComponentMaterialityAllocation> = Vec::new();
137        let mut significant_components: Vec<String> = Vec::new();
138        let group_mat_f64 = group_materiality
139            .to_string()
140            .parse::<f64>()
141            .unwrap_or(1_000_000.0);
142
143        for (i, company) in companies.iter().enumerate() {
144            let entity_share = weights[i] / total_weight;
145
146            // component_materiality = group_materiality * entity_share * 0.75
147            let cm_f64 = group_mat_f64 * entity_share * 0.75;
148            let component_materiality =
149                Decimal::from_f64_retain(cm_f64).unwrap_or(Decimal::new(100_000, 2));
150            let clearly_trivial =
151                Decimal::from_f64_retain(cm_f64 * 0.05).unwrap_or(Decimal::new(5_000, 2));
152
153            let allocation_basis = if entity_share >= 0.05 {
154                // Both significant (≥15%) and mid-size (5–15%) entities use
155                // revenue-proportional allocation; small entities use risk-based.
156                AllocationBasis::RevenueProportional
157            } else {
158                AllocationBasis::RiskBased
159            };
160
161            if entity_share >= 0.15 {
162                significant_components.push(company.code.clone());
163            }
164
165            component_allocations.push(ComponentMaterialityAllocation {
166                entity_code: company.code.clone(),
167                component_materiality,
168                clearly_trivial,
169                allocation_basis,
170            });
171        }
172
173        // ----------------------------------------------------------------
174        // 5. Aggregation risk — driven by number of components
175        // ----------------------------------------------------------------
176        let aggregation_risk = if n <= 2 {
177            GroupRiskLevel::Low
178        } else if n <= 5 {
179            GroupRiskLevel::Medium
180        } else {
181            GroupRiskLevel::High
182        };
183
184        // ----------------------------------------------------------------
185        // 6. Build GroupAuditPlan
186        // ----------------------------------------------------------------
187        let consolidation_procedures = vec![
188            "Review intercompany eliminations for completeness".to_string(),
189            "Agree component trial balances to consolidation working papers".to_string(),
190            "Test goodwill impairment at group level".to_string(),
191            "Review consolidation journal entries for unusual items".to_string(),
192            "Assess appropriateness of accounting policies across components".to_string(),
193        ];
194
195        let group_audit_plan = GroupAuditPlan {
196            engagement_id: engagement_id.to_string(),
197            group_materiality,
198            component_allocations: component_allocations.clone(),
199            aggregation_risk,
200            significant_components: significant_components.clone(),
201            consolidation_audit_procedures: consolidation_procedures,
202        };
203
204        // ----------------------------------------------------------------
205        // 7. Build ComponentInstruction per entity
206        // ----------------------------------------------------------------
207        let reporting_deadline = period_end + Duration::days(60);
208        let mut instruction_id_counter: u32 = 0;
209        let mut instructions: Vec<ComponentInstruction> = Vec::new();
210
211        for (i, company) in companies.iter().enumerate() {
212            instruction_id_counter += 1;
213            let entity_share = weights[i] / total_weight;
214            let auditor_id = company_to_auditor_id(&company.country, &country_to_auditor_id);
215
216            let alloc = &component_allocations[i];
217
218            // Scope determined by entity share
219            let scope = if entity_share >= 0.15 {
220                ComponentScope::FullScope
221            } else if entity_share >= 0.05 {
222                ComponentScope::SpecificScope {
223                    account_areas: vec![
224                        "Revenue".to_string(),
225                        "Receivables".to_string(),
226                        "Inventory".to_string(),
227                    ],
228                }
229            } else {
230                ComponentScope::AnalyticalOnly
231            };
232
233            let specific_procedures = self.build_procedures(&scope, company);
234            let areas_of_focus = self.build_areas_of_focus(&scope);
235
236            instructions.push(ComponentInstruction {
237                id: format!("CI-{instruction_id_counter:06}"),
238                component_auditor_id: auditor_id,
239                entity_code: company.code.clone(),
240                scope,
241                materiality_allocated: alloc.component_materiality,
242                reporting_deadline,
243                specific_procedures,
244                areas_of_focus,
245            });
246        }
247
248        // ----------------------------------------------------------------
249        // 8. Build ComponentAuditorReport per entity
250        // ----------------------------------------------------------------
251        let mut report_id_counter: u32 = 0;
252        let mut reports: Vec<ComponentAuditorReport> = Vec::new();
253
254        for (i, company) in companies.iter().enumerate() {
255            report_id_counter += 1;
256            let entity_share = weights[i] / total_weight;
257            let instruction = &instructions[i];
258            let alloc = &component_allocations[i];
259            let auditor_id = company_to_auditor_id(&company.country, &country_to_auditor_id);
260
261            // Number of misstatements: 0-3, proportional to entity size
262            let max_misstatements = if entity_share >= 0.15 {
263                3usize
264            } else if entity_share >= 0.05 {
265                2
266            } else {
267                1
268            };
269            let misstatement_count = self.rng.random_range(0..=max_misstatements);
270
271            let mut misstatements: Vec<Misstatement> = Vec::new();
272            for _ in 0..misstatement_count {
273                misstatements.push(self.generate_misstatement(alloc.component_materiality));
274            }
275
276            // Scope limitations: rare (5% chance)
277            let scope_limitations: Vec<String> = if self.rng.random::<f64>() < 0.05 {
278                vec!["Limited access to subsidiary records for inventory count".to_string()]
279            } else {
280                vec![]
281            };
282
283            // Significant findings
284            let significant_findings: Vec<String> = misstatements
285                .iter()
286                .filter(|m| !m.corrected)
287                .map(|m| {
288                    format!(
289                        "{}: {} {} ({})",
290                        m.account_area,
291                        m.description,
292                        m.amount,
293                        format!("{:?}", m.classification).to_lowercase()
294                    )
295                })
296                .collect();
297
298            let conclusion = if misstatements.iter().all(|m| m.corrected)
299                && scope_limitations.is_empty()
300            {
301                format!(
302                    "No uncorrected misstatements identified in {} that exceed component materiality.",
303                    company.name
304                )
305            } else {
306                format!(
307                    "Uncorrected misstatements or limitations noted in {}. See significant findings.",
308                    company.name
309                )
310            };
311
312            reports.push(ComponentAuditorReport {
313                id: format!("CR-{report_id_counter:06}"),
314                instruction_id: instruction.id.clone(),
315                component_auditor_id: auditor_id,
316                entity_code: company.code.clone(),
317                misstatements_identified: misstatements,
318                scope_limitations,
319                significant_findings,
320                conclusion,
321            });
322        }
323
324        ComponentAuditSnapshot {
325            component_auditors,
326            group_audit_plan: Some(group_audit_plan),
327            component_instructions: instructions,
328            component_reports: reports,
329        }
330    }
331
332    // ----------------------------------------------------------------
333    // Internal helpers
334    // ----------------------------------------------------------------
335
336    fn generate_misstatement(&mut self, component_materiality: Decimal) -> Misstatement {
337        let account_areas = [
338            "Revenue",
339            "Receivables",
340            "Inventory",
341            "Fixed Assets",
342            "Payables",
343            "Accruals",
344            "Provisions",
345        ];
346        let area_idx = self.rng.random_range(0..account_areas.len());
347        let area = account_areas[area_idx].to_string();
348
349        let types = [
350            MisstatementType::Factual,
351            MisstatementType::Judgmental,
352            MisstatementType::Projected,
353        ];
354        let type_idx = self.rng.random_range(0..types.len());
355        let classification = types[type_idx].clone();
356
357        // Amount: 1% – 80% of component materiality (random)
358        let cm_f64 = component_materiality
359            .to_string()
360            .parse::<f64>()
361            .unwrap_or(100_000.0);
362        let pct: f64 = self.rng.random_range(0.01..=0.80);
363        let amount = Decimal::from_f64_retain(cm_f64 * pct).unwrap_or(Decimal::new(1_000, 0));
364
365        let corrected = self.rng.random::<f64>() > 0.40; // 60% corrected
366
367        let description = match &classification {
368            MisstatementType::Factual => format!("Factual misstatement in {area}"),
369            MisstatementType::Judgmental => format!("Judgmental difference in {area} estimate"),
370            MisstatementType::Projected => format!("Projected error in {area} population"),
371        };
372
373        Misstatement {
374            description,
375            amount,
376            classification,
377            account_area: area,
378            corrected,
379        }
380    }
381
382    fn build_procedures(&mut self, scope: &ComponentScope, company: &CompanyConfig) -> Vec<String> {
383        match scope {
384            ComponentScope::FullScope => vec![
385                format!(
386                    "Perform full audit of {} financial statements",
387                    company.name
388                ),
389                "Test internal controls over financial reporting".to_string(),
390                "Perform substantive testing on all material account balances".to_string(),
391                "Attend physical inventory count".to_string(),
392                "Confirm significant balances with third parties".to_string(),
393                "Review subsequent events through reporting deadline".to_string(),
394            ],
395            ComponentScope::SpecificScope { account_areas } => {
396                let mut procs =
397                    vec!["Perform substantive procedures on specified account areas".to_string()];
398                for area in account_areas {
399                    procs.push(format!("Obtain audit evidence for {area} balance"));
400                }
401                procs
402            }
403            ComponentScope::LimitedProcedures => vec![
404                "Perform agreed-upon procedures as specified in instruction".to_string(),
405                "Report all factual findings without expressing an opinion".to_string(),
406            ],
407            ComponentScope::AnalyticalOnly => vec![
408                "Perform analytical procedures on key account balances".to_string(),
409                "Investigate significant fluctuations exceeding component materiality".to_string(),
410                "Obtain management explanations for unusual movements".to_string(),
411            ],
412        }
413    }
414
415    fn build_areas_of_focus(&self, scope: &ComponentScope) -> Vec<String> {
416        match scope {
417            ComponentScope::FullScope => vec![
418                "Revenue recognition".to_string(),
419                "Going concern assessment".to_string(),
420                "Related party transactions".to_string(),
421                "Significant estimates and judgments".to_string(),
422            ],
423            ComponentScope::SpecificScope { account_areas } => account_areas.clone(),
424            ComponentScope::LimitedProcedures => vec!["As agreed in instruction".to_string()],
425            ComponentScope::AnalyticalOnly => vec![
426                "Year-on-year variance analysis".to_string(),
427                "Budget vs actual comparison".to_string(),
428            ],
429        }
430    }
431}
432
433/// Look up the auditor ID for a given country, falling back to a generic ID.
434fn company_to_auditor_id(country: &str, country_to_auditor_id: &HashMap<String, String>) -> String {
435    country_to_auditor_id
436        .get(country)
437        .cloned()
438        .unwrap_or_else(|| format!("CA-{country}-0001"))
439}
440
441#[cfg(test)]
442#[allow(clippy::unwrap_used)]
443mod tests {
444    use super::*;
445    use datasynth_config::schema::{CompanyConfig, TransactionVolume};
446
447    fn make_company(code: &str, name: &str, country: &str) -> CompanyConfig {
448        CompanyConfig {
449            code: code.to_string(),
450            name: name.to_string(),
451            currency: "USD".to_string(),
452            functional_currency: None,
453            country: country.to_string(),
454            fiscal_year_variant: "K4".to_string(),
455            annual_transaction_volume: TransactionVolume::TenK,
456            volume_weight: 1.0,
457        }
458    }
459
460    fn make_company_weighted(
461        code: &str,
462        name: &str,
463        country: &str,
464        volume_weight: f64,
465    ) -> CompanyConfig {
466        CompanyConfig {
467            code: code.to_string(),
468            name: name.to_string(),
469            currency: "USD".to_string(),
470            functional_currency: None,
471            country: country.to_string(),
472            fiscal_year_variant: "K4".to_string(),
473            annual_transaction_volume: TransactionVolume::TenK,
474            volume_weight,
475        }
476    }
477
478    #[test]
479    fn test_single_entity_produces_one_auditor_instruction_report() {
480        let companies = vec![make_company("C001", "Alpha Inc", "US")];
481        let mut gen = ComponentAuditGenerator::new(42);
482        let period_end = NaiveDate::from_ymd_opt(2025, 12, 31).unwrap();
483        let group_mat = Decimal::new(1_000_000, 0);
484
485        let snapshot = gen.generate(&companies, group_mat, "ENG-001", period_end);
486
487        assert_eq!(
488            snapshot.component_auditors.len(),
489            1,
490            "one auditor per jurisdiction"
491        );
492        assert_eq!(
493            snapshot.component_instructions.len(),
494            1,
495            "one instruction per entity"
496        );
497        assert_eq!(snapshot.component_reports.len(), 1, "one report per entity");
498        assert!(
499            snapshot.group_audit_plan.is_some(),
500            "group plan should be present"
501        );
502    }
503
504    #[test]
505    fn test_multi_entity_two_jurisdictions_two_auditors() {
506        let companies = vec![
507            make_company("C001", "Alpha Inc", "US"),
508            make_company("C002", "Beta GmbH", "DE"),
509            make_company("C003", "Gamma LLC", "US"),
510        ];
511        let mut gen = ComponentAuditGenerator::new(42);
512        let period_end = NaiveDate::from_ymd_opt(2025, 12, 31).unwrap();
513        let group_mat = Decimal::new(5_000_000, 0);
514
515        let snapshot = gen.generate(&companies, group_mat, "ENG-002", period_end);
516
517        assert_eq!(
518            snapshot.component_auditors.len(),
519            2,
520            "US and DE → 2 auditors"
521        );
522        assert_eq!(snapshot.component_instructions.len(), 3, "one per entity");
523        assert_eq!(snapshot.component_reports.len(), 3, "one per entity");
524    }
525
526    #[test]
527    fn test_scope_thresholds_with_large_group() {
528        // 2 equal-weight companies: each has 50% share → FullScope + significant
529        let companies = vec![
530            make_company("C001", "BigCo", "US"),
531            make_company("C002", "SmallCo", "US"),
532        ];
533        let mut gen = ComponentAuditGenerator::new(42);
534        let period_end = NaiveDate::from_ymd_opt(2025, 12, 31).unwrap();
535        let group_mat = Decimal::new(10_000_000, 0);
536
537        let snapshot = gen.generate(&companies, group_mat, "ENG-003", period_end);
538
539        // With 2 equal-weight companies: each has 50% → FullScope + significant
540        let plan = snapshot.group_audit_plan.as_ref().unwrap();
541        assert!(plan.significant_components.contains(&"C001".to_string()));
542        assert!(plan.significant_components.contains(&"C002".to_string()));
543
544        let c001_inst = snapshot
545            .component_instructions
546            .iter()
547            .find(|i| i.entity_code == "C001")
548            .unwrap();
549        assert_eq!(c001_inst.scope, ComponentScope::FullScope);
550    }
551
552    #[test]
553    fn test_scope_analytical_only_for_small_entity() {
554        // One large entity (volume_weight=10.0) and one tiny entity (volume_weight=0.5).
555        // Tiny entity share = 0.5 / 10.5 ≈ 4.8% → AnalyticalOnly (< 5%).
556        let companies = vec![
557            make_company_weighted("C001", "BigCo", "US", 10.0),
558            make_company_weighted("C002", "TinyCo", "US", 0.5),
559        ];
560        let mut gen = ComponentAuditGenerator::new(42);
561        let period_end = NaiveDate::from_ymd_opt(2025, 12, 31).unwrap();
562        let group_mat = Decimal::new(10_000_000, 0);
563
564        let snapshot = gen.generate(&companies, group_mat, "ENG-004", period_end);
565
566        // C002 has 0.5/10.5 ≈ 4.8% share → AnalyticalOnly
567        let tiny_inst = snapshot
568            .component_instructions
569            .iter()
570            .find(|i| i.entity_code == "C002")
571            .unwrap();
572        assert_eq!(tiny_inst.scope, ComponentScope::AnalyticalOnly);
573    }
574
575    #[test]
576    fn test_sum_of_component_materialities_le_group_materiality() {
577        let companies: Vec<CompanyConfig> = (1..=5)
578            .map(|i| make_company(&format!("C{i:03}"), &format!("Firm {i}"), "US"))
579            .collect();
580        let mut gen = ComponentAuditGenerator::new(99);
581        let period_end = NaiveDate::from_ymd_opt(2025, 12, 31).unwrap();
582        let group_mat = Decimal::new(2_000_000, 0);
583
584        let snapshot = gen.generate(&companies, group_mat, "ENG-005", period_end);
585
586        let plan = snapshot.group_audit_plan.as_ref().unwrap();
587        let total_component_mat: Decimal = plan
588            .component_allocations
589            .iter()
590            .map(|a| a.component_materiality)
591            .sum();
592
593        assert!(
594            total_component_mat <= group_mat,
595            "sum of component mats {total_component_mat} should be <= group mat {group_mat}"
596        );
597    }
598
599    #[test]
600    fn test_all_entities_covered_by_exactly_one_instruction() {
601        let companies = vec![
602            make_company("C001", "Alpha", "US"),
603            make_company("C002", "Beta", "DE"),
604            make_company("C003", "Gamma", "FR"),
605        ];
606        let mut gen = ComponentAuditGenerator::new(7);
607        let period_end = NaiveDate::from_ymd_opt(2025, 12, 31).unwrap();
608        let group_mat = Decimal::new(3_000_000, 0);
609
610        let snapshot = gen.generate(&companies, group_mat, "ENG-006", period_end);
611
612        for company in &companies {
613            let count = snapshot
614                .component_instructions
615                .iter()
616                .filter(|i| i.entity_code == company.code)
617                .count();
618            assert_eq!(
619                count, 1,
620                "entity {} should have exactly 1 instruction",
621                company.code
622            );
623        }
624    }
625
626    #[test]
627    fn test_all_reports_reference_valid_instruction_ids() {
628        let companies = vec![
629            make_company("C001", "Alpha", "US"),
630            make_company("C002", "Beta", "GB"),
631        ];
632        let mut gen = ComponentAuditGenerator::new(123);
633        let period_end = NaiveDate::from_ymd_opt(2025, 12, 31).unwrap();
634        let group_mat = Decimal::new(1_500_000, 0);
635
636        let snapshot = gen.generate(&companies, group_mat, "ENG-007", period_end);
637
638        let instruction_ids: std::collections::HashSet<String> = snapshot
639            .component_instructions
640            .iter()
641            .map(|i| i.id.clone())
642            .collect();
643
644        for report in &snapshot.component_reports {
645            assert!(
646                instruction_ids.contains(&report.instruction_id),
647                "report {} references unknown instruction {}",
648                report.id,
649                report.instruction_id
650            );
651        }
652    }
653}