1use 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
26pub struct ComponentAuditGenerator {
28 rng: ChaCha8Rng,
29}
30
31impl ComponentAuditGenerator {
32 pub fn new(seed: u64) -> Self {
34 Self {
35 rng: seeded_rng(seed, 0x600),
36 }
37 }
38
39 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 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 let mut jurisdictions: Vec<String> = jurisdiction_map.keys().cloned().collect();
76 jurisdictions.sort();
77
78 let mut auditor_id_counter: u32 = 0;
82 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 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, competence_assessment: competence,
114 assigned_entities,
115 });
116 }
117
118 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 vec![1.0f64; n]
133 } else {
134 raw_weights
135 }
136 };
137 let total_weight: f64 = weights.iter().sum();
138
139 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 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 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 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 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 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 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 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 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 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 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 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 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; 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
446fn 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 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 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 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 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}