use std::collections::HashMap;
use chrono::{Duration, NaiveDate};
use datasynth_config::schema::CompanyConfig;
use datasynth_core::models::audit::component_audit::{
AllocationBasis, CompetenceLevel, ComponentAuditSnapshot, ComponentAuditor,
ComponentAuditorReport, ComponentInstruction, ComponentMaterialityAllocation, ComponentScope,
GroupAuditPlan, GroupRiskLevel, Misstatement, MisstatementType,
};
use datasynth_core::utils::seeded_rng;
use rand::RngExt;
use rand_chacha::ChaCha8Rng;
use rust_decimal::Decimal;
use tracing::info;
pub struct ComponentAuditGenerator {
rng: ChaCha8Rng,
}
impl ComponentAuditGenerator {
pub fn new(seed: u64) -> Self {
Self {
rng: seeded_rng(seed, 0x600),
}
}
pub fn generate(
&mut self,
companies: &[CompanyConfig],
group_materiality: Decimal,
engagement_id: &str,
period_end: NaiveDate,
) -> ComponentAuditSnapshot {
if companies.is_empty() {
return ComponentAuditSnapshot::default();
}
info!(
"Generating component audit snapshot for engagement {} ({} companies)",
engagement_id,
companies.len()
);
let mut jurisdiction_map: HashMap<String, Vec<String>> = HashMap::new();
for company in companies {
jurisdiction_map
.entry(company.country.clone())
.or_default()
.push(company.code.clone());
}
let mut jurisdictions: Vec<String> = jurisdiction_map.keys().cloned().collect();
jurisdictions.sort();
let mut auditor_id_counter: u32 = 0;
let mut country_to_auditor_id: HashMap<String, String> = HashMap::new();
let mut component_auditors: Vec<ComponentAuditor> = Vec::new();
for country in &jurisdictions {
auditor_id_counter += 1;
let auditor_id = format!("CA-{country}-{auditor_id_counter:04}");
let firm_name = format!("Audit Firm {country}");
let competence = {
let r: f64 = self.rng.random();
if r < 0.90 {
CompetenceLevel::Satisfactory
} else if r < 0.98 {
CompetenceLevel::RequiresSupervision
} else {
CompetenceLevel::Unsatisfactory
}
};
let assigned_entities = jurisdiction_map.get(country).cloned().unwrap_or_default();
country_to_auditor_id.insert(country.clone(), auditor_id.clone());
component_auditors.push(ComponentAuditor {
id: auditor_id,
firm_name,
jurisdiction: country.clone(),
independence_confirmed: self.rng.random::<f64>() > 0.02, competence_assessment: competence,
assigned_entities,
});
}
let n = companies.len();
let raw_weights: Vec<f64> = companies.iter().map(|c| c.volume_weight).collect();
let weights: Vec<f64> = {
let all_equal = raw_weights
.iter()
.all(|&w| (w - raw_weights[0]).abs() < f64::EPSILON);
if all_equal {
vec![1.0f64; n]
} else {
raw_weights
}
};
let total_weight: f64 = weights.iter().sum();
let mut component_allocations: Vec<ComponentMaterialityAllocation> = Vec::new();
let mut significant_components: Vec<String> = Vec::new();
let group_mat_f64 = group_materiality
.to_string()
.parse::<f64>()
.unwrap_or(1_000_000.0);
for (i, company) in companies.iter().enumerate() {
let entity_share = weights[i] / total_weight;
let cm_f64 = group_mat_f64 * entity_share * 0.75;
let component_materiality =
Decimal::from_f64_retain(cm_f64).unwrap_or(Decimal::new(100_000, 2));
let clearly_trivial =
Decimal::from_f64_retain(cm_f64 * 0.05).unwrap_or(Decimal::new(5_000, 2));
let allocation_basis = if entity_share >= 0.05 {
AllocationBasis::RevenueProportional
} else {
AllocationBasis::RiskBased
};
if entity_share >= 0.15 {
significant_components.push(company.code.clone());
}
component_allocations.push(ComponentMaterialityAllocation {
entity_code: company.code.clone(),
component_materiality,
clearly_trivial,
allocation_basis,
});
}
let aggregation_risk = if n <= 2 {
GroupRiskLevel::Low
} else if n <= 5 {
GroupRiskLevel::Medium
} else {
GroupRiskLevel::High
};
let consolidation_procedures = vec![
"Review intercompany eliminations for completeness".to_string(),
"Agree component trial balances to consolidation working papers".to_string(),
"Test goodwill impairment at group level".to_string(),
"Review consolidation journal entries for unusual items".to_string(),
"Assess appropriateness of accounting policies across components".to_string(),
];
let group_audit_plan = GroupAuditPlan {
engagement_id: engagement_id.to_string(),
group_materiality,
component_allocations: component_allocations.clone(),
aggregation_risk,
significant_components: significant_components.clone(),
consolidation_audit_procedures: consolidation_procedures,
};
let reporting_deadline = period_end + Duration::days(60);
let mut instruction_id_counter: u32 = 0;
let mut instructions: Vec<ComponentInstruction> = Vec::new();
for (i, company) in companies.iter().enumerate() {
instruction_id_counter += 1;
let entity_share = weights[i] / total_weight;
let auditor_id = company_to_auditor_id(&company.country, &country_to_auditor_id);
let alloc = &component_allocations[i];
let scope = if entity_share >= 0.15 {
ComponentScope::FullScope
} else if entity_share >= 0.05 {
ComponentScope::SpecificScope {
account_areas: vec![
"Revenue".to_string(),
"Receivables".to_string(),
"Inventory".to_string(),
],
}
} else {
ComponentScope::AnalyticalOnly
};
let specific_procedures = self.build_procedures(&scope, company);
let areas_of_focus = self.build_areas_of_focus(&scope);
instructions.push(ComponentInstruction {
id: format!("CI-{instruction_id_counter:06}"),
component_auditor_id: auditor_id,
entity_code: company.code.clone(),
scope,
materiality_allocated: alloc.component_materiality,
reporting_deadline,
specific_procedures,
areas_of_focus,
});
}
let mut report_id_counter: u32 = 0;
let mut reports: Vec<ComponentAuditorReport> = Vec::new();
for (i, company) in companies.iter().enumerate() {
report_id_counter += 1;
let entity_share = weights[i] / total_weight;
let instruction = &instructions[i];
let alloc = &component_allocations[i];
let auditor_id = company_to_auditor_id(&company.country, &country_to_auditor_id);
let max_misstatements = if entity_share >= 0.15 {
3usize
} else if entity_share >= 0.05 {
2
} else {
1
};
let misstatement_count = self.rng.random_range(0..=max_misstatements);
let mut misstatements: Vec<Misstatement> = Vec::new();
for _ in 0..misstatement_count {
misstatements.push(self.generate_misstatement(alloc.component_materiality));
}
let scope_limitations: Vec<String> = if self.rng.random::<f64>() < 0.05 {
vec!["Limited access to subsidiary records for inventory count".to_string()]
} else {
vec![]
};
let significant_findings: Vec<String> = misstatements
.iter()
.filter(|m| !m.corrected)
.map(|m| {
format!(
"{}: {} {} ({})",
m.account_area,
m.description,
m.amount,
format!("{:?}", m.classification).to_lowercase()
)
})
.collect();
let conclusion = if misstatements.iter().all(|m| m.corrected)
&& scope_limitations.is_empty()
{
format!(
"No uncorrected misstatements identified in {} that exceed component materiality.",
company.name
)
} else {
format!(
"Uncorrected misstatements or limitations noted in {}. See significant findings.",
company.name
)
};
reports.push(ComponentAuditorReport {
id: format!("CR-{report_id_counter:06}"),
instruction_id: instruction.id.clone(),
component_auditor_id: auditor_id,
entity_code: company.code.clone(),
misstatements_identified: misstatements,
scope_limitations,
significant_findings,
conclusion,
});
}
let snapshot = ComponentAuditSnapshot {
component_auditors,
group_audit_plan: Some(group_audit_plan),
component_instructions: instructions,
component_reports: reports,
};
info!(
"Component audit snapshot generated: {} auditors, {} instructions, {} reports",
snapshot.component_auditors.len(),
snapshot.component_instructions.len(),
snapshot.component_reports.len()
);
snapshot
}
fn generate_misstatement(&mut self, component_materiality: Decimal) -> Misstatement {
let account_areas = [
"Revenue",
"Receivables",
"Inventory",
"Fixed Assets",
"Payables",
"Accruals",
"Provisions",
];
let area_idx = self.rng.random_range(0..account_areas.len());
let area = account_areas[area_idx].to_string();
let types = [
MisstatementType::Factual,
MisstatementType::Judgmental,
MisstatementType::Projected,
];
let type_idx = self.rng.random_range(0..types.len());
let classification = types[type_idx].clone();
let cm_f64 = component_materiality
.to_string()
.parse::<f64>()
.unwrap_or(100_000.0);
let pct: f64 = self.rng.random_range(0.01..=0.80);
let amount = Decimal::from_f64_retain(cm_f64 * pct).unwrap_or(Decimal::new(1_000, 0));
let corrected = self.rng.random::<f64>() > 0.40;
let description = match &classification {
MisstatementType::Factual => format!("Factual misstatement in {area}"),
MisstatementType::Judgmental => format!("Judgmental difference in {area} estimate"),
MisstatementType::Projected => format!("Projected error in {area} population"),
};
Misstatement {
description,
amount,
classification,
account_area: area,
corrected,
}
}
fn build_procedures(&mut self, scope: &ComponentScope, company: &CompanyConfig) -> Vec<String> {
match scope {
ComponentScope::FullScope => vec![
format!(
"Perform full audit of {} financial statements",
company.name
),
"Test internal controls over financial reporting".to_string(),
"Perform substantive testing on all material account balances".to_string(),
"Attend physical inventory count".to_string(),
"Confirm significant balances with third parties".to_string(),
"Review subsequent events through reporting deadline".to_string(),
],
ComponentScope::SpecificScope { account_areas } => {
let mut procs =
vec!["Perform substantive procedures on specified account areas".to_string()];
for area in account_areas {
procs.push(format!("Obtain audit evidence for {area} balance"));
}
procs
}
ComponentScope::LimitedProcedures => vec![
"Perform agreed-upon procedures as specified in instruction".to_string(),
"Report all factual findings without expressing an opinion".to_string(),
],
ComponentScope::AnalyticalOnly => vec![
"Perform analytical procedures on key account balances".to_string(),
"Investigate significant fluctuations exceeding component materiality".to_string(),
"Obtain management explanations for unusual movements".to_string(),
],
}
}
fn build_areas_of_focus(&self, scope: &ComponentScope) -> Vec<String> {
match scope {
ComponentScope::FullScope => vec![
"Revenue recognition".to_string(),
"Going concern assessment".to_string(),
"Related party transactions".to_string(),
"Significant estimates and judgments".to_string(),
],
ComponentScope::SpecificScope { account_areas } => account_areas.clone(),
ComponentScope::LimitedProcedures => vec!["As agreed in instruction".to_string()],
ComponentScope::AnalyticalOnly => vec![
"Year-on-year variance analysis".to_string(),
"Budget vs actual comparison".to_string(),
],
}
}
}
fn company_to_auditor_id(country: &str, country_to_auditor_id: &HashMap<String, String>) -> String {
country_to_auditor_id
.get(country)
.cloned()
.unwrap_or_else(|| format!("CA-{country}-0001"))
}
#[cfg(test)]
#[allow(clippy::unwrap_used)]
mod tests {
use super::*;
use datasynth_config::schema::{CompanyConfig, TransactionVolume};
fn make_company(code: &str, name: &str, country: &str) -> CompanyConfig {
CompanyConfig {
code: code.to_string(),
name: name.to_string(),
currency: "USD".to_string(),
functional_currency: None,
country: country.to_string(),
fiscal_year_variant: "K4".to_string(),
annual_transaction_volume: TransactionVolume::TenK,
volume_weight: 1.0,
}
}
fn make_company_weighted(
code: &str,
name: &str,
country: &str,
volume_weight: f64,
) -> CompanyConfig {
CompanyConfig {
code: code.to_string(),
name: name.to_string(),
currency: "USD".to_string(),
functional_currency: None,
country: country.to_string(),
fiscal_year_variant: "K4".to_string(),
annual_transaction_volume: TransactionVolume::TenK,
volume_weight,
}
}
#[test]
fn test_single_entity_produces_one_auditor_instruction_report() {
let companies = vec![make_company("C001", "Alpha Inc", "US")];
let mut gen = ComponentAuditGenerator::new(42);
let period_end = NaiveDate::from_ymd_opt(2025, 12, 31).unwrap();
let group_mat = Decimal::new(1_000_000, 0);
let snapshot = gen.generate(&companies, group_mat, "ENG-001", period_end);
assert_eq!(
snapshot.component_auditors.len(),
1,
"one auditor per jurisdiction"
);
assert_eq!(
snapshot.component_instructions.len(),
1,
"one instruction per entity"
);
assert_eq!(snapshot.component_reports.len(), 1, "one report per entity");
assert!(
snapshot.group_audit_plan.is_some(),
"group plan should be present"
);
}
#[test]
fn test_multi_entity_two_jurisdictions_two_auditors() {
let companies = vec![
make_company("C001", "Alpha Inc", "US"),
make_company("C002", "Beta GmbH", "DE"),
make_company("C003", "Gamma LLC", "US"),
];
let mut gen = ComponentAuditGenerator::new(42);
let period_end = NaiveDate::from_ymd_opt(2025, 12, 31).unwrap();
let group_mat = Decimal::new(5_000_000, 0);
let snapshot = gen.generate(&companies, group_mat, "ENG-002", period_end);
assert_eq!(
snapshot.component_auditors.len(),
2,
"US and DE → 2 auditors"
);
assert_eq!(snapshot.component_instructions.len(), 3, "one per entity");
assert_eq!(snapshot.component_reports.len(), 3, "one per entity");
}
#[test]
fn test_scope_thresholds_with_large_group() {
let companies = vec![
make_company("C001", "BigCo", "US"),
make_company("C002", "SmallCo", "US"),
];
let mut gen = ComponentAuditGenerator::new(42);
let period_end = NaiveDate::from_ymd_opt(2025, 12, 31).unwrap();
let group_mat = Decimal::new(10_000_000, 0);
let snapshot = gen.generate(&companies, group_mat, "ENG-003", period_end);
let plan = snapshot.group_audit_plan.as_ref().unwrap();
assert!(plan.significant_components.contains(&"C001".to_string()));
assert!(plan.significant_components.contains(&"C002".to_string()));
let c001_inst = snapshot
.component_instructions
.iter()
.find(|i| i.entity_code == "C001")
.unwrap();
assert_eq!(c001_inst.scope, ComponentScope::FullScope);
}
#[test]
fn test_scope_analytical_only_for_small_entity() {
let companies = vec![
make_company_weighted("C001", "BigCo", "US", 10.0),
make_company_weighted("C002", "TinyCo", "US", 0.5),
];
let mut gen = ComponentAuditGenerator::new(42);
let period_end = NaiveDate::from_ymd_opt(2025, 12, 31).unwrap();
let group_mat = Decimal::new(10_000_000, 0);
let snapshot = gen.generate(&companies, group_mat, "ENG-004", period_end);
let tiny_inst = snapshot
.component_instructions
.iter()
.find(|i| i.entity_code == "C002")
.unwrap();
assert_eq!(tiny_inst.scope, ComponentScope::AnalyticalOnly);
}
#[test]
fn test_sum_of_component_materialities_le_group_materiality() {
let companies: Vec<CompanyConfig> = (1..=5)
.map(|i| make_company(&format!("C{i:03}"), &format!("Firm {i}"), "US"))
.collect();
let mut gen = ComponentAuditGenerator::new(99);
let period_end = NaiveDate::from_ymd_opt(2025, 12, 31).unwrap();
let group_mat = Decimal::new(2_000_000, 0);
let snapshot = gen.generate(&companies, group_mat, "ENG-005", period_end);
let plan = snapshot.group_audit_plan.as_ref().unwrap();
let total_component_mat: Decimal = plan
.component_allocations
.iter()
.map(|a| a.component_materiality)
.sum();
assert!(
total_component_mat <= group_mat,
"sum of component mats {total_component_mat} should be <= group mat {group_mat}"
);
}
#[test]
fn test_all_entities_covered_by_exactly_one_instruction() {
let companies = vec![
make_company("C001", "Alpha", "US"),
make_company("C002", "Beta", "DE"),
make_company("C003", "Gamma", "FR"),
];
let mut gen = ComponentAuditGenerator::new(7);
let period_end = NaiveDate::from_ymd_opt(2025, 12, 31).unwrap();
let group_mat = Decimal::new(3_000_000, 0);
let snapshot = gen.generate(&companies, group_mat, "ENG-006", period_end);
for company in &companies {
let count = snapshot
.component_instructions
.iter()
.filter(|i| i.entity_code == company.code)
.count();
assert_eq!(
count, 1,
"entity {} should have exactly 1 instruction",
company.code
);
}
}
#[test]
fn test_all_reports_reference_valid_instruction_ids() {
let companies = vec![
make_company("C001", "Alpha", "US"),
make_company("C002", "Beta", "GB"),
];
let mut gen = ComponentAuditGenerator::new(123);
let period_end = NaiveDate::from_ymd_opt(2025, 12, 31).unwrap();
let group_mat = Decimal::new(1_500_000, 0);
let snapshot = gen.generate(&companies, group_mat, "ENG-007", period_end);
let instruction_ids: std::collections::HashSet<String> = snapshot
.component_instructions
.iter()
.map(|i| i.id.clone())
.collect();
for report in &snapshot.component_reports {
assert!(
instruction_ids.contains(&report.instruction_id),
"report {} references unknown instruction {}",
report.id,
report.instruction_id
);
}
}
}