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 = "crate::serde_decimal")]
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 = "crate::serde_decimal")]
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 = "crate::serde_decimal")]
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 = "crate::serde_decimal")]
124 pub benchmark_amount: Decimal,
125 /// Percentage applied to the benchmark (e.g. 0.05 for 5%).
126 #[serde(with = "crate::serde_decimal")]
127 pub benchmark_percentage: Decimal,
128 /// Overall materiality = benchmark_amount × benchmark_percentage.
129 #[serde(with = "crate::serde_decimal")]
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 = "crate::serde_decimal")]
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 = "crate::serde_decimal")]
139 pub clearly_trivial: Decimal,
140 /// Tolerable error — equals performance materiality for sampling purposes.
141 #[serde(with = "crate::serde_decimal")]
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 = "crate::serde_decimal")]
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)]
210mod tests {
211 use super::*;
212 use rust_decimal_macros::dec;
213
214 #[test]
215 fn materiality_basic_calculation() {
216 let calc = MaterialityCalculation::new(
217 "C001",
218 "FY2024",
219 MaterialityBenchmark::PretaxIncome,
220 dec!(1_000_000),
221 dec!(0.05),
222 dec!(0.65),
223 None,
224 "5% of pre-tax income — profit-making entity",
225 );
226 assert_eq!(calc.overall_materiality, dec!(50_000));
227 assert_eq!(calc.performance_materiality, dec!(32_500));
228 assert_eq!(calc.clearly_trivial, dec!(2_500));
229 assert_eq!(calc.tolerable_error, dec!(32_500));
230 // SAD nominal = 5% of overall materiality = 2,500
231 assert_eq!(calc.sad_nominal, dec!(2_500));
232 }
233
234 #[test]
235 fn pm_between_50_and_75_percent_of_overall() {
236 let calc = MaterialityCalculation::new(
237 "C001",
238 "FY2024",
239 MaterialityBenchmark::Revenue,
240 dec!(10_000_000),
241 dec!(0.005),
242 dec!(0.65),
243 None,
244 "0.5% of revenue",
245 );
246 let overall = calc.overall_materiality;
247 let pm = calc.performance_materiality;
248 let ratio = pm / overall;
249 assert!(ratio >= dec!(0.50), "PM should be >= 50% of overall");
250 assert!(ratio <= dec!(0.75), "PM should be <= 75% of overall");
251 }
252
253 #[test]
254 fn clearly_trivial_is_five_percent_of_overall() {
255 let calc = MaterialityCalculation::new(
256 "C001",
257 "FY2024",
258 MaterialityBenchmark::TotalAssets,
259 dec!(5_000_000),
260 dec!(0.005),
261 dec!(0.65),
262 None,
263 "0.5% of total assets",
264 );
265 let expected_ct = calc.overall_materiality * dec!(0.05);
266 assert_eq!(calc.clearly_trivial, expected_ct);
267 }
268
269 #[test]
270 fn normalized_earnings_adjustments_sum_correctly() {
271 let adjustments = vec![
272 NormalizationAdjustment {
273 description: "Restructuring charge".into(),
274 amount: dec!(200_000),
275 adjustment_type: AdjustmentType::NonRecurring,
276 },
277 NormalizationAdjustment {
278 description: "Asset write-off".into(),
279 amount: dec!(-50_000),
280 adjustment_type: AdjustmentType::Extraordinary,
281 },
282 ];
283 let ne = NormalizedEarnings::new(dec!(800_000), adjustments);
284 assert_eq!(ne.normalized_amount, dec!(950_000));
285 }
286}