Skip to main content

datasynth_generators/audit/
materiality_generator.rs

1//! Materiality benchmark calculation generator per ISA 320.
2//!
3//! Generates one `MaterialityCalculation` per entity per period.  The benchmark
4//! is selected based on the entity's financial profile and the appropriate
5//! percentage is drawn from ranges consistent with professional practice:
6//!
7//! | Benchmark     | Typical range | Rationale                              |
8//! |---------------|---------------|----------------------------------------|
9//! | Pre-tax income | 3–7%         | Profit-oriented entities               |
10//! | Revenue        | 0.5–1%       | Thin-margin or loss-making entities    |
11//! | Total assets   | 0.5–1%       | Asset-intensive industries             |
12//! | Equity         | 1–2%         | Non-profit / equity-focused users      |
13//!
14//! Performance materiality is set at 65% of overall (within the ISA 320.11
15//! range of 50–75%).  Clearly trivial is set at 5% of overall.
16//!
17//! Normalisation adjustments are generated when reported pre-tax earnings are
18//! unusually volatile (swing > 50% from an estimated "normal").
19
20use datasynth_core::models::audit::materiality_calculation::{
21    AdjustmentType, MaterialityBenchmark, MaterialityCalculation, NormalizationAdjustment,
22    NormalizedEarnings,
23};
24use datasynth_core::utils::seeded_rng;
25use rand::Rng;
26use rand_chacha::ChaCha8Rng;
27use rust_decimal::Decimal;
28use rust_decimal_macros::dec;
29use tracing::info;
30
31// ---------------------------------------------------------------------------
32// Input
33// ---------------------------------------------------------------------------
34
35/// Financial data extracted from the trial balance / JE stream, used to
36/// select the appropriate benchmark and compute materiality amounts.
37#[derive(Debug, Clone)]
38pub struct MaterialityInput {
39    /// Entity / company code.
40    pub entity_code: String,
41    /// Human-readable period descriptor (e.g. "FY2024").
42    pub period: String,
43    /// Revenue for the period (zero or positive).
44    pub revenue: Decimal,
45    /// Pre-tax income for the period (may be negative for a loss).
46    pub pretax_income: Decimal,
47    /// Total assets at period end.
48    pub total_assets: Decimal,
49    /// Total equity at period end.
50    pub equity: Decimal,
51    /// Gross profit = revenue − cost of goods sold.
52    pub gross_profit: Decimal,
53}
54
55// ---------------------------------------------------------------------------
56// Configuration
57// ---------------------------------------------------------------------------
58
59/// Configuration for the materiality generator.
60#[derive(Debug, Clone)]
61pub struct MaterialityGeneratorConfig {
62    /// Performance materiality as a fraction of overall materiality.
63    /// Must be in [0.50, 0.75] per ISA 320 guidance.
64    pub pm_percentage: Decimal,
65    /// Minimum benchmark amount — prevents immaterial overall materiality
66    /// for micro-entities.
67    pub minimum_overall_materiality: Decimal,
68}
69
70impl Default for MaterialityGeneratorConfig {
71    fn default() -> Self {
72        Self {
73            pm_percentage: dec!(0.65),
74            minimum_overall_materiality: dec!(5_000),
75        }
76    }
77}
78
79// ---------------------------------------------------------------------------
80// Generator
81// ---------------------------------------------------------------------------
82
83/// Generator for ISA 320 materiality calculations.
84pub struct MaterialityGenerator {
85    rng: ChaCha8Rng,
86    config: MaterialityGeneratorConfig,
87}
88
89impl MaterialityGenerator {
90    /// Create a new generator with default configuration.
91    pub fn new(seed: u64) -> Self {
92        Self {
93            rng: seeded_rng(seed, 0x320), // discriminator for ISA 320
94            config: MaterialityGeneratorConfig::default(),
95        }
96    }
97
98    /// Create a new generator with custom configuration.
99    pub fn with_config(seed: u64, config: MaterialityGeneratorConfig) -> Self {
100        Self {
101            rng: seeded_rng(seed, 0x320),
102            config,
103        }
104    }
105
106    /// Generate a materiality calculation for a single entity.
107    pub fn generate(&mut self, input: &MaterialityInput) -> MaterialityCalculation {
108        info!(
109            "Generating materiality calculation for entity {} period {}",
110            input.entity_code, input.period
111        );
112        let (benchmark, benchmark_amount, benchmark_pct, rationale) = self.select_benchmark(input);
113
114        // Apply the minimum overall materiality floor after benchmark selection
115        let raw_overall = benchmark_amount * benchmark_pct;
116        let effective_overall = raw_overall.max(self.config.minimum_overall_materiality);
117
118        // If the floor was applied, adjust the percentage so the formula stays consistent
119        let effective_pct = if benchmark_amount > Decimal::ZERO {
120            effective_overall / benchmark_amount
121        } else {
122            benchmark_pct
123        };
124
125        let normalized_earnings = self.maybe_generate_normalization(input);
126
127        let calc = MaterialityCalculation::new(
128            &input.entity_code,
129            &input.period,
130            benchmark,
131            benchmark_amount,
132            effective_pct,
133            self.config.pm_percentage,
134            normalized_earnings,
135            &rationale,
136        );
137        info!(
138            "Materiality for {} {}: overall={} PM={} benchmark={:?}",
139            input.entity_code,
140            input.period,
141            calc.overall_materiality,
142            calc.performance_materiality,
143            calc.benchmark
144        );
145        calc
146    }
147
148    /// Generate materiality calculations for a batch of entities.
149    pub fn generate_batch(&mut self, inputs: &[MaterialityInput]) -> Vec<MaterialityCalculation> {
150        inputs.iter().map(|i| self.generate(i)).collect()
151    }
152
153    // -----------------------------------------------------------------------
154    // Benchmark selection
155    // -----------------------------------------------------------------------
156
157    /// Select the appropriate benchmark and percentage for the entity.
158    ///
159    /// Decision rules:
160    /// 1. If pre-tax income is positive and > 5% of revenue → PretaxIncome at 3–7%
161    /// 2. If pre-tax income is negative or < 2% of revenue (thin margin) → Revenue at 0.5–1%
162    /// 3. If total assets dominate (assets > 10× revenue, e.g. financial institutions) → TotalAssets at 0.5–1%
163    /// 4. If equity is the primary concern → Equity at 1–2%
164    /// 5. Default fallback → Revenue at 0.5–1%
165    fn select_benchmark(
166        &mut self,
167        input: &MaterialityInput,
168    ) -> (MaterialityBenchmark, Decimal, Decimal, String) {
169        // Check if entity is asset-heavy (assets > 10× revenue)
170        let asset_heavy =
171            input.revenue > Decimal::ZERO && input.total_assets > input.revenue * dec!(10);
172
173        // Check if profitable (positive pre-tax income and > 5% of revenue)
174        let healthy_profit = input.pretax_income > Decimal::ZERO
175            && (input.revenue == Decimal::ZERO || input.pretax_income > input.revenue * dec!(0.05));
176
177        // Thin margin: profitable but < 2% of revenue (or small absolute)
178        let thin_margin = input.pretax_income > Decimal::ZERO
179            && input.revenue > Decimal::ZERO
180            && input.pretax_income < input.revenue * dec!(0.02);
181
182        if asset_heavy && input.total_assets > Decimal::ZERO {
183            // Asset-intensive entities (banks, real estate, investment firms)
184            let pct = self.random_pct(dec!(0.005), dec!(0.010));
185            let rationale = format!(
186                "Total assets selected as benchmark (asset-intensive entity; assets {:.0}× revenue). \
187                 {:.2}% of total assets applied.",
188                (input.total_assets / input.revenue.max(dec!(1))).round(),
189                pct * dec!(100)
190            );
191            (
192                MaterialityBenchmark::TotalAssets,
193                input.total_assets,
194                pct,
195                rationale,
196            )
197        } else if healthy_profit && !thin_margin {
198            // Standard profitable entity — pre-tax income benchmark
199            let pct = self.random_pct(dec!(0.03), dec!(0.07));
200            let rationale = format!(
201                "Pre-tax income selected as benchmark (profit-making entity with healthy margins). \
202                 {:.0}% applied.",
203                pct * dec!(100)
204            );
205            (
206                MaterialityBenchmark::PretaxIncome,
207                input.pretax_income,
208                pct,
209                rationale,
210            )
211        } else if input.pretax_income <= Decimal::ZERO || thin_margin {
212            // Loss-making or thin-margin entity — revenue benchmark
213            let pct = self.random_pct(dec!(0.005), dec!(0.010));
214            let rationale =
215                format!(
216                "Revenue selected as benchmark (entity has {} pre-tax income; revenue provides \
217                 more stable benchmark). {:.2}% applied.",
218                if input.pretax_income <= Decimal::ZERO { "negative" } else { "thin" },
219                pct * dec!(100)
220            );
221            (
222                MaterialityBenchmark::Revenue,
223                input.revenue.max(dec!(1)),
224                pct,
225                rationale,
226            )
227        } else if input.equity > Decimal::ZERO {
228            // Equity-focused (e.g. non-profit, investment entity)
229            let pct = self.random_pct(dec!(0.01), dec!(0.02));
230            let rationale = format!(
231                "Equity selected as benchmark (equity-focused entity). {:.0}% applied.",
232                pct * dec!(100)
233            );
234            (MaterialityBenchmark::Equity, input.equity, pct, rationale)
235        } else {
236            // Fallback — revenue
237            let pct = self.random_pct(dec!(0.005), dec!(0.010));
238            let rationale = format!(
239                "Revenue selected as default benchmark. {:.2}% applied.",
240                pct * dec!(100)
241            );
242            (
243                MaterialityBenchmark::Revenue,
244                input.revenue.max(dec!(1)),
245                pct,
246                rationale,
247            )
248        }
249    }
250
251    // -----------------------------------------------------------------------
252    // Normalization
253    // -----------------------------------------------------------------------
254
255    /// Optionally generate 0–2 normalization adjustments.
256    ///
257    /// Adjustments are generated when pre-tax income is unusually volatile —
258    /// defined as abs(pre-tax income) being very small relative to revenue
259    /// (i.e. near break-even) or when the absolute earnings swing indicates
260    /// a non-recurring item.
261    fn maybe_generate_normalization(
262        &mut self,
263        input: &MaterialityInput,
264    ) -> Option<NormalizedEarnings> {
265        // Only normalize when using pre-tax income as a benchmark
266        if input.pretax_income <= Decimal::ZERO {
267            return None;
268        }
269        // Check for "unusual" earnings: earnings < 3% of revenue suggests volatility
270        let is_unusual =
271            input.revenue > Decimal::ZERO && input.pretax_income < input.revenue * dec!(0.03);
272        if !is_unusual {
273            return None;
274        }
275        // 60% chance of generating adjustments when earnings are unusual
276        let roll: f64 = self.rng.random();
277        if roll > 0.60 {
278            return None;
279        }
280
281        let n_adjustments: u32 = self.rng.random_range(1u32..=2);
282        let mut adjustments = Vec::new();
283
284        for i in 0..n_adjustments {
285            let (description, amount, adj_type) = self.random_adjustment(input, i);
286            adjustments.push(NormalizationAdjustment {
287                description,
288                amount,
289                adjustment_type: adj_type,
290            });
291        }
292
293        let ne = NormalizedEarnings::new(input.pretax_income, adjustments);
294        Some(ne)
295    }
296
297    /// Generate a single normalization adjustment.
298    fn random_adjustment(
299        &mut self,
300        input: &MaterialityInput,
301        index: u32,
302    ) -> (String, Decimal, AdjustmentType) {
303        let templates = [
304            (
305                "Restructuring charge — one-time plant closure costs",
306                AdjustmentType::NonRecurring,
307                0.01_f64,
308            ),
309            (
310                "Impairment of goodwill — non-recurring write-down",
311                AdjustmentType::NonRecurring,
312                0.02_f64,
313            ),
314            (
315                "Gain on disposal of subsidiary — non-recurring",
316                AdjustmentType::Extraordinary,
317                -0.015_f64,
318            ),
319            (
320                "Litigation settlement — one-time charge",
321                AdjustmentType::NonRecurring,
322                0.008_f64,
323            ),
324            (
325                "COVID-19 related costs — non-recurring operational impact",
326                AdjustmentType::NonRecurring,
327                0.005_f64,
328            ),
329        ];
330
331        let idx =
332            (index as usize + self.rng.random_range(0usize..templates.len())) % templates.len();
333        let (desc, adj_type, revenue_frac) = &templates[idx];
334        let base = input.revenue.max(dec!(100_000));
335        let frac = Decimal::try_from(*revenue_frac).unwrap_or(dec!(0.01));
336        let amount = (base * frac).round_dp(0);
337
338        (desc.to_string(), amount, *adj_type)
339    }
340
341    // -----------------------------------------------------------------------
342    // Helpers
343    // -----------------------------------------------------------------------
344
345    /// Sample a Decimal percentage in the closed range [lo, hi].
346    fn random_pct(&mut self, lo: Decimal, hi: Decimal) -> Decimal {
347        use rust_decimal::prelude::ToPrimitive;
348        let lo_f = lo.to_f64().unwrap_or(0.005);
349        let hi_f = hi.to_f64().unwrap_or(0.010);
350        let val = self.rng.random_range(lo_f..=hi_f);
351        // Round to 4 decimal places
352        Decimal::try_from(val).unwrap_or(lo).round_dp(4)
353    }
354}
355
356// ---------------------------------------------------------------------------
357// Tests
358// ---------------------------------------------------------------------------
359
360#[cfg(test)]
361#[allow(clippy::unwrap_used)]
362mod tests {
363    use super::*;
364
365    fn sample_input() -> MaterialityInput {
366        MaterialityInput {
367            entity_code: "C001".into(),
368            period: "FY2024".into(),
369            revenue: dec!(10_000_000),
370            pretax_income: dec!(1_000_000),
371            total_assets: dec!(8_000_000),
372            equity: dec!(4_000_000),
373            gross_profit: dec!(3_500_000),
374        }
375    }
376
377    #[test]
378    fn materiality_formula_holds() {
379        let mut gen = MaterialityGenerator::new(42);
380        let calc = gen.generate(&sample_input());
381        // overall_materiality = benchmark_amount × benchmark_percentage
382        let expected = (calc.benchmark_amount * calc.benchmark_percentage).round_dp(10);
383        assert_eq!(
384            calc.overall_materiality.round_dp(10),
385            expected,
386            "overall_materiality must equal benchmark_amount × benchmark_percentage"
387        );
388    }
389
390    #[test]
391    fn pm_is_between_50_and_75_percent_of_overall() {
392        let mut gen = MaterialityGenerator::new(42);
393        let calc = gen.generate(&sample_input());
394        let ratio = calc.performance_materiality / calc.overall_materiality;
395        assert!(
396            ratio >= dec!(0.50),
397            "PM ({}) < 50% of overall ({})",
398            calc.performance_materiality,
399            calc.overall_materiality
400        );
401        assert!(
402            ratio <= dec!(0.75),
403            "PM ({}) > 75% of overall ({})",
404            calc.performance_materiality,
405            calc.overall_materiality
406        );
407    }
408
409    #[test]
410    fn clearly_trivial_is_five_percent_of_overall() {
411        let mut gen = MaterialityGenerator::new(42);
412        let calc = gen.generate(&sample_input());
413        let expected_ct = calc.overall_materiality * dec!(0.05);
414        assert_eq!(calc.clearly_trivial, expected_ct);
415    }
416
417    #[test]
418    fn sad_nominal_is_five_percent_of_overall() {
419        let mut gen = MaterialityGenerator::new(42);
420        let calc = gen.generate(&sample_input());
421        // SAD nominal = 5% of overall materiality (ISA 450 guidance).
422        let expected = calc.overall_materiality * dec!(0.05);
423        assert_eq!(calc.sad_nominal, expected);
424    }
425
426    #[test]
427    fn minimum_materiality_floor_applied() {
428        let mut gen = MaterialityGenerator::new(42);
429        let tiny_input = MaterialityInput {
430            entity_code: "TINY".into(),
431            period: "FY2024".into(),
432            revenue: dec!(10_000),
433            pretax_income: dec!(500),
434            total_assets: dec!(5_000),
435            equity: dec!(2_000),
436            gross_profit: dec!(2_000),
437        };
438        let calc = gen.generate(&tiny_input);
439        assert!(
440            calc.overall_materiality >= dec!(5_000),
441            "Minimum floor should apply; got {}",
442            calc.overall_materiality
443        );
444    }
445
446    #[test]
447    fn asset_heavy_entity_uses_total_assets() {
448        let mut gen = MaterialityGenerator::new(42);
449        let asset_input = MaterialityInput {
450            entity_code: "BANK".into(),
451            period: "FY2024".into(),
452            revenue: dec!(1_000_000),
453            pretax_income: dec!(200_000),
454            total_assets: dec!(50_000_000), // 50× revenue → asset-heavy
455            equity: dec!(5_000_000),
456            gross_profit: dec!(800_000),
457        };
458        let calc = gen.generate(&asset_input);
459        assert_eq!(
460            calc.benchmark,
461            MaterialityBenchmark::TotalAssets,
462            "Asset-heavy entity should use TotalAssets benchmark"
463        );
464    }
465
466    #[test]
467    fn loss_making_entity_uses_revenue() {
468        let mut gen = MaterialityGenerator::new(42);
469        let loss_input = MaterialityInput {
470            entity_code: "LOSS".into(),
471            period: "FY2024".into(),
472            revenue: dec!(5_000_000),
473            pretax_income: dec!(-200_000),
474            total_assets: dec!(3_000_000),
475            equity: dec!(1_000_000),
476            gross_profit: dec!(500_000),
477        };
478        let calc = gen.generate(&loss_input);
479        assert_eq!(
480            calc.benchmark,
481            MaterialityBenchmark::Revenue,
482            "Loss-making entity should use Revenue benchmark"
483        );
484    }
485}