Skip to main content

datasynth_generators/intercompany/
matching_engine.rs

1//! Intercompany matching engine.
2//!
3//! Matches IC balances between entities and identifies discrepancies
4//! for reconciliation and elimination.
5
6use chrono::NaiveDate;
7use rust_decimal::Decimal;
8use rust_decimal_macros::dec;
9use std::collections::HashMap;
10
11use datasynth_core::models::intercompany::{
12    ICAggregatedBalance, ICMatchedPair, ICNettingArrangement, ICNettingPosition,
13};
14use datasynth_core::models::JournalEntry;
15
16/// Result of IC matching process.
17#[derive(Debug, Clone)]
18pub struct ICMatchingResult {
19    /// Matched balances (zero difference).
20    pub matched_balances: Vec<ICAggregatedBalance>,
21    /// Unmatched balances (non-zero difference).
22    pub unmatched_balances: Vec<ICAggregatedBalance>,
23    /// Total matched amount.
24    pub total_matched: Decimal,
25    /// Total unmatched amount.
26    pub total_unmatched: Decimal,
27    /// Match rate (0.0 to 1.0).
28    pub match_rate: f64,
29    /// As-of date.
30    pub as_of_date: NaiveDate,
31    /// Tolerance used for matching.
32    pub tolerance: Decimal,
33}
34
35/// Configuration for IC matching.
36#[derive(Debug, Clone)]
37pub struct ICMatchingConfig {
38    /// Tolerance for matching (amounts within this are considered matched).
39    pub tolerance: Decimal,
40    /// Match by IC reference (exact match).
41    pub match_by_reference: bool,
42    /// Match by amount (fuzzy match).
43    pub match_by_amount: bool,
44    /// Match by date range.
45    pub date_range_days: i64,
46    /// Auto-create adjustment entries for small differences.
47    pub auto_adjust_threshold: Decimal,
48    /// Currency for matching.
49    pub base_currency: String,
50}
51
52impl Default for ICMatchingConfig {
53    fn default() -> Self {
54        Self {
55            tolerance: dec!(0.01),
56            match_by_reference: true,
57            match_by_amount: true,
58            date_range_days: 5,
59            auto_adjust_threshold: dec!(100),
60            base_currency: "USD".to_string(),
61        }
62    }
63}
64
65/// IC Matching Engine for reconciliation.
66pub struct ICMatchingEngine {
67    /// Configuration.
68    config: ICMatchingConfig,
69    /// IC balances by company pair.
70    balances: HashMap<(String, String), ICAggregatedBalance>,
71    /// Unmatched items by company.
72    unmatched_items: HashMap<String, Vec<UnmatchedItem>>,
73    /// Matching results history.
74    matching_history: Vec<ICMatchingResult>,
75}
76
77impl ICMatchingEngine {
78    /// Create a new matching engine.
79    pub fn new(config: ICMatchingConfig) -> Self {
80        Self {
81            config,
82            balances: HashMap::new(),
83            unmatched_items: HashMap::new(),
84            matching_history: Vec::new(),
85        }
86    }
87
88    /// Add a receivable entry to the engine.
89    pub fn add_receivable(
90        &mut self,
91        creditor: &str,
92        debtor: &str,
93        amount: Decimal,
94        ic_reference: Option<&str>,
95        date: NaiveDate,
96    ) {
97        let key = (creditor.to_string(), debtor.to_string());
98        let balance = self.balances.entry(key.clone()).or_insert_with(|| {
99            ICAggregatedBalance::new(
100                creditor.to_string(),
101                debtor.to_string(),
102                format!("1310{}", &debtor[..debtor.len().min(2)]),
103                format!("2110{}", &creditor[..creditor.len().min(2)]),
104                self.config.base_currency.clone(),
105                date,
106            )
107        });
108
109        balance.receivable_balance += amount;
110        balance.set_balances(balance.receivable_balance, balance.payable_balance);
111
112        // Track for detailed matching
113        self.unmatched_items
114            .entry(creditor.to_string())
115            .or_default()
116            .push(UnmatchedItem {
117                company: creditor.to_string(),
118                counterparty: debtor.to_string(),
119                amount,
120                is_receivable: true,
121                ic_reference: ic_reference.map(|s| s.to_string()),
122                date,
123                matched: false,
124            });
125    }
126
127    /// Add a payable entry to the engine.
128    pub fn add_payable(
129        &mut self,
130        debtor: &str,
131        creditor: &str,
132        amount: Decimal,
133        ic_reference: Option<&str>,
134        date: NaiveDate,
135    ) {
136        let key = (creditor.to_string(), debtor.to_string());
137        let balance = self.balances.entry(key.clone()).or_insert_with(|| {
138            ICAggregatedBalance::new(
139                creditor.to_string(),
140                debtor.to_string(),
141                format!("1310{}", &debtor[..debtor.len().min(2)]),
142                format!("2110{}", &creditor[..creditor.len().min(2)]),
143                self.config.base_currency.clone(),
144                date,
145            )
146        });
147
148        balance.payable_balance += amount;
149        balance.set_balances(balance.receivable_balance, balance.payable_balance);
150
151        // Track for detailed matching
152        self.unmatched_items
153            .entry(debtor.to_string())
154            .or_default()
155            .push(UnmatchedItem {
156                company: debtor.to_string(),
157                counterparty: creditor.to_string(),
158                amount,
159                is_receivable: false,
160                ic_reference: ic_reference.map(|s| s.to_string()),
161                date,
162                matched: false,
163            });
164    }
165
166    /// Load matched pairs into the engine.
167    pub fn load_matched_pairs(&mut self, pairs: &[ICMatchedPair]) {
168        for pair in pairs {
169            self.add_receivable(
170                &pair.seller_company,
171                &pair.buyer_company,
172                pair.amount,
173                Some(&pair.ic_reference),
174                pair.transaction_date,
175            );
176            self.add_payable(
177                &pair.buyer_company,
178                &pair.seller_company,
179                pair.amount,
180                Some(&pair.ic_reference),
181                pair.transaction_date,
182            );
183        }
184    }
185
186    /// Load journal entries and extract IC items.
187    pub fn load_journal_entries(&mut self, entries: &[JournalEntry]) {
188        for entry in entries {
189            // Look for IC accounts in journal lines
190            for line in &entry.lines {
191                // Check for IC receivable accounts (1310xx pattern)
192                if line.account_code.starts_with("1310") && line.debit_amount > Decimal::ZERO {
193                    // Extract counterparty from account code
194                    let counterparty = line.account_code[4..].to_string();
195                    self.add_receivable(
196                        entry.company_code(),
197                        &counterparty,
198                        line.debit_amount,
199                        entry.header.reference.as_deref(),
200                        entry.posting_date(),
201                    );
202                }
203
204                // Check for IC payable accounts (2110xx pattern)
205                if line.account_code.starts_with("2110") && line.credit_amount > Decimal::ZERO {
206                    let counterparty = line.account_code[4..].to_string();
207                    self.add_payable(
208                        entry.company_code(),
209                        &counterparty,
210                        line.credit_amount,
211                        entry.header.reference.as_deref(),
212                        entry.posting_date(),
213                    );
214                }
215            }
216        }
217    }
218
219    /// Perform matching process.
220    pub fn run_matching(&mut self, as_of_date: NaiveDate) -> ICMatchingResult {
221        let mut matched_balances = Vec::new();
222        let mut unmatched_balances = Vec::new();
223        let mut total_matched = Decimal::ZERO;
224        let mut total_unmatched = Decimal::ZERO;
225
226        // First pass: match by IC reference
227        if self.config.match_by_reference {
228            self.match_by_reference();
229        }
230
231        // Second pass: match by amount
232        if self.config.match_by_amount {
233            self.match_by_amount();
234        }
235
236        // Evaluate results
237        for balance in self.balances.values() {
238            if balance.difference.abs() <= self.config.tolerance {
239                matched_balances.push(balance.clone());
240                total_matched += balance.elimination_amount();
241            } else {
242                unmatched_balances.push(balance.clone());
243                total_unmatched += balance.difference.abs();
244            }
245        }
246
247        let total_items = matched_balances.len() + unmatched_balances.len();
248        let match_rate = if total_items > 0 {
249            matched_balances.len() as f64 / total_items as f64
250        } else {
251            1.0
252        };
253
254        let result = ICMatchingResult {
255            matched_balances,
256            unmatched_balances,
257            total_matched,
258            total_unmatched,
259            match_rate,
260            as_of_date,
261            tolerance: self.config.tolerance,
262        };
263
264        self.matching_history.push(result.clone());
265        result
266    }
267
268    /// Match items by IC reference.
269    fn match_by_reference(&mut self) {
270        // Collect match candidates: (company, item_idx, counterparty, cp_item_idx)
271        let mut matches_to_apply: Vec<(String, usize, String, usize)> = Vec::new();
272
273        let companies: Vec<String> = self.unmatched_items.keys().cloned().collect();
274        let tolerance = self.config.tolerance;
275
276        for company in &companies {
277            if let Some(items) = self.unmatched_items.get(company) {
278                for (item_idx, item) in items.iter().enumerate() {
279                    if item.matched || item.ic_reference.is_none() {
280                        continue;
281                    }
282
283                    let ic_ref = item.ic_reference.as_ref().unwrap();
284
285                    // Look for matching item in counterparty
286                    if let Some(counterparty_items) = self.unmatched_items.get(&item.counterparty) {
287                        for (cp_idx, cp_item) in counterparty_items.iter().enumerate() {
288                            if cp_item.matched {
289                                continue;
290                            }
291
292                            if cp_item.ic_reference.as_ref() == Some(ic_ref)
293                                && cp_item.counterparty == *company
294                                && cp_item.is_receivable != item.is_receivable
295                                && (cp_item.amount - item.amount).abs() <= tolerance
296                            {
297                                matches_to_apply.push((
298                                    company.clone(),
299                                    item_idx,
300                                    item.counterparty.clone(),
301                                    cp_idx,
302                                ));
303                                break;
304                            }
305                        }
306                    }
307                }
308            }
309        }
310
311        // Apply matches
312        for (company, item_idx, counterparty, cp_idx) in matches_to_apply {
313            if let Some(items) = self.unmatched_items.get_mut(&company) {
314                if let Some(item) = items.get_mut(item_idx) {
315                    item.matched = true;
316                }
317            }
318            if let Some(cp_items) = self.unmatched_items.get_mut(&counterparty) {
319                if let Some(cp_item) = cp_items.get_mut(cp_idx) {
320                    cp_item.matched = true;
321                }
322            }
323        }
324    }
325
326    /// Match items by amount.
327    fn match_by_amount(&mut self) {
328        // Collect match candidates: (company, item_idx, counterparty, cp_item_idx)
329        let mut matches_to_apply: Vec<(String, usize, String, usize)> = Vec::new();
330
331        let companies: Vec<String> = self.unmatched_items.keys().cloned().collect();
332        let tolerance = self.config.tolerance;
333        let date_range_days = self.config.date_range_days;
334
335        for company in &companies {
336            if let Some(items) = self.unmatched_items.get(company) {
337                for (item_idx, item) in items.iter().enumerate() {
338                    if item.matched {
339                        continue;
340                    }
341
342                    // Look for matching amount in counterparty
343                    if let Some(counterparty_items) = self.unmatched_items.get(&item.counterparty) {
344                        for (cp_idx, cp_item) in counterparty_items.iter().enumerate() {
345                            if cp_item.matched {
346                                continue;
347                            }
348
349                            if cp_item.counterparty == *company
350                                && cp_item.is_receivable != item.is_receivable
351                                && (cp_item.amount - item.amount).abs() <= tolerance
352                            {
353                                // Check date range
354                                let date_diff = (cp_item.date - item.date).num_days().abs();
355                                if date_diff <= date_range_days {
356                                    matches_to_apply.push((
357                                        company.clone(),
358                                        item_idx,
359                                        item.counterparty.clone(),
360                                        cp_idx,
361                                    ));
362                                    break;
363                                }
364                            }
365                        }
366                    }
367                }
368            }
369        }
370
371        // Apply matches
372        for (company, item_idx, counterparty, cp_idx) in matches_to_apply {
373            if let Some(items) = self.unmatched_items.get_mut(&company) {
374                if let Some(item) = items.get_mut(item_idx) {
375                    item.matched = true;
376                }
377            }
378            if let Some(cp_items) = self.unmatched_items.get_mut(&counterparty) {
379                if let Some(cp_item) = cp_items.get_mut(cp_idx) {
380                    cp_item.matched = true;
381                }
382            }
383        }
384    }
385
386    /// Get aggregated balances.
387    pub fn get_balances(&self) -> Vec<&ICAggregatedBalance> {
388        self.balances.values().collect()
389    }
390
391    /// Get unmatched balances.
392    pub fn get_unmatched_balances(&self) -> Vec<&ICAggregatedBalance> {
393        self.balances.values().filter(|b| !b.is_matched).collect()
394    }
395
396    /// Get balance for a specific company pair.
397    pub fn get_balance(&self, creditor: &str, debtor: &str) -> Option<&ICAggregatedBalance> {
398        self.balances
399            .get(&(creditor.to_string(), debtor.to_string()))
400    }
401
402    /// Generate netting arrangement.
403    pub fn generate_netting(
404        &self,
405        companies: Vec<String>,
406        period_start: NaiveDate,
407        period_end: NaiveDate,
408        settlement_date: NaiveDate,
409    ) -> ICNettingArrangement {
410        let netting_ref = format!("NET{}", settlement_date.format("%Y%m%d"));
411
412        let mut arrangement = ICNettingArrangement::new(
413            netting_ref,
414            companies.clone(),
415            period_start,
416            period_end,
417            settlement_date,
418            self.config.base_currency.clone(),
419        );
420
421        // Calculate gross positions for each company
422        for company in &companies {
423            let mut position =
424                ICNettingPosition::new(company.clone(), self.config.base_currency.clone());
425
426            // Sum receivables (company is creditor)
427            for ((creditor, _), balance) in &self.balances {
428                if creditor == company {
429                    position.add_receivable(balance.receivable_balance);
430                }
431            }
432
433            // Sum payables (company is debtor)
434            for ((_, debtor), balance) in &self.balances {
435                if debtor == company {
436                    position.add_payable(balance.payable_balance);
437                }
438            }
439
440            arrangement.total_gross_receivables += position.gross_receivables;
441            arrangement.total_gross_payables += position.gross_payables;
442            arrangement.gross_positions.push(position.clone());
443
444            // Net position
445            let mut net_position = position.clone();
446            net_position.net_position = position.gross_receivables - position.gross_payables;
447            arrangement.net_positions.push(net_position);
448        }
449
450        // Calculate net settlement
451        let mut total_positive = Decimal::ZERO;
452        for pos in &arrangement.net_positions {
453            if pos.net_position > Decimal::ZERO {
454                total_positive += pos.net_position;
455            }
456        }
457        arrangement.net_settlement_amount = total_positive;
458        arrangement.calculate_efficiency();
459
460        arrangement
461    }
462
463    /// Get matching statistics.
464    pub fn get_statistics(&self) -> MatchingStatistics {
465        let total_receivables: Decimal = self.balances.values().map(|b| b.receivable_balance).sum();
466        let total_payables: Decimal = self.balances.values().map(|b| b.payable_balance).sum();
467        let total_difference: Decimal = self.balances.values().map(|b| b.difference.abs()).sum();
468
469        let matched_count = self.balances.values().filter(|b| b.is_matched).count();
470        let total_count = self.balances.len();
471
472        MatchingStatistics {
473            total_company_pairs: total_count,
474            matched_pairs: matched_count,
475            unmatched_pairs: total_count - matched_count,
476            total_receivables,
477            total_payables,
478            total_difference,
479            match_rate: if total_count > 0 {
480                matched_count as f64 / total_count as f64
481            } else {
482                1.0
483            },
484        }
485    }
486
487    /// Clear all data.
488    pub fn clear(&mut self) {
489        self.balances.clear();
490        self.unmatched_items.clear();
491    }
492}
493
494/// An unmatched IC item for detailed matching.
495#[derive(Debug, Clone)]
496struct UnmatchedItem {
497    /// Company code.
498    company: String,
499    /// Counterparty company code.
500    counterparty: String,
501    /// Amount.
502    amount: Decimal,
503    /// Is this a receivable (true) or payable (false)?
504    is_receivable: bool,
505    /// IC reference number.
506    ic_reference: Option<String>,
507    /// Transaction date.
508    date: NaiveDate,
509    /// Has been matched?
510    matched: bool,
511}
512
513/// Statistics from matching process.
514#[derive(Debug, Clone)]
515pub struct MatchingStatistics {
516    /// Total number of company pairs.
517    pub total_company_pairs: usize,
518    /// Number of matched pairs.
519    pub matched_pairs: usize,
520    /// Number of unmatched pairs.
521    pub unmatched_pairs: usize,
522    /// Total receivables amount.
523    pub total_receivables: Decimal,
524    /// Total payables amount.
525    pub total_payables: Decimal,
526    /// Total difference amount.
527    pub total_difference: Decimal,
528    /// Match rate (0.0 to 1.0).
529    pub match_rate: f64,
530}
531
532/// IC Discrepancy for reconciliation.
533#[derive(Debug, Clone)]
534pub struct ICDiscrepancy {
535    /// Creditor company.
536    pub creditor: String,
537    /// Debtor company.
538    pub debtor: String,
539    /// Receivable amount per creditor.
540    pub receivable_amount: Decimal,
541    /// Payable amount per debtor.
542    pub payable_amount: Decimal,
543    /// Difference.
544    pub difference: Decimal,
545    /// Suggested action.
546    pub suggested_action: DiscrepancyAction,
547    /// Currency.
548    pub currency: String,
549}
550
551/// Suggested action for discrepancy resolution.
552#[derive(Debug, Clone, Copy, PartialEq, Eq)]
553pub enum DiscrepancyAction {
554    /// Investigate - difference is significant.
555    Investigate,
556    /// Auto-adjust - difference is within threshold.
557    AutoAdjust,
558    /// Write-off - aged discrepancy.
559    WriteOff,
560    /// Currency adjustment needed.
561    CurrencyAdjust,
562}
563
564impl ICMatchingEngine {
565    /// Identify discrepancies requiring action.
566    pub fn identify_discrepancies(&self) -> Vec<ICDiscrepancy> {
567        let mut discrepancies = Vec::new();
568
569        for balance in self.balances.values() {
570            if !balance.is_matched {
571                let action = if balance.difference.abs() <= self.config.auto_adjust_threshold {
572                    DiscrepancyAction::AutoAdjust
573                } else {
574                    DiscrepancyAction::Investigate
575                };
576
577                discrepancies.push(ICDiscrepancy {
578                    creditor: balance.creditor_company.clone(),
579                    debtor: balance.debtor_company.clone(),
580                    receivable_amount: balance.receivable_balance,
581                    payable_amount: balance.payable_balance,
582                    difference: balance.difference,
583                    suggested_action: action,
584                    currency: balance.currency.clone(),
585                });
586            }
587        }
588
589        discrepancies
590    }
591}
592
593#[cfg(test)]
594mod tests {
595    use super::*;
596
597    #[test]
598    fn test_matching_engine_basic() {
599        let config = ICMatchingConfig::default();
600        let mut engine = ICMatchingEngine::new(config);
601
602        let date = NaiveDate::from_ymd_opt(2022, 6, 15).unwrap();
603
604        // Add matching entries
605        engine.add_receivable("1000", "1100", dec!(50000), Some("IC001"), date);
606        engine.add_payable("1100", "1000", dec!(50000), Some("IC001"), date);
607
608        let result = engine.run_matching(date);
609
610        assert_eq!(result.matched_balances.len(), 1);
611        assert_eq!(result.unmatched_balances.len(), 0);
612        assert_eq!(result.match_rate, 1.0);
613    }
614
615    #[test]
616    fn test_matching_engine_discrepancy() {
617        let config = ICMatchingConfig::default();
618        let mut engine = ICMatchingEngine::new(config);
619
620        let date = NaiveDate::from_ymd_opt(2022, 6, 15).unwrap();
621
622        // Add mismatched entries
623        engine.add_receivable("1000", "1100", dec!(50000), Some("IC001"), date);
624        engine.add_payable("1100", "1000", dec!(48000), Some("IC001"), date);
625
626        let result = engine.run_matching(date);
627
628        assert_eq!(result.unmatched_balances.len(), 1);
629        assert_eq!(result.unmatched_balances[0].difference, dec!(2000));
630    }
631
632    #[test]
633    fn test_matching_by_amount() {
634        let config = ICMatchingConfig {
635            tolerance: dec!(1),
636            date_range_days: 3,
637            ..Default::default()
638        };
639
640        let mut engine = ICMatchingEngine::new(config);
641
642        let date1 = NaiveDate::from_ymd_opt(2022, 6, 15).unwrap();
643        let date2 = NaiveDate::from_ymd_opt(2022, 6, 16).unwrap();
644
645        // Add entries without IC reference but matching amounts
646        engine.add_receivable("1000", "1100", dec!(50000), None, date1);
647        engine.add_payable("1100", "1000", dec!(50000), None, date2);
648
649        let result = engine.run_matching(date2);
650
651        assert_eq!(result.matched_balances.len(), 1);
652    }
653
654    #[test]
655    fn test_generate_netting() {
656        let config = ICMatchingConfig::default();
657        let mut engine = ICMatchingEngine::new(config);
658
659        let date = NaiveDate::from_ymd_opt(2022, 6, 30).unwrap();
660
661        // Add multiple IC balances
662        engine.add_receivable("1000", "1100", dec!(100000), Some("IC001"), date);
663        engine.add_payable("1100", "1000", dec!(100000), Some("IC001"), date);
664        engine.add_receivable("1100", "1200", dec!(50000), Some("IC002"), date);
665        engine.add_payable("1200", "1100", dec!(50000), Some("IC002"), date);
666        engine.add_receivable("1200", "1000", dec!(30000), Some("IC003"), date);
667        engine.add_payable("1000", "1200", dec!(30000), Some("IC003"), date);
668
669        let netting = engine.generate_netting(
670            vec!["1000".to_string(), "1100".to_string(), "1200".to_string()],
671            NaiveDate::from_ymd_opt(2022, 6, 1).unwrap(),
672            date,
673            NaiveDate::from_ymd_opt(2022, 7, 5).unwrap(),
674        );
675
676        assert_eq!(netting.participating_companies.len(), 3);
677        assert!(netting.netting_efficiency > Decimal::ZERO);
678    }
679
680    #[test]
681    fn test_identify_discrepancies() {
682        let config = ICMatchingConfig {
683            auto_adjust_threshold: dec!(100),
684            ..Default::default()
685        };
686
687        let mut engine = ICMatchingEngine::new(config);
688        let date = NaiveDate::from_ymd_opt(2022, 6, 15).unwrap();
689
690        // Small discrepancy (auto-adjust)
691        engine.add_receivable("1000", "1100", dec!(50000), Some("IC001"), date);
692        engine.add_payable("1100", "1000", dec!(49950), Some("IC001"), date);
693
694        // Large discrepancy (investigate)
695        engine.add_receivable("1000", "1200", dec!(100000), Some("IC002"), date);
696        engine.add_payable("1200", "1000", dec!(95000), Some("IC002"), date);
697
698        engine.run_matching(date);
699        let discrepancies = engine.identify_discrepancies();
700
701        assert_eq!(discrepancies.len(), 2);
702
703        let small = discrepancies.iter().find(|d| d.debtor == "1100").unwrap();
704        assert_eq!(small.suggested_action, DiscrepancyAction::AutoAdjust);
705
706        let large = discrepancies.iter().find(|d| d.debtor == "1200").unwrap();
707        assert_eq!(large.suggested_action, DiscrepancyAction::Investigate);
708    }
709}