use rust_decimal::Decimal;
use rust_decimal_macros::dec;
use serde::{Deserialize, Serialize};
use super::account_balance::BalanceSnapshot;
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum RelationshipType {
DaysSalesOutstanding,
DaysPayableOutstanding,
DaysInventoryOutstanding,
CashConversionCycle,
GrossMargin,
OperatingMargin,
NetMargin,
CurrentRatio,
QuickRatio,
DebtToEquity,
InterestCoverage,
AssetTurnover,
ReturnOnAssets,
ReturnOnEquity,
DepreciationRate,
BalanceSheetEquation,
RetainedEarningsRollForward,
}
impl RelationshipType {
pub fn display_name(&self) -> &'static str {
match self {
Self::DaysSalesOutstanding => "Days Sales Outstanding",
Self::DaysPayableOutstanding => "Days Payable Outstanding",
Self::DaysInventoryOutstanding => "Days Inventory Outstanding",
Self::CashConversionCycle => "Cash Conversion Cycle",
Self::GrossMargin => "Gross Margin",
Self::OperatingMargin => "Operating Margin",
Self::NetMargin => "Net Margin",
Self::CurrentRatio => "Current Ratio",
Self::QuickRatio => "Quick Ratio",
Self::DebtToEquity => "Debt-to-Equity Ratio",
Self::InterestCoverage => "Interest Coverage Ratio",
Self::AssetTurnover => "Asset Turnover",
Self::ReturnOnAssets => "Return on Assets",
Self::ReturnOnEquity => "Return on Equity",
Self::DepreciationRate => "Depreciation Rate",
Self::BalanceSheetEquation => "Balance Sheet Equation",
Self::RetainedEarningsRollForward => "Retained Earnings Roll-forward",
}
}
pub fn is_critical(&self) -> bool {
matches!(
self,
Self::BalanceSheetEquation | Self::RetainedEarningsRollForward
)
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct BalanceRelationshipRule {
pub rule_id: String,
pub name: String,
pub relationship_type: RelationshipType,
pub target_value: Option<Decimal>,
pub min_value: Option<Decimal>,
pub max_value: Option<Decimal>,
pub tolerance: Decimal,
pub enabled: bool,
pub severity: RuleSeverity,
pub numerator_accounts: Vec<String>,
pub denominator_accounts: Vec<String>,
pub multiplier: Decimal,
}
impl BalanceRelationshipRule {
pub fn new_dso_rule(target_days: u32, tolerance_days: u32) -> Self {
Self {
rule_id: "DSO".to_string(),
name: "Days Sales Outstanding".to_string(),
relationship_type: RelationshipType::DaysSalesOutstanding,
target_value: Some(Decimal::from(target_days)),
min_value: Some(Decimal::from(target_days.saturating_sub(tolerance_days))),
max_value: Some(Decimal::from(target_days + tolerance_days)),
tolerance: Decimal::from(tolerance_days),
enabled: true,
severity: RuleSeverity::Warning,
numerator_accounts: vec!["1200".to_string()], denominator_accounts: vec!["4100".to_string()], multiplier: dec!(365),
}
}
pub fn new_dpo_rule(target_days: u32, tolerance_days: u32) -> Self {
Self {
rule_id: "DPO".to_string(),
name: "Days Payable Outstanding".to_string(),
relationship_type: RelationshipType::DaysPayableOutstanding,
target_value: Some(Decimal::from(target_days)),
min_value: Some(Decimal::from(target_days.saturating_sub(tolerance_days))),
max_value: Some(Decimal::from(target_days + tolerance_days)),
tolerance: Decimal::from(tolerance_days),
enabled: true,
severity: RuleSeverity::Warning,
numerator_accounts: vec!["2100".to_string()], denominator_accounts: vec!["5100".to_string()], multiplier: dec!(365),
}
}
pub fn new_gross_margin_rule(target_margin: Decimal, tolerance: Decimal) -> Self {
Self {
rule_id: "GROSS_MARGIN".to_string(),
name: "Gross Margin".to_string(),
relationship_type: RelationshipType::GrossMargin,
target_value: Some(target_margin),
min_value: Some(target_margin - tolerance),
max_value: Some(target_margin + tolerance),
tolerance,
enabled: true,
severity: RuleSeverity::Warning,
numerator_accounts: vec!["4100".to_string(), "5100".to_string()], denominator_accounts: vec!["4100".to_string()], multiplier: Decimal::ONE,
}
}
pub fn new_balance_equation_rule() -> Self {
Self {
rule_id: "BS_EQUATION".to_string(),
name: "Balance Sheet Equation".to_string(),
relationship_type: RelationshipType::BalanceSheetEquation,
target_value: Some(Decimal::ZERO),
min_value: Some(dec!(-0.01)),
max_value: Some(dec!(0.01)),
tolerance: dec!(0.01),
enabled: true,
severity: RuleSeverity::Critical,
numerator_accounts: Vec::new(),
denominator_accounts: Vec::new(),
multiplier: Decimal::ONE,
}
}
pub fn is_within_range(&self, value: Decimal) -> bool {
let within_min = self.min_value.is_none_or(|min| value >= min);
let within_max = self.max_value.is_none_or(|max| value <= max);
within_min && within_max
}
pub fn deviation_from_target(&self, value: Decimal) -> Option<Decimal> {
self.target_value.map(|target| value - target)
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize, Default)]
#[serde(rename_all = "snake_case")]
pub enum RuleSeverity {
Info,
#[default]
Warning,
Error,
Critical,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ValidationResult {
pub rule_id: String,
pub rule_name: String,
pub relationship_type: RelationshipType,
pub calculated_value: Decimal,
pub target_value: Option<Decimal>,
pub is_valid: bool,
pub deviation: Option<Decimal>,
pub deviation_percent: Option<Decimal>,
pub severity: RuleSeverity,
pub message: String,
}
impl ValidationResult {
pub fn pass(rule: &BalanceRelationshipRule, calculated_value: Decimal) -> Self {
let deviation = rule.deviation_from_target(calculated_value);
let deviation_percent = rule.target_value.and_then(|target| {
if target != Decimal::ZERO {
Some((calculated_value - target) / target * dec!(100))
} else {
None
}
});
Self {
rule_id: rule.rule_id.clone(),
rule_name: rule.name.clone(),
relationship_type: rule.relationship_type,
calculated_value,
target_value: rule.target_value,
is_valid: true,
deviation,
deviation_percent,
severity: RuleSeverity::Info,
message: format!(
"{} = {:.2} (within acceptable range)",
rule.name, calculated_value
),
}
}
pub fn fail(
rule: &BalanceRelationshipRule,
calculated_value: Decimal,
message: String,
) -> Self {
let deviation = rule.deviation_from_target(calculated_value);
let deviation_percent = rule.target_value.and_then(|target| {
if target != Decimal::ZERO {
Some((calculated_value - target) / target * dec!(100))
} else {
None
}
});
Self {
rule_id: rule.rule_id.clone(),
rule_name: rule.name.clone(),
relationship_type: rule.relationship_type,
calculated_value,
target_value: rule.target_value,
is_valid: false,
deviation,
deviation_percent,
severity: rule.severity,
message,
}
}
}
pub struct BalanceCoherenceValidator {
rules: Vec<BalanceRelationshipRule>,
}
impl BalanceCoherenceValidator {
pub fn new() -> Self {
Self { rules: Vec::new() }
}
pub fn add_rule(&mut self, rule: BalanceRelationshipRule) {
self.rules.push(rule);
}
pub fn add_standard_rules(&mut self, target_dso: u32, target_dpo: u32, target_margin: Decimal) {
self.rules
.push(BalanceRelationshipRule::new_dso_rule(target_dso, 10));
self.rules
.push(BalanceRelationshipRule::new_dpo_rule(target_dpo, 10));
self.rules
.push(BalanceRelationshipRule::new_gross_margin_rule(
target_margin,
dec!(0.05),
));
self.rules
.push(BalanceRelationshipRule::new_balance_equation_rule());
}
pub fn validate_snapshot(&self, snapshot: &BalanceSnapshot) -> Vec<ValidationResult> {
let mut results = Vec::new();
for rule in &self.rules {
if !rule.enabled {
continue;
}
let result = self.validate_rule(rule, snapshot);
results.push(result);
}
results
}
fn validate_rule(
&self,
rule: &BalanceRelationshipRule,
snapshot: &BalanceSnapshot,
) -> ValidationResult {
match rule.relationship_type {
RelationshipType::BalanceSheetEquation => {
let equation_diff = snapshot.balance_difference;
if snapshot.is_balanced {
ValidationResult::pass(rule, equation_diff)
} else {
ValidationResult::fail(
rule,
equation_diff,
format!("Balance sheet is out of balance by {equation_diff:.2}"),
)
}
}
RelationshipType::CurrentRatio => {
let current_assets: Decimal = if snapshot.balances.is_empty() {
snapshot.total_assets } else {
snapshot
.balances
.values()
.filter(|b| {
let code = b
.account_code
.trim_start_matches(|c: char| !c.is_ascii_digit());
let digits: String =
code.chars().filter(|c| c.is_ascii_digit()).collect();
if digits.len() >= 2 {
matches!(&digits[..2], "10" | "11" | "12" | "13")
} else {
false
}
})
.map(|b| b.closing_balance)
.sum()
};
let current_liabilities = snapshot.total_liabilities;
if current_liabilities == Decimal::ZERO {
ValidationResult::fail(
rule,
Decimal::ZERO,
"No current liabilities".to_string(),
)
} else {
let ratio = current_assets / current_liabilities;
if rule.is_within_range(ratio) {
ValidationResult::pass(rule, ratio)
} else {
ValidationResult::fail(
rule,
ratio,
format!("Current ratio {ratio:.2} is outside acceptable range"),
)
}
}
}
RelationshipType::DebtToEquity => {
if snapshot.total_equity == Decimal::ZERO {
ValidationResult::fail(rule, Decimal::ZERO, "No equity".to_string())
} else {
let ratio = snapshot.total_liabilities / snapshot.total_equity;
if rule.is_within_range(ratio) {
ValidationResult::pass(rule, ratio)
} else {
ValidationResult::fail(
rule,
ratio,
format!("Debt-to-equity ratio {ratio:.2} is outside acceptable range"),
)
}
}
}
RelationshipType::GrossMargin => {
if snapshot.total_revenue == Decimal::ZERO {
ValidationResult::pass(rule, Decimal::ZERO) } else {
let gross_profit = snapshot.total_revenue - snapshot.total_expenses; let margin = gross_profit / snapshot.total_revenue;
if rule.is_within_range(margin) {
ValidationResult::pass(rule, margin)
} else {
ValidationResult::fail(
rule,
margin,
format!(
"Gross margin {:.1}% is outside target range",
margin * dec!(100)
),
)
}
}
}
_ => {
let numerator: Decimal = rule
.numerator_accounts
.iter()
.filter_map(|code| snapshot.get_balance(code))
.map(|b| b.closing_balance)
.sum();
let denominator: Decimal = rule
.denominator_accounts
.iter()
.filter_map(|code| snapshot.get_balance(code))
.map(|b| b.closing_balance)
.sum();
if denominator == Decimal::ZERO {
ValidationResult::fail(rule, Decimal::ZERO, "Denominator is zero".to_string())
} else {
let value = numerator / denominator * rule.multiplier;
if rule.is_within_range(value) {
ValidationResult::pass(rule, value)
} else {
ValidationResult::fail(
rule,
value,
format!("{} = {:.2} is outside acceptable range", rule.name, value),
)
}
}
}
}
}
pub fn summarize_results(results: &[ValidationResult]) -> ValidationSummary {
let total = results.len();
let passed = results.iter().filter(|r| r.is_valid).count();
let failed = total - passed;
let critical_failures = results
.iter()
.filter(|r| !r.is_valid && r.severity == RuleSeverity::Critical)
.count();
let error_failures = results
.iter()
.filter(|r| !r.is_valid && r.severity == RuleSeverity::Error)
.count();
let warning_failures = results
.iter()
.filter(|r| !r.is_valid && r.severity == RuleSeverity::Warning)
.count();
ValidationSummary {
total_rules: total,
passed,
failed,
critical_failures,
error_failures,
warning_failures,
is_coherent: critical_failures == 0,
}
}
}
impl Default for BalanceCoherenceValidator {
fn default() -> Self {
Self::new()
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ValidationSummary {
pub total_rules: usize,
pub passed: usize,
pub failed: usize,
pub critical_failures: usize,
pub error_failures: usize,
pub warning_failures: usize,
pub is_coherent: bool,
}
#[derive(Debug, Clone, Default)]
pub struct AccountGroups {
pub current_assets: Vec<String>,
pub non_current_assets: Vec<String>,
pub current_liabilities: Vec<String>,
pub non_current_liabilities: Vec<String>,
pub equity: Vec<String>,
pub revenue: Vec<String>,
pub cogs: Vec<String>,
pub operating_expenses: Vec<String>,
pub accounts_receivable: Vec<String>,
pub accounts_payable: Vec<String>,
pub inventory: Vec<String>,
pub fixed_assets: Vec<String>,
pub accumulated_depreciation: Vec<String>,
}
pub fn calculate_dso(ar_balance: Decimal, annual_revenue: Decimal) -> Option<Decimal> {
if annual_revenue == Decimal::ZERO {
None
} else {
Some(ar_balance / annual_revenue * dec!(365))
}
}
pub fn calculate_dpo(ap_balance: Decimal, annual_cogs: Decimal) -> Option<Decimal> {
if annual_cogs == Decimal::ZERO {
None
} else {
Some(ap_balance / annual_cogs * dec!(365))
}
}
pub fn calculate_dio(inventory_balance: Decimal, annual_cogs: Decimal) -> Option<Decimal> {
if annual_cogs == Decimal::ZERO {
None
} else {
Some(inventory_balance / annual_cogs * dec!(365))
}
}
pub fn calculate_ccc(dso: Decimal, dio: Decimal, dpo: Decimal) -> Decimal {
dso + dio - dpo
}
pub fn calculate_gross_margin(revenue: Decimal, cogs: Decimal) -> Option<Decimal> {
if revenue == Decimal::ZERO {
None
} else {
Some((revenue - cogs) / revenue)
}
}
pub fn calculate_operating_margin(revenue: Decimal, operating_income: Decimal) -> Option<Decimal> {
if revenue == Decimal::ZERO {
None
} else {
Some(operating_income / revenue)
}
}
#[cfg(test)]
#[allow(clippy::unwrap_used)]
mod tests {
use super::*;
#[test]
fn test_dso_calculation() {
let ar = dec!(123288); let revenue = dec!(1000000);
let dso = calculate_dso(ar, revenue).unwrap();
assert!((dso - dec!(45)).abs() < dec!(1));
}
#[test]
fn test_dpo_calculation() {
let ap = dec!(58904); let cogs = dec!(650000);
let dpo = calculate_dpo(ap, cogs).unwrap();
assert!((dpo - dec!(33)).abs() < dec!(2));
}
#[test]
fn test_gross_margin_calculation() {
let revenue = dec!(1000000);
let cogs = dec!(650000);
let margin = calculate_gross_margin(revenue, cogs).unwrap();
assert_eq!(margin, dec!(0.35));
}
#[test]
fn test_ccc_calculation() {
let dso = dec!(45);
let dio = dec!(60);
let dpo = dec!(30);
let ccc = calculate_ccc(dso, dio, dpo);
assert_eq!(ccc, dec!(75));
}
#[test]
fn test_dso_rule() {
let rule = BalanceRelationshipRule::new_dso_rule(45, 10);
assert!(rule.is_within_range(dec!(45)));
assert!(rule.is_within_range(dec!(35)));
assert!(rule.is_within_range(dec!(55)));
assert!(!rule.is_within_range(dec!(30)));
assert!(!rule.is_within_range(dec!(60)));
}
#[test]
fn test_gross_margin_rule() {
let rule = BalanceRelationshipRule::new_gross_margin_rule(dec!(0.35), dec!(0.05));
assert!(rule.is_within_range(dec!(0.35)));
assert!(rule.is_within_range(dec!(0.30)));
assert!(rule.is_within_range(dec!(0.40)));
assert!(!rule.is_within_range(dec!(0.25)));
assert!(!rule.is_within_range(dec!(0.45)));
}
#[test]
fn test_validation_summary() {
let rule1 = BalanceRelationshipRule::new_balance_equation_rule();
let rule2 = BalanceRelationshipRule::new_dso_rule(45, 10);
let results = vec![
ValidationResult::pass(&rule1, Decimal::ZERO),
ValidationResult::fail(&rule2, dec!(60), "DSO too high".to_string()),
];
let summary = BalanceCoherenceValidator::summarize_results(&results);
assert_eq!(summary.total_rules, 2);
assert_eq!(summary.passed, 1);
assert_eq!(summary.failed, 1);
assert!(summary.is_coherent); }
}