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)]
197#[allow(clippy::unwrap_used)]
198mod tests {
199    use super::*;
200
201    #[test]
202    fn test_valid_reconciliation() {
203        let evaluator = BankReconciliationEvaluator::new();
204        let data = vec![ReconciliationData {
205            reconciliation_id: "BR001".to_string(),
206            bank_ending_balance: 100_000.0,
207            book_ending_balance: 100_500.0,
208            reconciling_items_sum: 500.0,
209            is_completed: true,
210            total_statement_lines: 50,
211            matched_statement_lines: 48,
212            reconciling_item_count: 5,
213            items_with_descriptions: 5,
214        }];
215
216        let result = evaluator.evaluate(&data).unwrap();
217        assert!(result.passes);
218        assert_eq!(result.balance_accuracy, 1.0);
219        assert_eq!(result.completed_zero_difference_rate, 1.0);
220    }
221
222    #[test]
223    fn test_imbalanced_reconciliation() {
224        let evaluator = BankReconciliationEvaluator::new();
225        let data = vec![ReconciliationData {
226            reconciliation_id: "BR001".to_string(),
227            bank_ending_balance: 100_000.0,
228            book_ending_balance: 110_000.0,
229            reconciling_items_sum: 500.0, // Doesn't bridge the gap
230            is_completed: true,
231            total_statement_lines: 50,
232            matched_statement_lines: 48,
233            reconciling_item_count: 5,
234            items_with_descriptions: 5,
235        }];
236
237        let result = evaluator.evaluate(&data).unwrap();
238        assert!(!result.passes);
239        assert_eq!(result.balance_accuracy, 0.0);
240    }
241
242    #[test]
243    fn test_low_match_rate() {
244        let evaluator = BankReconciliationEvaluator::new();
245        let data = vec![ReconciliationData {
246            reconciliation_id: "BR001".to_string(),
247            bank_ending_balance: 100_000.0,
248            book_ending_balance: 100_000.0,
249            reconciling_items_sum: 0.0,
250            is_completed: true,
251            total_statement_lines: 100,
252            matched_statement_lines: 50, // Only 50% matched
253            reconciling_item_count: 0,
254            items_with_descriptions: 0,
255        }];
256
257        let result = evaluator.evaluate(&data).unwrap();
258        assert!(!result.passes);
259        assert_eq!(result.match_rate, 0.5);
260    }
261
262    #[test]
263    fn test_empty_data() {
264        let evaluator = BankReconciliationEvaluator::new();
265        let result = evaluator.evaluate(&[]).unwrap();
266        assert!(result.passes);
267    }
268}