datasynth_eval/coherence/
bank_reconciliation.rs1use crate::error::EvalResult;
7use serde::{Deserialize, Serialize};
8
9#[derive(Debug, Clone, Serialize, Deserialize)]
11pub struct BankReconciliationThresholds {
12 pub min_balance_accuracy: f64,
14 pub min_match_rate: f64,
16 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#[derive(Debug, Clone)]
32pub struct ReconciliationData {
33 pub reconciliation_id: String,
35 pub bank_ending_balance: f64,
37 pub book_ending_balance: f64,
39 pub reconciling_items_sum: f64,
41 pub is_completed: bool,
43 pub total_statement_lines: usize,
45 pub matched_statement_lines: usize,
47 pub reconciling_item_count: usize,
49 pub items_with_descriptions: usize,
51}
52
53#[derive(Debug, Clone, Serialize, Deserialize)]
55pub struct BankReconciliationEvaluation {
56 pub balance_accuracy: f64,
58 pub completed_zero_difference_rate: f64,
60 pub match_rate: f64,
62 pub reconciling_item_completeness: f64,
64 pub total_reconciliations: usize,
66 pub balanced_count: usize,
68 pub passes: bool,
70 pub issues: Vec<String>,
72}
73
74pub struct BankReconciliationEvaluator {
76 thresholds: BankReconciliationThresholds,
77}
78
79impl BankReconciliationEvaluator {
80 pub fn new() -> Self {
82 Self {
83 thresholds: BankReconciliationThresholds::default(),
84 }
85 }
86
87 pub fn with_thresholds(thresholds: BankReconciliationThresholds) -> Self {
89 Self { thresholds }
90 }
91
92 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 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 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 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 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 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, 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, 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}