1use std::collections::{HashMap, HashSet};
11
12use serde::{Deserialize, Serialize};
13
14use crate::error::EvalResult;
15
16#[derive(Debug, Clone)]
18pub struct PaymentRef {
19 pub payment_id: String,
20 pub amount: f64,
21 pub is_fraud: bool,
22 pub journal_entry_id: Option<String>,
23}
24
25#[derive(Debug, Clone)]
27pub struct BankTxnLinks {
28 pub transaction_id: String,
29 pub source_payment_id: Option<String>,
30 pub source_invoice_id: Option<String>,
31 pub journal_entry_id: Option<String>,
32 pub gl_cash_account: Option<String>,
33 pub is_suspicious: bool,
34 pub is_outbound: bool,
35 pub amount: f64,
36 pub parent_transaction_id: Option<String>,
37}
38
39#[derive(Debug, Clone, Serialize, Deserialize)]
41pub struct CrossLayerThresholds {
42 pub max_dangling_payment_rate: f64,
44 pub min_fraud_propagation_rate: f64,
46 pub max_missing_gl_rate: f64,
48 pub max_amount_deviation: f64,
50}
51
52impl Default for CrossLayerThresholds {
53 fn default() -> Self {
54 Self {
55 max_dangling_payment_rate: 0.0,
56 min_fraud_propagation_rate: 0.95,
57 max_missing_gl_rate: 0.01,
58 max_amount_deviation: 0.01,
59 }
60 }
61}
62
63#[derive(Debug, Clone, Serialize, Deserialize)]
65pub struct CrossLayerCoherenceAnalysis {
66 pub total_bank_transactions: usize,
68 pub bridged_transactions: usize,
70 pub dangling_payment_refs: usize,
72 pub unpropagated_fraud_payments: usize,
74 pub total_fraud_payments: usize,
76 pub missing_gl_account: usize,
78 pub amount_mismatches: usize,
80 pub mirror_transactions: usize,
82 pub fraud_propagation_rate: f64,
84 pub passes: bool,
86 pub issues: Vec<String>,
87}
88
89pub struct CrossLayerCoherenceAnalyzer {
91 pub thresholds: CrossLayerThresholds,
92}
93
94impl CrossLayerCoherenceAnalyzer {
95 pub fn new() -> Self {
96 Self {
97 thresholds: CrossLayerThresholds::default(),
98 }
99 }
100
101 pub fn with_thresholds(thresholds: CrossLayerThresholds) -> Self {
102 Self { thresholds }
103 }
104
105 pub fn analyze(
107 &self,
108 payments: &[PaymentRef],
109 bank_txns: &[BankTxnLinks],
110 ) -> EvalResult<CrossLayerCoherenceAnalysis> {
111 let payment_by_id: HashMap<&str, &PaymentRef> = payments
112 .iter()
113 .map(|p| (p.payment_id.as_str(), p))
114 .collect();
115 let total_fraud_payments = payments.iter().filter(|p| p.is_fraud).count();
116
117 let mut bridged_count = 0usize;
118 let mut dangling = 0usize;
119 let mut missing_gl = 0usize;
120 let mut mismatches = 0usize;
121 let mut mirror_count = 0usize;
122 let mut fraud_payments_with_suspicious_txn: HashSet<&str> = HashSet::new();
124
125 for txn in bank_txns {
126 if txn.parent_transaction_id.is_some() {
127 mirror_count += 1;
128 }
129 let Some(ref pid) = txn.source_payment_id else {
130 continue;
131 };
132 bridged_count += 1;
133
134 match payment_by_id.get(pid.as_str()) {
135 None => {
136 dangling += 1;
137 }
138 Some(payment) => {
139 let deviation =
141 (payment.amount - txn.amount).abs() / payment.amount.abs().max(1.0);
142 if deviation > self.thresholds.max_amount_deviation {
143 mismatches += 1;
144 }
145 if payment.is_fraud && txn.is_suspicious {
147 fraud_payments_with_suspicious_txn.insert(pid.as_str());
148 }
149 }
150 }
151
152 if txn.gl_cash_account.is_none() {
153 missing_gl += 1;
154 }
155 }
156
157 let unpropagated_fraud_payments =
158 total_fraud_payments.saturating_sub(fraud_payments_with_suspicious_txn.len());
159
160 let fraud_propagation_rate = if total_fraud_payments > 0 {
161 fraud_payments_with_suspicious_txn.len() as f64 / total_fraud_payments as f64
162 } else {
163 1.0
164 };
165
166 let dangling_rate = if bridged_count > 0 {
167 dangling as f64 / bridged_count as f64
168 } else {
169 0.0
170 };
171 let missing_gl_rate = if bridged_count > 0 {
172 missing_gl as f64 / bridged_count as f64
173 } else {
174 0.0
175 };
176
177 let mut issues = Vec::new();
178 if dangling_rate > self.thresholds.max_dangling_payment_rate {
179 issues.push(format!(
180 "{dangling} bridged bank transactions reference non-existent payments ({:.2}%)",
181 dangling_rate * 100.0
182 ));
183 }
184 if total_fraud_payments > 0
185 && fraud_propagation_rate < self.thresholds.min_fraud_propagation_rate
186 {
187 issues.push(format!(
188 "Fraud propagation rate {:.1}% below minimum {:.1}% ({} of {} fraud payments had no suspicious bank txn)",
189 fraud_propagation_rate * 100.0,
190 self.thresholds.min_fraud_propagation_rate * 100.0,
191 unpropagated_fraud_payments,
192 total_fraud_payments,
193 ));
194 }
195 if missing_gl_rate > self.thresholds.max_missing_gl_rate {
196 issues.push(format!(
197 "{missing_gl} bridged transactions missing gl_cash_account ({:.2}%)",
198 missing_gl_rate * 100.0
199 ));
200 }
201 if mismatches > 0 {
202 issues.push(format!(
203 "{mismatches} bridged transactions have amount deviation > {:.2}% from their payment",
204 self.thresholds.max_amount_deviation * 100.0
205 ));
206 }
207
208 Ok(CrossLayerCoherenceAnalysis {
209 total_bank_transactions: bank_txns.len(),
210 bridged_transactions: bridged_count,
211 dangling_payment_refs: dangling,
212 unpropagated_fraud_payments,
213 total_fraud_payments,
214 missing_gl_account: missing_gl,
215 amount_mismatches: mismatches,
216 mirror_transactions: mirror_count,
217 fraud_propagation_rate,
218 passes: issues.is_empty(),
219 issues,
220 })
221 }
222}
223
224impl Default for CrossLayerCoherenceAnalyzer {
225 fn default() -> Self {
226 Self::new()
227 }
228}
229
230#[cfg(test)]
231mod tests {
232 use super::*;
233
234 #[test]
235 fn test_clean_coherence_passes() {
236 let payments = vec![
237 PaymentRef {
238 payment_id: "PAY-1".into(),
239 amount: 1000.0,
240 is_fraud: false,
241 journal_entry_id: Some("JE-1".into()),
242 },
243 PaymentRef {
244 payment_id: "PAY-2".into(),
245 amount: 500.0,
246 is_fraud: true,
247 journal_entry_id: Some("JE-2".into()),
248 },
249 ];
250 let bank_txns = vec![
251 BankTxnLinks {
252 transaction_id: "BT-1".into(),
253 source_payment_id: Some("PAY-1".into()),
254 source_invoice_id: None,
255 journal_entry_id: Some("JE-1".into()),
256 gl_cash_account: Some("100000".into()),
257 is_suspicious: false,
258 is_outbound: true,
259 amount: 1000.0,
260 parent_transaction_id: None,
261 },
262 BankTxnLinks {
263 transaction_id: "BT-2".into(),
264 source_payment_id: Some("PAY-2".into()),
265 source_invoice_id: None,
266 journal_entry_id: Some("JE-2".into()),
267 gl_cash_account: Some("100000".into()),
268 is_suspicious: true, is_outbound: true,
270 amount: 500.0,
271 parent_transaction_id: None,
272 },
273 ];
274
275 let analyzer = CrossLayerCoherenceAnalyzer::new();
276 let result = analyzer.analyze(&payments, &bank_txns).unwrap();
277 assert!(result.passes, "Issues: {:?}", result.issues);
278 assert_eq!(result.bridged_transactions, 2);
279 assert_eq!(result.dangling_payment_refs, 0);
280 assert!((result.fraud_propagation_rate - 1.0).abs() < 1e-9);
281 }
282
283 #[test]
284 fn test_dangling_payment_ref_detected() {
285 let payments = vec![PaymentRef {
286 payment_id: "PAY-1".into(),
287 amount: 1000.0,
288 is_fraud: false,
289 journal_entry_id: None,
290 }];
291 let bank_txns = vec![BankTxnLinks {
292 transaction_id: "BT-1".into(),
293 source_payment_id: Some("PAY-999".into()), source_invoice_id: None,
295 journal_entry_id: None,
296 gl_cash_account: Some("100000".into()),
297 is_suspicious: false,
298 is_outbound: true,
299 amount: 1000.0,
300 parent_transaction_id: None,
301 }];
302
303 let analyzer = CrossLayerCoherenceAnalyzer::new();
304 let result = analyzer.analyze(&payments, &bank_txns).unwrap();
305 assert!(!result.passes);
306 assert_eq!(result.dangling_payment_refs, 1);
307 }
308
309 #[test]
310 fn test_fraud_propagation_failure_detected() {
311 let payments = vec![PaymentRef {
313 payment_id: "PAY-1".into(),
314 amount: 1000.0,
315 is_fraud: true,
316 journal_entry_id: None,
317 }];
318 let bank_txns = vec![BankTxnLinks {
319 transaction_id: "BT-1".into(),
320 source_payment_id: Some("PAY-1".into()),
321 source_invoice_id: None,
322 journal_entry_id: None,
323 gl_cash_account: Some("100000".into()),
324 is_suspicious: false, is_outbound: true,
326 amount: 1000.0,
327 parent_transaction_id: None,
328 }];
329
330 let analyzer = CrossLayerCoherenceAnalyzer::new();
331 let result = analyzer.analyze(&payments, &bank_txns).unwrap();
332 assert!(!result.passes);
333 assert!((result.fraud_propagation_rate - 0.0).abs() < 1e-9);
334 assert_eq!(result.unpropagated_fraud_payments, 1);
335 }
336}