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