use crate::error::EvalResult;
use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct BankReconciliationThresholds {
pub min_balance_accuracy: f64,
pub min_match_rate: f64,
pub balance_tolerance: f64,
}
impl Default for BankReconciliationThresholds {
fn default() -> Self {
Self {
min_balance_accuracy: 0.99,
min_match_rate: 0.85,
balance_tolerance: 0.01,
}
}
}
#[derive(Debug, Clone)]
pub struct ReconciliationData {
pub reconciliation_id: String,
pub bank_ending_balance: f64,
pub book_ending_balance: f64,
pub reconciling_items_sum: f64,
pub is_completed: bool,
pub total_statement_lines: usize,
pub matched_statement_lines: usize,
pub reconciling_item_count: usize,
pub items_with_descriptions: usize,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct BankReconciliationEvaluation {
pub balance_accuracy: f64,
pub completed_zero_difference_rate: f64,
pub match_rate: f64,
pub reconciling_item_completeness: f64,
pub total_reconciliations: usize,
pub balanced_count: usize,
pub passes: bool,
pub issues: Vec<String>,
}
pub struct BankReconciliationEvaluator {
thresholds: BankReconciliationThresholds,
}
impl BankReconciliationEvaluator {
pub fn new() -> Self {
Self {
thresholds: BankReconciliationThresholds::default(),
}
}
pub fn with_thresholds(thresholds: BankReconciliationThresholds) -> Self {
Self { thresholds }
}
pub fn evaluate(
&self,
reconciliations: &[ReconciliationData],
) -> EvalResult<BankReconciliationEvaluation> {
let mut issues = Vec::new();
let tol = self.thresholds.balance_tolerance;
let total = reconciliations.len();
let balanced_count = reconciliations
.iter()
.filter(|r| {
let adjusted = r.bank_ending_balance + r.reconciling_items_sum;
(adjusted - r.book_ending_balance).abs() <= tol
})
.count();
let balance_accuracy = if total > 0 {
balanced_count as f64 / total as f64
} else {
1.0
};
let completed: Vec<&ReconciliationData> =
reconciliations.iter().filter(|r| r.is_completed).collect();
let completed_zero = completed
.iter()
.filter(|r| {
let adjusted = r.bank_ending_balance + r.reconciling_items_sum;
(adjusted - r.book_ending_balance).abs() <= tol
})
.count();
let completed_zero_difference_rate = if completed.is_empty() {
1.0
} else {
completed_zero as f64 / completed.len() as f64
};
let total_lines: usize = reconciliations
.iter()
.map(|r| r.total_statement_lines)
.sum();
let matched_lines: usize = reconciliations
.iter()
.map(|r| r.matched_statement_lines)
.sum();
let match_rate = if total_lines > 0 {
matched_lines as f64 / total_lines as f64
} else {
1.0
};
let total_items: usize = reconciliations
.iter()
.map(|r| r.reconciling_item_count)
.sum();
let items_with_desc: usize = reconciliations
.iter()
.map(|r| r.items_with_descriptions)
.sum();
let reconciling_item_completeness = if total_items > 0 {
items_with_desc as f64 / total_items as f64
} else {
1.0
};
if balance_accuracy < self.thresholds.min_balance_accuracy {
issues.push(format!(
"Balance accuracy {:.3} < {:.3}",
balance_accuracy, self.thresholds.min_balance_accuracy
));
}
if match_rate < self.thresholds.min_match_rate {
issues.push(format!(
"Match rate {:.3} < {:.3}",
match_rate, self.thresholds.min_match_rate
));
}
let passes = issues.is_empty();
Ok(BankReconciliationEvaluation {
balance_accuracy,
completed_zero_difference_rate,
match_rate,
reconciling_item_completeness,
total_reconciliations: total,
balanced_count,
passes,
issues,
})
}
}
impl Default for BankReconciliationEvaluator {
fn default() -> Self {
Self::new()
}
}
#[cfg(test)]
#[allow(clippy::unwrap_used)]
mod tests {
use super::*;
#[test]
fn test_valid_reconciliation() {
let evaluator = BankReconciliationEvaluator::new();
let data = vec![ReconciliationData {
reconciliation_id: "BR001".to_string(),
bank_ending_balance: 100_000.0,
book_ending_balance: 100_500.0,
reconciling_items_sum: 500.0,
is_completed: true,
total_statement_lines: 50,
matched_statement_lines: 48,
reconciling_item_count: 5,
items_with_descriptions: 5,
}];
let result = evaluator.evaluate(&data).unwrap();
assert!(result.passes);
assert_eq!(result.balance_accuracy, 1.0);
assert_eq!(result.completed_zero_difference_rate, 1.0);
}
#[test]
fn test_imbalanced_reconciliation() {
let evaluator = BankReconciliationEvaluator::new();
let data = vec![ReconciliationData {
reconciliation_id: "BR001".to_string(),
bank_ending_balance: 100_000.0,
book_ending_balance: 110_000.0,
reconciling_items_sum: 500.0, is_completed: true,
total_statement_lines: 50,
matched_statement_lines: 48,
reconciling_item_count: 5,
items_with_descriptions: 5,
}];
let result = evaluator.evaluate(&data).unwrap();
assert!(!result.passes);
assert_eq!(result.balance_accuracy, 0.0);
}
#[test]
fn test_low_match_rate() {
let evaluator = BankReconciliationEvaluator::new();
let data = vec![ReconciliationData {
reconciliation_id: "BR001".to_string(),
bank_ending_balance: 100_000.0,
book_ending_balance: 100_000.0,
reconciling_items_sum: 0.0,
is_completed: true,
total_statement_lines: 100,
matched_statement_lines: 50, reconciling_item_count: 0,
items_with_descriptions: 0,
}];
let result = evaluator.evaluate(&data).unwrap();
assert!(!result.passes);
assert_eq!(result.match_rate, 0.5);
}
#[test]
fn test_empty_data() {
let evaluator = BankReconciliationEvaluator::new();
let result = evaluator.evaluate(&[]).unwrap();
assert!(result.passes);
}
}