use std::collections::{HashMap, HashSet};
use serde::{Deserialize, Serialize};
use crate::error::EvalResult;
#[derive(Debug, Clone)]
pub struct PaymentRef {
pub payment_id: String,
pub amount: f64,
pub is_fraud: bool,
pub journal_entry_id: Option<String>,
}
#[derive(Debug, Clone)]
pub struct BankTxnLinks {
pub transaction_id: String,
pub source_payment_id: Option<String>,
pub source_invoice_id: Option<String>,
pub journal_entry_id: Option<String>,
pub gl_cash_account: Option<String>,
pub is_suspicious: bool,
pub is_outbound: bool,
pub amount: f64,
pub parent_transaction_id: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct CrossLayerThresholds {
pub max_dangling_payment_rate: f64,
pub min_fraud_propagation_rate: f64,
pub max_missing_gl_rate: f64,
pub max_amount_deviation: f64,
}
impl Default for CrossLayerThresholds {
fn default() -> Self {
Self {
max_dangling_payment_rate: 0.0,
min_fraud_propagation_rate: 0.95,
max_missing_gl_rate: 0.01,
max_amount_deviation: 0.01,
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct CrossLayerCoherenceAnalysis {
pub total_bank_transactions: usize,
pub bridged_transactions: usize,
pub dangling_payment_refs: usize,
pub unpropagated_fraud_payments: usize,
pub total_fraud_payments: usize,
pub missing_gl_account: usize,
pub amount_mismatches: usize,
pub mirror_transactions: usize,
pub fraud_propagation_rate: f64,
pub passes: bool,
pub issues: Vec<String>,
}
pub struct CrossLayerCoherenceAnalyzer {
pub thresholds: CrossLayerThresholds,
}
impl CrossLayerCoherenceAnalyzer {
pub fn new() -> Self {
Self {
thresholds: CrossLayerThresholds::default(),
}
}
pub fn with_thresholds(thresholds: CrossLayerThresholds) -> Self {
Self { thresholds }
}
pub fn analyze(
&self,
payments: &[PaymentRef],
bank_txns: &[BankTxnLinks],
) -> EvalResult<CrossLayerCoherenceAnalysis> {
let payment_by_id: HashMap<&str, &PaymentRef> = payments
.iter()
.map(|p| (p.payment_id.as_str(), p))
.collect();
let total_fraud_payments = payments.iter().filter(|p| p.is_fraud).count();
let mut bridged_count = 0usize;
let mut dangling = 0usize;
let mut missing_gl = 0usize;
let mut mismatches = 0usize;
let mut mirror_count = 0usize;
let mut fraud_payments_with_suspicious_txn: HashSet<&str> = HashSet::new();
for txn in bank_txns {
if txn.parent_transaction_id.is_some() {
mirror_count += 1;
}
let Some(ref pid) = txn.source_payment_id else {
continue;
};
bridged_count += 1;
match payment_by_id.get(pid.as_str()) {
None => {
dangling += 1;
}
Some(payment) => {
let deviation =
(payment.amount - txn.amount).abs() / payment.amount.abs().max(1.0);
if deviation > self.thresholds.max_amount_deviation {
mismatches += 1;
}
if payment.is_fraud && txn.is_suspicious {
fraud_payments_with_suspicious_txn.insert(pid.as_str());
}
}
}
if txn.gl_cash_account.is_none() {
missing_gl += 1;
}
}
let unpropagated_fraud_payments =
total_fraud_payments.saturating_sub(fraud_payments_with_suspicious_txn.len());
let fraud_propagation_rate = if total_fraud_payments > 0 {
fraud_payments_with_suspicious_txn.len() as f64 / total_fraud_payments as f64
} else {
1.0
};
let dangling_rate = if bridged_count > 0 {
dangling as f64 / bridged_count as f64
} else {
0.0
};
let missing_gl_rate = if bridged_count > 0 {
missing_gl as f64 / bridged_count as f64
} else {
0.0
};
let mut issues = Vec::new();
if dangling_rate > self.thresholds.max_dangling_payment_rate {
issues.push(format!(
"{dangling} bridged bank transactions reference non-existent payments ({:.2}%)",
dangling_rate * 100.0
));
}
if total_fraud_payments > 0
&& fraud_propagation_rate < self.thresholds.min_fraud_propagation_rate
{
issues.push(format!(
"Fraud propagation rate {:.1}% below minimum {:.1}% ({} of {} fraud payments had no suspicious bank txn)",
fraud_propagation_rate * 100.0,
self.thresholds.min_fraud_propagation_rate * 100.0,
unpropagated_fraud_payments,
total_fraud_payments,
));
}
if missing_gl_rate > self.thresholds.max_missing_gl_rate {
issues.push(format!(
"{missing_gl} bridged transactions missing gl_cash_account ({:.2}%)",
missing_gl_rate * 100.0
));
}
if mismatches > 0 {
issues.push(format!(
"{mismatches} bridged transactions have amount deviation > {:.2}% from their payment",
self.thresholds.max_amount_deviation * 100.0
));
}
Ok(CrossLayerCoherenceAnalysis {
total_bank_transactions: bank_txns.len(),
bridged_transactions: bridged_count,
dangling_payment_refs: dangling,
unpropagated_fraud_payments,
total_fraud_payments,
missing_gl_account: missing_gl,
amount_mismatches: mismatches,
mirror_transactions: mirror_count,
fraud_propagation_rate,
passes: issues.is_empty(),
issues,
})
}
}
impl Default for CrossLayerCoherenceAnalyzer {
fn default() -> Self {
Self::new()
}
}
#[cfg(test)]
#[allow(clippy::unwrap_used)]
mod tests {
use super::*;
#[test]
fn test_clean_coherence_passes() {
let payments = vec![
PaymentRef {
payment_id: "PAY-1".into(),
amount: 1000.0,
is_fraud: false,
journal_entry_id: Some("JE-1".into()),
},
PaymentRef {
payment_id: "PAY-2".into(),
amount: 500.0,
is_fraud: true,
journal_entry_id: Some("JE-2".into()),
},
];
let bank_txns = vec![
BankTxnLinks {
transaction_id: "BT-1".into(),
source_payment_id: Some("PAY-1".into()),
source_invoice_id: None,
journal_entry_id: Some("JE-1".into()),
gl_cash_account: Some("100000".into()),
is_suspicious: false,
is_outbound: true,
amount: 1000.0,
parent_transaction_id: None,
},
BankTxnLinks {
transaction_id: "BT-2".into(),
source_payment_id: Some("PAY-2".into()),
source_invoice_id: None,
journal_entry_id: Some("JE-2".into()),
gl_cash_account: Some("100000".into()),
is_suspicious: true, is_outbound: true,
amount: 500.0,
parent_transaction_id: None,
},
];
let analyzer = CrossLayerCoherenceAnalyzer::new();
let result = analyzer.analyze(&payments, &bank_txns).unwrap();
assert!(result.passes, "Issues: {:?}", result.issues);
assert_eq!(result.bridged_transactions, 2);
assert_eq!(result.dangling_payment_refs, 0);
assert!((result.fraud_propagation_rate - 1.0).abs() < 1e-9);
}
#[test]
fn test_dangling_payment_ref_detected() {
let payments = vec![PaymentRef {
payment_id: "PAY-1".into(),
amount: 1000.0,
is_fraud: false,
journal_entry_id: None,
}];
let bank_txns = vec![BankTxnLinks {
transaction_id: "BT-1".into(),
source_payment_id: Some("PAY-999".into()), source_invoice_id: None,
journal_entry_id: None,
gl_cash_account: Some("100000".into()),
is_suspicious: false,
is_outbound: true,
amount: 1000.0,
parent_transaction_id: None,
}];
let analyzer = CrossLayerCoherenceAnalyzer::new();
let result = analyzer.analyze(&payments, &bank_txns).unwrap();
assert!(!result.passes);
assert_eq!(result.dangling_payment_refs, 1);
}
#[test]
fn test_fraud_propagation_failure_detected() {
let payments = vec![PaymentRef {
payment_id: "PAY-1".into(),
amount: 1000.0,
is_fraud: true,
journal_entry_id: None,
}];
let bank_txns = vec![BankTxnLinks {
transaction_id: "BT-1".into(),
source_payment_id: Some("PAY-1".into()),
source_invoice_id: None,
journal_entry_id: None,
gl_cash_account: Some("100000".into()),
is_suspicious: false, is_outbound: true,
amount: 1000.0,
parent_transaction_id: None,
}];
let analyzer = CrossLayerCoherenceAnalyzer::new();
let result = analyzer.analyze(&payments, &bank_txns).unwrap();
assert!(!result.passes);
assert!((result.fraud_propagation_rate - 0.0).abs() < 1e-9);
assert_eq!(result.unpropagated_fraud_payments, 1);
}
}