Skip to main content

datasynth_eval/coherence/
bank_reconciliation.rs

1//! Bank reconciliation evaluator.
2//!
3//! Validates bank reconciliation coherence including balance equations,
4//! completed reconciliation status, match rates, and reconciling item completeness.
5
6use crate::error::EvalResult;
7use serde::{Deserialize, Serialize};
8
9/// Thresholds for bank reconciliation evaluation.
10#[derive(Debug, Clone, Serialize, Deserialize)]
11pub struct BankReconciliationThresholds {
12    /// Minimum balance accuracy (fraction of reconciliations within tolerance).
13    pub min_balance_accuracy: f64,
14    /// Minimum statement line match rate.
15    pub min_match_rate: f64,
16    /// Tolerance for balance comparisons.
17    pub balance_tolerance: f64,
18}
19
20impl Default for BankReconciliationThresholds {
21    fn default() -> Self {
22        Self {
23            min_balance_accuracy: 0.99,
24            min_match_rate: 0.85,
25            balance_tolerance: 0.01,
26        }
27    }
28}
29
30/// Bank reconciliation data for validation.
31#[derive(Debug, Clone)]
32pub struct ReconciliationData {
33    /// Reconciliation identifier.
34    pub reconciliation_id: String,
35    /// Bank ending balance.
36    pub bank_ending_balance: f64,
37    /// Book ending balance.
38    pub book_ending_balance: f64,
39    /// Sum of reconciling items (adjustments from bank to book).
40    pub reconciling_items_sum: f64,
41    /// Whether the reconciliation is marked as completed.
42    pub is_completed: bool,
43    /// Total statement lines in the period.
44    pub total_statement_lines: usize,
45    /// Matched statement lines.
46    pub matched_statement_lines: usize,
47    /// Number of reconciling items.
48    pub reconciling_item_count: usize,
49    /// Number of reconciling items with descriptions.
50    pub items_with_descriptions: usize,
51}
52
53/// Results of bank reconciliation evaluation.
54#[derive(Debug, Clone, Serialize, Deserialize)]
55pub struct BankReconciliationEvaluation {
56    /// Balance accuracy: fraction of reconciliations where bank_ending + reconciling_items = book_ending.
57    pub balance_accuracy: f64,
58    /// Fraction of completed reconciliations with zero net difference.
59    pub completed_zero_difference_rate: f64,
60    /// Average statement line match rate.
61    pub match_rate: f64,
62    /// Reconciling item completeness (fraction with descriptions).
63    pub reconciling_item_completeness: f64,
64    /// Total reconciliations evaluated.
65    pub total_reconciliations: usize,
66    /// Reconciliations with balance equation satisfied.
67    pub balanced_count: usize,
68    /// Overall pass/fail.
69    pub passes: bool,
70    /// Issues found.
71    pub issues: Vec<String>,
72}
73
74/// Evaluator for bank reconciliation coherence.
75pub struct BankReconciliationEvaluator {
76    thresholds: BankReconciliationThresholds,
77}
78
79impl BankReconciliationEvaluator {
80    /// Create a new evaluator with default thresholds.
81    pub fn new() -> Self {
82        Self {
83            thresholds: BankReconciliationThresholds::default(),
84        }
85    }
86
87    /// Create with custom thresholds.
88    pub fn with_thresholds(thresholds: BankReconciliationThresholds) -> Self {
89        Self { thresholds }
90    }
91
92    /// Evaluate bank reconciliation data.
93    pub fn evaluate(
94        &self,
95        reconciliations: &[ReconciliationData],
96    ) -> EvalResult<BankReconciliationEvaluation> {
97        let mut issues = Vec::new();
98        let tol = self.thresholds.balance_tolerance;
99        let total = reconciliations.len();
100
101        // 1. Balance equation: bank_ending + reconciling_items ≈ book_ending
102        let balanced_count = reconciliations
103            .iter()
104            .filter(|r| {
105                let adjusted = r.bank_ending_balance + r.reconciling_items_sum;
106                (adjusted - r.book_ending_balance).abs() <= tol
107            })
108            .count();
109        let balance_accuracy = if total > 0 {
110            balanced_count as f64 / total as f64
111        } else {
112            1.0
113        };
114
115        // 2. Completed reconciliations should have zero net difference
116        let completed: Vec<&ReconciliationData> =
117            reconciliations.iter().filter(|r| r.is_completed).collect();
118        let completed_zero = completed
119            .iter()
120            .filter(|r| {
121                let adjusted = r.bank_ending_balance + r.reconciling_items_sum;
122                (adjusted - r.book_ending_balance).abs() <= tol
123            })
124            .count();
125        let completed_zero_difference_rate = if completed.is_empty() {
126            1.0
127        } else {
128            completed_zero as f64 / completed.len() as f64
129        };
130
131        // 3. Match rate
132        let total_lines: usize = reconciliations
133            .iter()
134            .map(|r| r.total_statement_lines)
135            .sum();
136        let matched_lines: usize = reconciliations
137            .iter()
138            .map(|r| r.matched_statement_lines)
139            .sum();
140        let match_rate = if total_lines > 0 {
141            matched_lines as f64 / total_lines as f64
142        } else {
143            1.0
144        };
145
146        // 4. Reconciling item completeness
147        let total_items: usize = reconciliations
148            .iter()
149            .map(|r| r.reconciling_item_count)
150            .sum();
151        let items_with_desc: usize = reconciliations
152            .iter()
153            .map(|r| r.items_with_descriptions)
154            .sum();
155        let reconciling_item_completeness = if total_items > 0 {
156            items_with_desc as f64 / total_items as f64
157        } else {
158            1.0
159        };
160
161        // Check thresholds
162        if balance_accuracy < self.thresholds.min_balance_accuracy {
163            issues.push(format!(
164                "Balance accuracy {:.3} < {:.3}",
165                balance_accuracy, self.thresholds.min_balance_accuracy
166            ));
167        }
168        if match_rate < self.thresholds.min_match_rate {
169            issues.push(format!(
170                "Match rate {:.3} < {:.3}",
171                match_rate, self.thresholds.min_match_rate
172            ));
173        }
174
175        let passes = issues.is_empty();
176
177        Ok(BankReconciliationEvaluation {
178            balance_accuracy,
179            completed_zero_difference_rate,
180            match_rate,
181            reconciling_item_completeness,
182            total_reconciliations: total,
183            balanced_count,
184            passes,
185            issues,
186        })
187    }
188}
189
190impl Default for BankReconciliationEvaluator {
191    fn default() -> Self {
192        Self::new()
193    }
194}
195
196#[cfg(test)]
197mod tests {
198    use super::*;
199
200    #[test]
201    fn test_valid_reconciliation() {
202        let evaluator = BankReconciliationEvaluator::new();
203        let data = vec![ReconciliationData {
204            reconciliation_id: "BR001".to_string(),
205            bank_ending_balance: 100_000.0,
206            book_ending_balance: 100_500.0,
207            reconciling_items_sum: 500.0,
208            is_completed: true,
209            total_statement_lines: 50,
210            matched_statement_lines: 48,
211            reconciling_item_count: 5,
212            items_with_descriptions: 5,
213        }];
214
215        let result = evaluator.evaluate(&data).unwrap();
216        assert!(result.passes);
217        assert_eq!(result.balance_accuracy, 1.0);
218        assert_eq!(result.completed_zero_difference_rate, 1.0);
219    }
220
221    #[test]
222    fn test_imbalanced_reconciliation() {
223        let evaluator = BankReconciliationEvaluator::new();
224        let data = vec![ReconciliationData {
225            reconciliation_id: "BR001".to_string(),
226            bank_ending_balance: 100_000.0,
227            book_ending_balance: 110_000.0,
228            reconciling_items_sum: 500.0, // Doesn't bridge the gap
229            is_completed: true,
230            total_statement_lines: 50,
231            matched_statement_lines: 48,
232            reconciling_item_count: 5,
233            items_with_descriptions: 5,
234        }];
235
236        let result = evaluator.evaluate(&data).unwrap();
237        assert!(!result.passes);
238        assert_eq!(result.balance_accuracy, 0.0);
239    }
240
241    #[test]
242    fn test_low_match_rate() {
243        let evaluator = BankReconciliationEvaluator::new();
244        let data = vec![ReconciliationData {
245            reconciliation_id: "BR001".to_string(),
246            bank_ending_balance: 100_000.0,
247            book_ending_balance: 100_000.0,
248            reconciling_items_sum: 0.0,
249            is_completed: true,
250            total_statement_lines: 100,
251            matched_statement_lines: 50, // Only 50% matched
252            reconciling_item_count: 0,
253            items_with_descriptions: 0,
254        }];
255
256        let result = evaluator.evaluate(&data).unwrap();
257        assert!(!result.passes);
258        assert_eq!(result.match_rate, 0.5);
259    }
260
261    #[test]
262    fn test_empty_data() {
263        let evaluator = BankReconciliationEvaluator::new();
264        let result = evaluator.evaluate(&[]).unwrap();
265        assert!(result.passes);
266    }
267}