Skip to main content

datasynth_core/models/audit/
materiality_calculation.rs

1//! Materiality benchmark calculation models.
2//!
3//! Materiality is set at the planning stage per ISA 320 and is used to:
4//! - Design audit procedures (performance materiality drives sample sizes)
5//! - Evaluate whether uncorrected misstatements are material (SAD threshold)
6//! - Determine whether items are clearly trivial (no further consideration)
7//!
8//! References:
9//! - ISA 320 — Materiality in Planning and Performing an Audit
10//! - ISA 450 — Evaluation of Misstatements Identified during the Audit
11
12use rust_decimal::Decimal;
13use serde::{Deserialize, Serialize};
14
15// ---------------------------------------------------------------------------
16// Benchmark selection
17// ---------------------------------------------------------------------------
18
19/// Benchmark used to derive overall materiality.
20///
21/// The appropriate benchmark depends on the entity's nature, the users of
22/// the financial statements, and the stability/relevance of the benchmark.
23#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
24#[serde(rename_all = "snake_case")]
25pub enum MaterialityBenchmark {
26    /// Pre-tax income (profit-making entities, 3–7% range).
27    PretaxIncome,
28    /// Revenue (thin-margin entities or revenue-focused users, 0.5–1% range).
29    Revenue,
30    /// Total assets (asset-heavy industries, 0.5–1% range).
31    TotalAssets,
32    /// Equity (equity-focused users or non-profit entities, 1–2% range).
33    Equity,
34    /// Gross profit (manufacturing/retail with thin net margins).
35    GrossProfit,
36}
37
38impl std::fmt::Display for MaterialityBenchmark {
39    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
40        let s = match self {
41            Self::PretaxIncome => "Pre-tax Income",
42            Self::Revenue => "Revenue",
43            Self::TotalAssets => "Total Assets",
44            Self::Equity => "Equity",
45            Self::GrossProfit => "Gross Profit",
46        };
47        write!(f, "{s}")
48    }
49}
50
51// ---------------------------------------------------------------------------
52// Normalized earnings
53// ---------------------------------------------------------------------------
54
55/// Type of normalization adjustment applied to reported earnings.
56#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
57#[serde(rename_all = "snake_case")]
58pub enum AdjustmentType {
59    /// Non-recurring item (restructuring, write-off, etc.).
60    NonRecurring,
61    /// Extraordinary item (rare, unusual, material by nature).
62    Extraordinary,
63    /// Reclassification between income statement line items.
64    Reclassification,
65}
66
67/// A single normalization adjustment to reported earnings.
68#[derive(Debug, Clone, Serialize, Deserialize)]
69pub struct NormalizationAdjustment {
70    /// Human-readable description (e.g. "Restructuring charge — one-time Q3").
71    pub description: String,
72    /// Amount of the adjustment (positive = increases earnings, negative = decreases).
73    #[serde(with = "rust_decimal::serde::str")]
74    pub amount: Decimal,
75    /// Category of adjustment.
76    pub adjustment_type: AdjustmentType,
77}
78
79/// Normalized earnings schedule — strips non-recurring items from reported
80/// earnings to arrive at a "run-rate" figure used as the materiality base.
81#[derive(Debug, Clone, Serialize, Deserialize)]
82pub struct NormalizedEarnings {
83    /// Reported (unadjusted) earnings from the income statement.
84    #[serde(with = "rust_decimal::serde::str")]
85    pub reported_earnings: Decimal,
86    /// Adjustments applied to arrive at normalized earnings.
87    pub adjustments: Vec<NormalizationAdjustment>,
88    /// Normalized earnings = reported + sum(adjustments).
89    #[serde(with = "rust_decimal::serde::str")]
90    pub normalized_amount: Decimal,
91}
92
93impl NormalizedEarnings {
94    /// Construct and verify the normalized total from reported earnings and adjustments.
95    pub fn new(reported_earnings: Decimal, adjustments: Vec<NormalizationAdjustment>) -> Self {
96        let adj_total: Decimal = adjustments.iter().map(|a| a.amount).sum();
97        let normalized_amount = reported_earnings + adj_total;
98        Self {
99            reported_earnings,
100            adjustments,
101            normalized_amount,
102        }
103    }
104}
105
106// ---------------------------------------------------------------------------
107// Main struct
108// ---------------------------------------------------------------------------
109
110/// Materiality calculation for a single entity and reporting period.
111///
112/// Generated once per entity per period.  All monetary amounts are in the
113/// entity's functional currency.
114#[derive(Debug, Clone, Serialize, Deserialize)]
115pub struct MaterialityCalculation {
116    /// Entity / company code.
117    pub entity_code: String,
118    /// Human-readable period descriptor (e.g. "FY2024").
119    pub period: String,
120    /// Benchmark selected for this entity.
121    pub benchmark: MaterialityBenchmark,
122    /// Raw benchmark amount drawn from financial data.
123    #[serde(with = "rust_decimal::serde::str")]
124    pub benchmark_amount: Decimal,
125    /// Percentage applied to the benchmark (e.g. 0.05 for 5%).
126    #[serde(with = "rust_decimal::serde::str")]
127    pub benchmark_percentage: Decimal,
128    /// Overall materiality = benchmark_amount × benchmark_percentage.
129    #[serde(with = "rust_decimal::serde::str")]
130    pub overall_materiality: Decimal,
131    /// Performance materiality (typically 50–75% of overall; default 65%).
132    /// Used to reduce the risk that aggregate uncorrected misstatements exceed
133    /// overall materiality (ISA 320.11).
134    #[serde(with = "rust_decimal::serde::str")]
135    pub performance_materiality: Decimal,
136    /// Clearly trivial threshold (typically 5% of overall).
137    /// Misstatements below this amount need not be accumulated (ISA 450.A2).
138    #[serde(with = "rust_decimal::serde::str")]
139    pub clearly_trivial: Decimal,
140    /// Tolerable error — equals performance materiality for sampling purposes.
141    #[serde(with = "rust_decimal::serde::str")]
142    pub tolerable_error: Decimal,
143    /// Summary of Audit Differences (SAD) nominal threshold — misstatements
144    /// below this amount need not be individually tracked in the SAD schedule.
145    /// Set to 5% of overall materiality per common practice (ISA 450).
146    #[serde(with = "rust_decimal::serde::str")]
147    pub sad_nominal: Decimal,
148    /// Optional normalized earnings schedule (generated when reported earnings
149    /// are unusual or volatile).
150    #[serde(skip_serializing_if = "Option::is_none")]
151    pub normalized_earnings: Option<NormalizedEarnings>,
152    /// Auditor's narrative rationale for the benchmark choice.
153    pub rationale: String,
154}
155
156impl MaterialityCalculation {
157    /// Derive the computed amounts from the supplied inputs.
158    ///
159    /// # Arguments
160    /// * `entity_code` — Entity identifier.
161    /// * `period` — Period descriptor.
162    /// * `benchmark` — Chosen benchmark type.
163    /// * `benchmark_amount` — Raw benchmark figure.
164    /// * `benchmark_percentage` — Decimal fraction to apply (e.g. `dec!(0.05)` for 5%).
165    /// * `pm_percentage` — Performance materiality as fraction of overall (e.g. `dec!(0.65)`).
166    /// * `normalized_earnings` — Optional normalized earnings schedule.
167    /// * `rationale` — Free-text rationale for the benchmark selection.
168    #[allow(clippy::too_many_arguments)]
169    pub fn new(
170        entity_code: &str,
171        period: &str,
172        benchmark: MaterialityBenchmark,
173        benchmark_amount: Decimal,
174        benchmark_percentage: Decimal,
175        pm_percentage: Decimal,
176        normalized_earnings: Option<NormalizedEarnings>,
177        rationale: &str,
178    ) -> Self {
179        let overall_materiality = benchmark_amount * benchmark_percentage;
180        let performance_materiality = overall_materiality * pm_percentage;
181        let clearly_trivial = overall_materiality * Decimal::new(5, 2); // 5%
182        let tolerable_error = performance_materiality;
183        // SAD nominal = 5% of overall materiality (common professional practice).
184        // Misstatements below this threshold need not be individually accumulated
185        // in the Summary of Audit Differences schedule.
186        let sad_nominal = overall_materiality * Decimal::new(5, 2); // 5% of OM
187
188        Self {
189            entity_code: entity_code.to_string(),
190            period: period.to_string(),
191            benchmark,
192            benchmark_amount,
193            benchmark_percentage,
194            overall_materiality,
195            performance_materiality,
196            clearly_trivial,
197            tolerable_error,
198            sad_nominal,
199            normalized_earnings,
200            rationale: rationale.to_string(),
201        }
202    }
203}
204
205// ---------------------------------------------------------------------------
206// Tests
207// ---------------------------------------------------------------------------
208
209#[cfg(test)]
210#[allow(clippy::unwrap_used)]
211mod tests {
212    use super::*;
213    use rust_decimal_macros::dec;
214
215    #[test]
216    fn materiality_basic_calculation() {
217        let calc = MaterialityCalculation::new(
218            "C001",
219            "FY2024",
220            MaterialityBenchmark::PretaxIncome,
221            dec!(1_000_000),
222            dec!(0.05),
223            dec!(0.65),
224            None,
225            "5% of pre-tax income — profit-making entity",
226        );
227        assert_eq!(calc.overall_materiality, dec!(50_000));
228        assert_eq!(calc.performance_materiality, dec!(32_500));
229        assert_eq!(calc.clearly_trivial, dec!(2_500));
230        assert_eq!(calc.tolerable_error, dec!(32_500));
231        // SAD nominal = 5% of overall materiality = 2,500
232        assert_eq!(calc.sad_nominal, dec!(2_500));
233    }
234
235    #[test]
236    fn pm_between_50_and_75_percent_of_overall() {
237        let calc = MaterialityCalculation::new(
238            "C001",
239            "FY2024",
240            MaterialityBenchmark::Revenue,
241            dec!(10_000_000),
242            dec!(0.005),
243            dec!(0.65),
244            None,
245            "0.5% of revenue",
246        );
247        let overall = calc.overall_materiality;
248        let pm = calc.performance_materiality;
249        let ratio = pm / overall;
250        assert!(ratio >= dec!(0.50), "PM should be >= 50% of overall");
251        assert!(ratio <= dec!(0.75), "PM should be <= 75% of overall");
252    }
253
254    #[test]
255    fn clearly_trivial_is_five_percent_of_overall() {
256        let calc = MaterialityCalculation::new(
257            "C001",
258            "FY2024",
259            MaterialityBenchmark::TotalAssets,
260            dec!(5_000_000),
261            dec!(0.005),
262            dec!(0.65),
263            None,
264            "0.5% of total assets",
265        );
266        let expected_ct = calc.overall_materiality * dec!(0.05);
267        assert_eq!(calc.clearly_trivial, expected_ct);
268    }
269
270    #[test]
271    fn normalized_earnings_adjustments_sum_correctly() {
272        let adjustments = vec![
273            NormalizationAdjustment {
274                description: "Restructuring charge".into(),
275                amount: dec!(200_000),
276                adjustment_type: AdjustmentType::NonRecurring,
277            },
278            NormalizationAdjustment {
279                description: "Asset write-off".into(),
280                amount: dec!(-50_000),
281                adjustment_type: AdjustmentType::Extraordinary,
282            },
283        ];
284        let ne = NormalizedEarnings::new(dec!(800_000), adjustments);
285        assert_eq!(ne.normalized_amount, dec!(950_000));
286    }
287}