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::RngExt;
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)]
361mod tests {
362    use super::*;
363
364    fn sample_input() -> MaterialityInput {
365        MaterialityInput {
366            entity_code: "C001".into(),
367            period: "FY2024".into(),
368            revenue: dec!(10_000_000),
369            pretax_income: dec!(1_000_000),
370            total_assets: dec!(8_000_000),
371            equity: dec!(4_000_000),
372            gross_profit: dec!(3_500_000),
373        }
374    }
375
376    #[test]
377    fn materiality_formula_holds() {
378        let mut gen = MaterialityGenerator::new(42);
379        let calc = gen.generate(&sample_input());
380        // overall_materiality = benchmark_amount × benchmark_percentage
381        let expected = (calc.benchmark_amount * calc.benchmark_percentage).round_dp(10);
382        assert_eq!(
383            calc.overall_materiality.round_dp(10),
384            expected,
385            "overall_materiality must equal benchmark_amount × benchmark_percentage"
386        );
387    }
388
389    #[test]
390    fn pm_is_between_50_and_75_percent_of_overall() {
391        let mut gen = MaterialityGenerator::new(42);
392        let calc = gen.generate(&sample_input());
393        let ratio = calc.performance_materiality / calc.overall_materiality;
394        assert!(
395            ratio >= dec!(0.50),
396            "PM ({}) < 50% of overall ({})",
397            calc.performance_materiality,
398            calc.overall_materiality
399        );
400        assert!(
401            ratio <= dec!(0.75),
402            "PM ({}) > 75% of overall ({})",
403            calc.performance_materiality,
404            calc.overall_materiality
405        );
406    }
407
408    #[test]
409    fn clearly_trivial_is_five_percent_of_overall() {
410        let mut gen = MaterialityGenerator::new(42);
411        let calc = gen.generate(&sample_input());
412        let expected_ct = calc.overall_materiality * dec!(0.05);
413        assert_eq!(calc.clearly_trivial, expected_ct);
414    }
415
416    #[test]
417    fn sad_nominal_is_five_percent_of_overall() {
418        let mut gen = MaterialityGenerator::new(42);
419        let calc = gen.generate(&sample_input());
420        // SAD nominal = 5% of overall materiality (ISA 450 guidance).
421        let expected = calc.overall_materiality * dec!(0.05);
422        assert_eq!(calc.sad_nominal, expected);
423    }
424
425    #[test]
426    fn minimum_materiality_floor_applied() {
427        let mut gen = MaterialityGenerator::new(42);
428        let tiny_input = MaterialityInput {
429            entity_code: "TINY".into(),
430            period: "FY2024".into(),
431            revenue: dec!(10_000),
432            pretax_income: dec!(500),
433            total_assets: dec!(5_000),
434            equity: dec!(2_000),
435            gross_profit: dec!(2_000),
436        };
437        let calc = gen.generate(&tiny_input);
438        assert!(
439            calc.overall_materiality >= dec!(5_000),
440            "Minimum floor should apply; got {}",
441            calc.overall_materiality
442        );
443    }
444
445    #[test]
446    fn asset_heavy_entity_uses_total_assets() {
447        let mut gen = MaterialityGenerator::new(42);
448        let asset_input = MaterialityInput {
449            entity_code: "BANK".into(),
450            period: "FY2024".into(),
451            revenue: dec!(1_000_000),
452            pretax_income: dec!(200_000),
453            total_assets: dec!(50_000_000), // 50× revenue → asset-heavy
454            equity: dec!(5_000_000),
455            gross_profit: dec!(800_000),
456        };
457        let calc = gen.generate(&asset_input);
458        assert_eq!(
459            calc.benchmark,
460            MaterialityBenchmark::TotalAssets,
461            "Asset-heavy entity should use TotalAssets benchmark"
462        );
463    }
464
465    #[test]
466    fn loss_making_entity_uses_revenue() {
467        let mut gen = MaterialityGenerator::new(42);
468        let loss_input = MaterialityInput {
469            entity_code: "LOSS".into(),
470            period: "FY2024".into(),
471            revenue: dec!(5_000_000),
472            pretax_income: dec!(-200_000),
473            total_assets: dec!(3_000_000),
474            equity: dec!(1_000_000),
475            gross_profit: dec!(500_000),
476        };
477        let calc = gen.generate(&loss_input);
478        assert_eq!(
479            calc.benchmark,
480            MaterialityBenchmark::Revenue,
481            "Loss-making entity should use Revenue benchmark"
482        );
483    }
484}