use rust_decimal::Decimal;
use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum MaterialityBenchmark {
PretaxIncome,
Revenue,
TotalAssets,
Equity,
GrossProfit,
}
impl std::fmt::Display for MaterialityBenchmark {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
let s = match self {
Self::PretaxIncome => "Pre-tax Income",
Self::Revenue => "Revenue",
Self::TotalAssets => "Total Assets",
Self::Equity => "Equity",
Self::GrossProfit => "Gross Profit",
};
write!(f, "{s}")
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum AdjustmentType {
NonRecurring,
Extraordinary,
Reclassification,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct NormalizationAdjustment {
pub description: String,
#[serde(with = "crate::serde_decimal")]
pub amount: Decimal,
pub adjustment_type: AdjustmentType,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct NormalizedEarnings {
#[serde(with = "crate::serde_decimal")]
pub reported_earnings: Decimal,
pub adjustments: Vec<NormalizationAdjustment>,
#[serde(with = "crate::serde_decimal")]
pub normalized_amount: Decimal,
}
impl NormalizedEarnings {
pub fn new(reported_earnings: Decimal, adjustments: Vec<NormalizationAdjustment>) -> Self {
let adj_total: Decimal = adjustments.iter().map(|a| a.amount).sum();
let normalized_amount = reported_earnings + adj_total;
Self {
reported_earnings,
adjustments,
normalized_amount,
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct MaterialityCalculation {
pub entity_code: String,
pub period: String,
pub benchmark: MaterialityBenchmark,
#[serde(with = "crate::serde_decimal")]
pub benchmark_amount: Decimal,
#[serde(with = "crate::serde_decimal")]
pub benchmark_percentage: Decimal,
#[serde(with = "crate::serde_decimal")]
pub overall_materiality: Decimal,
#[serde(with = "crate::serde_decimal")]
pub performance_materiality: Decimal,
#[serde(with = "crate::serde_decimal")]
pub clearly_trivial: Decimal,
#[serde(with = "crate::serde_decimal")]
pub tolerable_error: Decimal,
#[serde(with = "crate::serde_decimal")]
pub sad_nominal: Decimal,
#[serde(skip_serializing_if = "Option::is_none")]
pub normalized_earnings: Option<NormalizedEarnings>,
pub rationale: String,
}
impl MaterialityCalculation {
#[allow(clippy::too_many_arguments)]
pub fn new(
entity_code: &str,
period: &str,
benchmark: MaterialityBenchmark,
benchmark_amount: Decimal,
benchmark_percentage: Decimal,
pm_percentage: Decimal,
normalized_earnings: Option<NormalizedEarnings>,
rationale: &str,
) -> Self {
let overall_materiality = benchmark_amount * benchmark_percentage;
let performance_materiality = overall_materiality * pm_percentage;
let clearly_trivial = overall_materiality * Decimal::new(5, 2); let tolerable_error = performance_materiality;
let sad_nominal = overall_materiality * Decimal::new(5, 2);
Self {
entity_code: entity_code.to_string(),
period: period.to_string(),
benchmark,
benchmark_amount,
benchmark_percentage,
overall_materiality,
performance_materiality,
clearly_trivial,
tolerable_error,
sad_nominal,
normalized_earnings,
rationale: rationale.to_string(),
}
}
}
#[cfg(test)]
#[allow(clippy::unwrap_used)]
mod tests {
use super::*;
use rust_decimal_macros::dec;
#[test]
fn materiality_basic_calculation() {
let calc = MaterialityCalculation::new(
"C001",
"FY2024",
MaterialityBenchmark::PretaxIncome,
dec!(1_000_000),
dec!(0.05),
dec!(0.65),
None,
"5% of pre-tax income — profit-making entity",
);
assert_eq!(calc.overall_materiality, dec!(50_000));
assert_eq!(calc.performance_materiality, dec!(32_500));
assert_eq!(calc.clearly_trivial, dec!(2_500));
assert_eq!(calc.tolerable_error, dec!(32_500));
assert_eq!(calc.sad_nominal, dec!(2_500));
}
#[test]
fn pm_between_50_and_75_percent_of_overall() {
let calc = MaterialityCalculation::new(
"C001",
"FY2024",
MaterialityBenchmark::Revenue,
dec!(10_000_000),
dec!(0.005),
dec!(0.65),
None,
"0.5% of revenue",
);
let overall = calc.overall_materiality;
let pm = calc.performance_materiality;
let ratio = pm / overall;
assert!(ratio >= dec!(0.50), "PM should be >= 50% of overall");
assert!(ratio <= dec!(0.75), "PM should be <= 75% of overall");
}
#[test]
fn clearly_trivial_is_five_percent_of_overall() {
let calc = MaterialityCalculation::new(
"C001",
"FY2024",
MaterialityBenchmark::TotalAssets,
dec!(5_000_000),
dec!(0.005),
dec!(0.65),
None,
"0.5% of total assets",
);
let expected_ct = calc.overall_materiality * dec!(0.05);
assert_eq!(calc.clearly_trivial, expected_ct);
}
#[test]
fn normalized_earnings_adjustments_sum_correctly() {
let adjustments = vec![
NormalizationAdjustment {
description: "Restructuring charge".into(),
amount: dec!(200_000),
adjustment_type: AdjustmentType::NonRecurring,
},
NormalizationAdjustment {
description: "Asset write-off".into(),
amount: dec!(-50_000),
adjustment_type: AdjustmentType::Extraordinary,
},
];
let ne = NormalizedEarnings::new(dec!(800_000), adjustments);
assert_eq!(ne.normalized_amount, dec!(950_000));
}
}