use crate::error::EvalResult;
use rust_decimal::prelude::ToPrimitive;
use rust_decimal::Decimal;
use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ICMatchingEvaluation {
pub total_pairs: usize,
pub matched_pairs: usize,
pub match_rate: f64,
pub total_receivables: Decimal,
pub total_payables: Decimal,
pub total_unmatched: Decimal,
pub net_position: Decimal,
pub discrepancy_count: usize,
pub within_tolerance_count: usize,
pub outside_tolerance_count: usize,
pub netting_efficiency: Option<f64>,
}
#[derive(Debug, Clone)]
pub struct ICMatchingData {
pub total_pairs: usize,
pub matched_pairs: usize,
pub total_receivables: Decimal,
pub total_payables: Decimal,
pub unmatched_items: Vec<UnmatchedICItem>,
pub gross_volume: Option<Decimal>,
pub net_settlement: Option<Decimal>,
}
#[derive(Debug, Clone)]
pub struct UnmatchedICItem {
pub company: String,
pub counterparty: String,
pub amount: Decimal,
pub is_receivable: bool,
}
pub struct ICMatchingEvaluator {
tolerance: Decimal,
}
impl ICMatchingEvaluator {
pub fn new(tolerance: Decimal) -> Self {
Self { tolerance }
}
pub fn evaluate(&self, data: &ICMatchingData) -> EvalResult<ICMatchingEvaluation> {
let match_rate = if data.total_pairs > 0 {
data.matched_pairs as f64 / data.total_pairs as f64
} else {
1.0
};
let total_unmatched: Decimal = data.unmatched_items.iter().map(|i| i.amount.abs()).sum();
let net_position = data.total_receivables - data.total_payables;
let within_tolerance_count = data
.unmatched_items
.iter()
.filter(|item| item.amount.abs() <= self.tolerance)
.count();
let outside_tolerance_count = data.unmatched_items.len() - within_tolerance_count;
let discrepancy_count = outside_tolerance_count;
let netting_efficiency = match (data.gross_volume, data.net_settlement) {
(Some(gross), Some(net)) if gross > Decimal::ZERO => {
Some(1.0 - (net / gross).to_f64().unwrap_or(0.0))
}
_ => None,
};
Ok(ICMatchingEvaluation {
total_pairs: data.total_pairs,
matched_pairs: data.matched_pairs,
match_rate,
total_receivables: data.total_receivables,
total_payables: data.total_payables,
total_unmatched,
net_position,
discrepancy_count,
within_tolerance_count,
outside_tolerance_count,
netting_efficiency,
})
}
}
impl Default for ICMatchingEvaluator {
fn default() -> Self {
Self::new(Decimal::new(1, 2)) }
}
#[derive(Debug, Clone)]
pub struct ICNetZeroData {
pub elimination_entries: Vec<ICEliminationLineData>,
pub post_elimination_ic_receivables: Decimal,
pub post_elimination_ic_payables: Decimal,
}
#[derive(Debug, Clone)]
pub struct ICEliminationLineData {
pub entry_id: String,
pub elimination_type: String,
pub total_debits: Decimal,
pub total_credits: Decimal,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ICNetZeroEvaluation {
pub total_entries: usize,
pub unbalanced_entries: usize,
pub all_entries_balanced: bool,
pub aggregate_debits: Decimal,
pub aggregate_credits: Decimal,
pub aggregate_imbalance: Decimal,
pub residual_ic_balance: Decimal,
pub net_zero_achieved: bool,
pub failed_entries: Vec<String>,
}
pub struct ICNetZeroEvaluator {
tolerance: Decimal,
}
impl ICNetZeroEvaluator {
pub fn new(tolerance: Decimal) -> Self {
Self { tolerance }
}
pub fn evaluate(&self, data: &ICNetZeroData) -> EvalResult<ICNetZeroEvaluation> {
let mut failed_entries = Vec::new();
let mut aggregate_debits = Decimal::ZERO;
let mut aggregate_credits = Decimal::ZERO;
for entry in &data.elimination_entries {
aggregate_debits += entry.total_debits;
aggregate_credits += entry.total_credits;
let diff = (entry.total_debits - entry.total_credits).abs();
if diff > self.tolerance {
failed_entries.push(entry.entry_id.clone());
}
}
let aggregate_imbalance = (aggregate_debits - aggregate_credits).abs();
let all_entries_balanced = failed_entries.is_empty();
let residual_ic_balance =
(data.post_elimination_ic_receivables - data.post_elimination_ic_payables).abs();
let net_zero_achieved =
residual_ic_balance <= self.tolerance && aggregate_imbalance <= self.tolerance;
Ok(ICNetZeroEvaluation {
total_entries: data.elimination_entries.len(),
unbalanced_entries: failed_entries.len(),
all_entries_balanced,
aggregate_debits,
aggregate_credits,
aggregate_imbalance,
residual_ic_balance,
net_zero_achieved,
failed_entries,
})
}
}
impl Default for ICNetZeroEvaluator {
fn default() -> Self {
Self::new(Decimal::new(1, 2)) }
}
#[cfg(test)]
#[allow(clippy::unwrap_used)]
mod tests {
use super::*;
#[test]
fn test_fully_matched_ic() {
let data = ICMatchingData {
total_pairs: 5,
matched_pairs: 5,
total_receivables: Decimal::new(100000, 2),
total_payables: Decimal::new(100000, 2),
unmatched_items: vec![],
gross_volume: Some(Decimal::new(200000, 2)),
net_settlement: Some(Decimal::new(20000, 2)),
};
let evaluator = ICMatchingEvaluator::default();
let result = evaluator.evaluate(&data).unwrap();
assert_eq!(result.match_rate, 1.0);
assert_eq!(result.total_unmatched, Decimal::ZERO);
assert_eq!(result.net_position, Decimal::ZERO);
assert!(result.netting_efficiency.unwrap() > 0.8);
}
#[test]
fn test_partial_match() {
let data = ICMatchingData {
total_pairs: 10,
matched_pairs: 8,
total_receivables: Decimal::new(100000, 2),
total_payables: Decimal::new(95000, 2),
unmatched_items: vec![UnmatchedICItem {
company: "1000".to_string(),
counterparty: "2000".to_string(),
amount: Decimal::new(5000, 2),
is_receivable: true,
}],
gross_volume: None,
net_settlement: None,
};
let evaluator = ICMatchingEvaluator::default();
let result = evaluator.evaluate(&data).unwrap();
assert_eq!(result.match_rate, 0.8);
assert_eq!(result.discrepancy_count, 1);
assert_eq!(result.net_position, Decimal::new(5000, 2));
}
#[test]
fn test_no_ic_transactions() {
let data = ICMatchingData {
total_pairs: 0,
matched_pairs: 0,
total_receivables: Decimal::ZERO,
total_payables: Decimal::ZERO,
unmatched_items: vec![],
gross_volume: None,
net_settlement: None,
};
let evaluator = ICMatchingEvaluator::default();
let result = evaluator.evaluate(&data).unwrap();
assert_eq!(result.match_rate, 1.0); }
#[test]
fn test_ic_net_zero_balanced() {
let data = ICNetZeroData {
elimination_entries: vec![
ICEliminationLineData {
entry_id: "ELIM-001".to_string(),
elimination_type: "ICBalances".to_string(),
total_debits: Decimal::new(500000, 2),
total_credits: Decimal::new(500000, 2),
},
ICEliminationLineData {
entry_id: "ELIM-002".to_string(),
elimination_type: "ICRevenueExpense".to_string(),
total_debits: Decimal::new(250000, 2),
total_credits: Decimal::new(250000, 2),
},
],
post_elimination_ic_receivables: Decimal::ZERO,
post_elimination_ic_payables: Decimal::ZERO,
};
let evaluator = ICNetZeroEvaluator::default();
let result = evaluator.evaluate(&data).unwrap();
assert!(result.all_entries_balanced);
assert!(result.net_zero_achieved);
assert_eq!(result.unbalanced_entries, 0);
assert_eq!(result.residual_ic_balance, Decimal::ZERO);
}
#[test]
fn test_ic_net_zero_unbalanced_entry() {
let data = ICNetZeroData {
elimination_entries: vec![ICEliminationLineData {
entry_id: "ELIM-BAD".to_string(),
elimination_type: "ICBalances".to_string(),
total_debits: Decimal::new(500000, 2),
total_credits: Decimal::new(495000, 2), }],
post_elimination_ic_receivables: Decimal::new(5000, 2),
post_elimination_ic_payables: Decimal::ZERO,
};
let evaluator = ICNetZeroEvaluator::default();
let result = evaluator.evaluate(&data).unwrap();
assert!(!result.all_entries_balanced);
assert!(!result.net_zero_achieved);
assert_eq!(result.unbalanced_entries, 1);
}
#[test]
fn test_ic_net_zero_no_eliminations() {
let data = ICNetZeroData {
elimination_entries: vec![],
post_elimination_ic_receivables: Decimal::ZERO,
post_elimination_ic_payables: Decimal::ZERO,
};
let evaluator = ICNetZeroEvaluator::default();
let result = evaluator.evaluate(&data).unwrap();
assert!(result.all_entries_balanced);
assert!(result.net_zero_achieved);
}
}