Skip to main content

datasynth_eval/coherence/
intercompany.rs

1//! Intercompany matching evaluation.
2//!
3//! Validates that intercompany transactions are properly matched
4//! between company pairs.
5
6use crate::error::EvalResult;
7use rust_decimal::prelude::ToPrimitive;
8use rust_decimal::Decimal;
9use serde::{Deserialize, Serialize};
10
11/// Results of intercompany matching evaluation.
12#[derive(Debug, Clone, Serialize, Deserialize)]
13pub struct ICMatchingEvaluation {
14    /// Total company pairs with IC transactions.
15    pub total_pairs: usize,
16    /// Number of pairs fully matched.
17    pub matched_pairs: usize,
18    /// Match rate (0.0-1.0).
19    pub match_rate: f64,
20    /// Total intercompany receivables.
21    pub total_receivables: Decimal,
22    /// Total intercompany payables.
23    pub total_payables: Decimal,
24    /// Total unmatched amount.
25    pub total_unmatched: Decimal,
26    /// Net position (receivables - payables).
27    pub net_position: Decimal,
28    /// Number of discrepancies.
29    pub discrepancy_count: usize,
30    /// Netting efficiency if applicable.
31    pub netting_efficiency: Option<f64>,
32}
33
34/// Input for IC matching evaluation.
35#[derive(Debug, Clone)]
36pub struct ICMatchingData {
37    /// Total company pairs.
38    pub total_pairs: usize,
39    /// Matched company pairs.
40    pub matched_pairs: usize,
41    /// Total receivables amount.
42    pub total_receivables: Decimal,
43    /// Total payables amount.
44    pub total_payables: Decimal,
45    /// Unmatched items details.
46    pub unmatched_items: Vec<UnmatchedICItem>,
47    /// Gross IC volume (for netting calculation).
48    pub gross_volume: Option<Decimal>,
49    /// Net settlement amount (for netting calculation).
50    pub net_settlement: Option<Decimal>,
51}
52
53/// An unmatched IC item.
54#[derive(Debug, Clone)]
55pub struct UnmatchedICItem {
56    /// Company code.
57    pub company: String,
58    /// Counterparty company code.
59    pub counterparty: String,
60    /// Amount.
61    pub amount: Decimal,
62    /// Whether this is a receivable (true) or payable (false).
63    pub is_receivable: bool,
64}
65
66/// Evaluator for intercompany matching.
67pub struct ICMatchingEvaluator {
68    /// Tolerance for amount-level matching — will be used to classify
69    /// unmatched items as within/outside tolerance once per-item evaluation
70    /// is added.
71    #[allow(dead_code)]
72    tolerance: Decimal,
73}
74
75impl ICMatchingEvaluator {
76    /// Create a new evaluator with the specified tolerance.
77    pub fn new(tolerance: Decimal) -> Self {
78        Self { tolerance }
79    }
80
81    /// Evaluate IC matching results.
82    pub fn evaluate(&self, data: &ICMatchingData) -> EvalResult<ICMatchingEvaluation> {
83        let match_rate = if data.total_pairs > 0 {
84            data.matched_pairs as f64 / data.total_pairs as f64
85        } else {
86            1.0
87        };
88
89        let total_unmatched: Decimal = data.unmatched_items.iter().map(|i| i.amount.abs()).sum();
90        let net_position = data.total_receivables - data.total_payables;
91        let discrepancy_count = data.unmatched_items.len();
92
93        // Calculate netting efficiency if data available
94        let netting_efficiency = match (data.gross_volume, data.net_settlement) {
95            (Some(gross), Some(net)) if gross > Decimal::ZERO => {
96                Some(1.0 - (net / gross).to_f64().unwrap_or(0.0))
97            }
98            _ => None,
99        };
100
101        Ok(ICMatchingEvaluation {
102            total_pairs: data.total_pairs,
103            matched_pairs: data.matched_pairs,
104            match_rate,
105            total_receivables: data.total_receivables,
106            total_payables: data.total_payables,
107            total_unmatched,
108            net_position,
109            discrepancy_count,
110            netting_efficiency,
111        })
112    }
113}
114
115impl Default for ICMatchingEvaluator {
116    fn default() -> Self {
117        Self::new(Decimal::new(1, 2)) // 0.01 tolerance
118    }
119}
120
121#[cfg(test)]
122#[allow(clippy::unwrap_used)]
123mod tests {
124    use super::*;
125
126    #[test]
127    fn test_fully_matched_ic() {
128        let data = ICMatchingData {
129            total_pairs: 5,
130            matched_pairs: 5,
131            total_receivables: Decimal::new(100000, 2),
132            total_payables: Decimal::new(100000, 2),
133            unmatched_items: vec![],
134            gross_volume: Some(Decimal::new(200000, 2)),
135            net_settlement: Some(Decimal::new(20000, 2)),
136        };
137
138        let evaluator = ICMatchingEvaluator::default();
139        let result = evaluator.evaluate(&data).unwrap();
140
141        assert_eq!(result.match_rate, 1.0);
142        assert_eq!(result.total_unmatched, Decimal::ZERO);
143        assert_eq!(result.net_position, Decimal::ZERO);
144        assert!(result.netting_efficiency.unwrap() > 0.8);
145    }
146
147    #[test]
148    fn test_partial_match() {
149        let data = ICMatchingData {
150            total_pairs: 10,
151            matched_pairs: 8,
152            total_receivables: Decimal::new(100000, 2),
153            total_payables: Decimal::new(95000, 2),
154            unmatched_items: vec![UnmatchedICItem {
155                company: "1000".to_string(),
156                counterparty: "2000".to_string(),
157                amount: Decimal::new(5000, 2),
158                is_receivable: true,
159            }],
160            gross_volume: None,
161            net_settlement: None,
162        };
163
164        let evaluator = ICMatchingEvaluator::default();
165        let result = evaluator.evaluate(&data).unwrap();
166
167        assert_eq!(result.match_rate, 0.8);
168        assert_eq!(result.discrepancy_count, 1);
169        assert_eq!(result.net_position, Decimal::new(5000, 2));
170    }
171
172    #[test]
173    fn test_no_ic_transactions() {
174        let data = ICMatchingData {
175            total_pairs: 0,
176            matched_pairs: 0,
177            total_receivables: Decimal::ZERO,
178            total_payables: Decimal::ZERO,
179            unmatched_items: vec![],
180            gross_volume: None,
181            net_settlement: None,
182        };
183
184        let evaluator = ICMatchingEvaluator::default();
185        let result = evaluator.evaluate(&data).unwrap();
186
187        assert_eq!(result.match_rate, 1.0); // No IC = 100% matched
188    }
189}