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