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}