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 (outside tolerance).
29    pub discrepancy_count: usize,
30    /// Number of unmatched items within tolerance.
31    pub within_tolerance_count: usize,
32    /// Number of unmatched items outside tolerance.
33    pub outside_tolerance_count: usize,
34    /// Netting efficiency if applicable.
35    pub netting_efficiency: Option<f64>,
36}
37
38/// Input for IC matching evaluation.
39#[derive(Debug, Clone)]
40pub struct ICMatchingData {
41    /// Total company pairs.
42    pub total_pairs: usize,
43    /// Matched company pairs.
44    pub matched_pairs: usize,
45    /// Total receivables amount.
46    pub total_receivables: Decimal,
47    /// Total payables amount.
48    pub total_payables: Decimal,
49    /// Unmatched items details.
50    pub unmatched_items: Vec<UnmatchedICItem>,
51    /// Gross IC volume (for netting calculation).
52    pub gross_volume: Option<Decimal>,
53    /// Net settlement amount (for netting calculation).
54    pub net_settlement: Option<Decimal>,
55}
56
57/// An unmatched IC item.
58#[derive(Debug, Clone)]
59pub struct UnmatchedICItem {
60    /// Company code.
61    pub company: String,
62    /// Counterparty company code.
63    pub counterparty: String,
64    /// Amount.
65    pub amount: Decimal,
66    /// Whether this is a receivable (true) or payable (false).
67    pub is_receivable: bool,
68}
69
70/// Evaluator for intercompany matching.
71pub struct ICMatchingEvaluator {
72    /// Tolerance for classifying unmatched items as within/outside tolerance.
73    tolerance: Decimal,
74}
75
76impl ICMatchingEvaluator {
77    /// Create a new evaluator with the specified tolerance.
78    pub fn new(tolerance: Decimal) -> Self {
79        Self { tolerance }
80    }
81
82    /// Evaluate IC matching results.
83    pub fn evaluate(&self, data: &ICMatchingData) -> EvalResult<ICMatchingEvaluation> {
84        let match_rate = if data.total_pairs > 0 {
85            data.matched_pairs as f64 / data.total_pairs as f64
86        } else {
87            1.0
88        };
89
90        let total_unmatched: Decimal = data.unmatched_items.iter().map(|i| i.amount.abs()).sum();
91        let net_position = data.total_receivables - data.total_payables;
92
93        // Classify unmatched items by tolerance
94        let within_tolerance_count = data
95            .unmatched_items
96            .iter()
97            .filter(|item| item.amount.abs() <= self.tolerance)
98            .count();
99        let outside_tolerance_count = data.unmatched_items.len() - within_tolerance_count;
100        // Only outside-tolerance items count as true discrepancies
101        let discrepancy_count = outside_tolerance_count;
102
103        // Calculate netting efficiency if data available
104        let netting_efficiency = match (data.gross_volume, data.net_settlement) {
105            (Some(gross), Some(net)) if gross > Decimal::ZERO => {
106                Some(1.0 - (net / gross).to_f64().unwrap_or(0.0))
107            }
108            _ => None,
109        };
110
111        Ok(ICMatchingEvaluation {
112            total_pairs: data.total_pairs,
113            matched_pairs: data.matched_pairs,
114            match_rate,
115            total_receivables: data.total_receivables,
116            total_payables: data.total_payables,
117            total_unmatched,
118            net_position,
119            discrepancy_count,
120            within_tolerance_count,
121            outside_tolerance_count,
122            netting_efficiency,
123        })
124    }
125}
126
127impl Default for ICMatchingEvaluator {
128    fn default() -> Self {
129        Self::new(Decimal::new(1, 2)) // 0.01 tolerance
130    }
131}
132
133// ---------------------------------------------------------------------------
134// IC Net-Zero Reconciliation Validator (v2.5 — fixes consolidation gap)
135// ---------------------------------------------------------------------------
136
137/// Input for IC net-zero reconciliation validation.
138#[derive(Debug, Clone)]
139pub struct ICNetZeroData {
140    /// Per-elimination-entry debit/credit totals.
141    pub elimination_entries: Vec<ICEliminationLineData>,
142    /// IC receivable balance remaining after all eliminations.
143    pub post_elimination_ic_receivables: Decimal,
144    /// IC payable balance remaining after all eliminations.
145    pub post_elimination_ic_payables: Decimal,
146}
147
148/// Debit/credit summary for a single elimination entry.
149#[derive(Debug, Clone)]
150pub struct ICEliminationLineData {
151    /// Entry identifier.
152    pub entry_id: String,
153    /// Elimination type (e.g., "ICBalances", "ICRevenueExpense").
154    pub elimination_type: String,
155    /// Sum of debit lines in this entry.
156    pub total_debits: Decimal,
157    /// Sum of credit lines in this entry.
158    pub total_credits: Decimal,
159}
160
161/// Results of IC net-zero reconciliation validation.
162#[derive(Debug, Clone, Serialize, Deserialize)]
163pub struct ICNetZeroEvaluation {
164    /// Total elimination entries checked.
165    pub total_entries: usize,
166    /// Number of entries where debits != credits.
167    pub unbalanced_entries: usize,
168    /// Whether every individual elimination entry is balanced.
169    pub all_entries_balanced: bool,
170    /// Sum of all elimination debits.
171    pub aggregate_debits: Decimal,
172    /// Sum of all elimination credits.
173    pub aggregate_credits: Decimal,
174    /// Aggregate imbalance (debits - credits).
175    pub aggregate_imbalance: Decimal,
176    /// Residual IC balance after elimination (receivables - payables; should be zero).
177    pub residual_ic_balance: Decimal,
178    /// Whether IC balances net to zero after elimination.
179    pub net_zero_achieved: bool,
180    /// Entry IDs that failed the balance check.
181    pub failed_entries: Vec<String>,
182}
183
184/// Validates that IC elimination entries net to zero (the consolidation principle).
185///
186/// Checks two levels:
187/// 1. **Per-entry**: Each elimination entry's debits must equal its credits.
188/// 2. **Aggregate**: After all eliminations, IC receivable and payable balances must net to zero.
189pub struct ICNetZeroEvaluator {
190    /// Tolerance for floating-point comparison.
191    tolerance: Decimal,
192}
193
194impl ICNetZeroEvaluator {
195    /// Create with a specific tolerance.
196    pub fn new(tolerance: Decimal) -> Self {
197        Self { tolerance }
198    }
199
200    /// Evaluate IC net-zero reconciliation.
201    pub fn evaluate(&self, data: &ICNetZeroData) -> EvalResult<ICNetZeroEvaluation> {
202        let mut failed_entries = Vec::new();
203        let mut aggregate_debits = Decimal::ZERO;
204        let mut aggregate_credits = Decimal::ZERO;
205
206        for entry in &data.elimination_entries {
207            aggregate_debits += entry.total_debits;
208            aggregate_credits += entry.total_credits;
209
210            let diff = (entry.total_debits - entry.total_credits).abs();
211            if diff > self.tolerance {
212                failed_entries.push(entry.entry_id.clone());
213            }
214        }
215
216        let aggregate_imbalance = (aggregate_debits - aggregate_credits).abs();
217        let all_entries_balanced = failed_entries.is_empty();
218
219        let residual_ic_balance =
220            (data.post_elimination_ic_receivables - data.post_elimination_ic_payables).abs();
221        let net_zero_achieved =
222            residual_ic_balance <= self.tolerance && aggregate_imbalance <= self.tolerance;
223
224        Ok(ICNetZeroEvaluation {
225            total_entries: data.elimination_entries.len(),
226            unbalanced_entries: failed_entries.len(),
227            all_entries_balanced,
228            aggregate_debits,
229            aggregate_credits,
230            aggregate_imbalance,
231            residual_ic_balance,
232            net_zero_achieved,
233            failed_entries,
234        })
235    }
236}
237
238impl Default for ICNetZeroEvaluator {
239    fn default() -> Self {
240        Self::new(Decimal::new(1, 2)) // 0.01 tolerance
241    }
242}
243
244#[cfg(test)]
245#[allow(clippy::unwrap_used)]
246mod tests {
247    use super::*;
248
249    #[test]
250    fn test_fully_matched_ic() {
251        let data = ICMatchingData {
252            total_pairs: 5,
253            matched_pairs: 5,
254            total_receivables: Decimal::new(100000, 2),
255            total_payables: Decimal::new(100000, 2),
256            unmatched_items: vec![],
257            gross_volume: Some(Decimal::new(200000, 2)),
258            net_settlement: Some(Decimal::new(20000, 2)),
259        };
260
261        let evaluator = ICMatchingEvaluator::default();
262        let result = evaluator.evaluate(&data).unwrap();
263
264        assert_eq!(result.match_rate, 1.0);
265        assert_eq!(result.total_unmatched, Decimal::ZERO);
266        assert_eq!(result.net_position, Decimal::ZERO);
267        assert!(result.netting_efficiency.unwrap() > 0.8);
268    }
269
270    #[test]
271    fn test_partial_match() {
272        let data = ICMatchingData {
273            total_pairs: 10,
274            matched_pairs: 8,
275            total_receivables: Decimal::new(100000, 2),
276            total_payables: Decimal::new(95000, 2),
277            unmatched_items: vec![UnmatchedICItem {
278                company: "1000".to_string(),
279                counterparty: "2000".to_string(),
280                amount: Decimal::new(5000, 2),
281                is_receivable: true,
282            }],
283            gross_volume: None,
284            net_settlement: None,
285        };
286
287        let evaluator = ICMatchingEvaluator::default();
288        let result = evaluator.evaluate(&data).unwrap();
289
290        assert_eq!(result.match_rate, 0.8);
291        assert_eq!(result.discrepancy_count, 1);
292        assert_eq!(result.net_position, Decimal::new(5000, 2));
293    }
294
295    #[test]
296    fn test_no_ic_transactions() {
297        let data = ICMatchingData {
298            total_pairs: 0,
299            matched_pairs: 0,
300            total_receivables: Decimal::ZERO,
301            total_payables: Decimal::ZERO,
302            unmatched_items: vec![],
303            gross_volume: None,
304            net_settlement: None,
305        };
306
307        let evaluator = ICMatchingEvaluator::default();
308        let result = evaluator.evaluate(&data).unwrap();
309
310        assert_eq!(result.match_rate, 1.0); // No IC = 100% matched
311    }
312
313    #[test]
314    fn test_ic_net_zero_balanced() {
315        let data = ICNetZeroData {
316            elimination_entries: vec![
317                ICEliminationLineData {
318                    entry_id: "ELIM-001".to_string(),
319                    elimination_type: "ICBalances".to_string(),
320                    total_debits: Decimal::new(500000, 2),
321                    total_credits: Decimal::new(500000, 2),
322                },
323                ICEliminationLineData {
324                    entry_id: "ELIM-002".to_string(),
325                    elimination_type: "ICRevenueExpense".to_string(),
326                    total_debits: Decimal::new(250000, 2),
327                    total_credits: Decimal::new(250000, 2),
328                },
329            ],
330            post_elimination_ic_receivables: Decimal::ZERO,
331            post_elimination_ic_payables: Decimal::ZERO,
332        };
333
334        let evaluator = ICNetZeroEvaluator::default();
335        let result = evaluator.evaluate(&data).unwrap();
336
337        assert!(result.all_entries_balanced);
338        assert!(result.net_zero_achieved);
339        assert_eq!(result.unbalanced_entries, 0);
340        assert_eq!(result.residual_ic_balance, Decimal::ZERO);
341    }
342
343    #[test]
344    fn test_ic_net_zero_unbalanced_entry() {
345        let data = ICNetZeroData {
346            elimination_entries: vec![ICEliminationLineData {
347                entry_id: "ELIM-BAD".to_string(),
348                elimination_type: "ICBalances".to_string(),
349                total_debits: Decimal::new(500000, 2),
350                total_credits: Decimal::new(495000, 2), // 50.00 difference
351            }],
352            post_elimination_ic_receivables: Decimal::new(5000, 2),
353            post_elimination_ic_payables: Decimal::ZERO,
354        };
355
356        let evaluator = ICNetZeroEvaluator::default();
357        let result = evaluator.evaluate(&data).unwrap();
358
359        assert!(!result.all_entries_balanced);
360        assert!(!result.net_zero_achieved);
361        assert_eq!(result.unbalanced_entries, 1);
362    }
363
364    #[test]
365    fn test_ic_net_zero_no_eliminations() {
366        let data = ICNetZeroData {
367            elimination_entries: vec![],
368            post_elimination_ic_receivables: Decimal::ZERO,
369            post_elimination_ic_payables: Decimal::ZERO,
370        };
371
372        let evaluator = ICNetZeroEvaluator::default();
373        let result = evaluator.evaluate(&data).unwrap();
374
375        assert!(result.all_entries_balanced);
376        assert!(result.net_zero_achieved);
377    }
378}