1use 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#[derive(Debug, Clone)]
18pub struct ICMatchingResult {
19 pub matched_balances: Vec<ICAggregatedBalance>,
21 pub unmatched_balances: Vec<ICAggregatedBalance>,
23 pub total_matched: Decimal,
25 pub total_unmatched: Decimal,
27 pub match_rate: f64,
29 pub as_of_date: NaiveDate,
31 pub tolerance: Decimal,
33}
34
35#[derive(Debug, Clone)]
37pub struct ICMatchingConfig {
38 pub tolerance: Decimal,
40 pub match_by_reference: bool,
42 pub match_by_amount: bool,
44 pub date_range_days: i64,
46 pub auto_adjust_threshold: Decimal,
48 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
65pub struct ICMatchingEngine {
67 config: ICMatchingConfig,
69 balances: HashMap<(String, String), ICAggregatedBalance>,
71 unmatched_items: HashMap<String, Vec<UnmatchedItem>>,
73 matching_history: Vec<ICMatchingResult>,
75}
76
77impl ICMatchingEngine {
78 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 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 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 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 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 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 pub fn load_journal_entries(&mut self, entries: &[JournalEntry]) {
188 for entry in entries {
189 for line in &entry.lines {
191 if line.account_code.starts_with("1310") && line.debit_amount > Decimal::ZERO {
193 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 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 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 if self.config.match_by_reference {
228 self.match_by_reference();
229 }
230
231 if self.config.match_by_amount {
233 self.match_by_amount();
234 }
235
236 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 fn match_by_reference(&mut self) {
270 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 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 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 fn match_by_amount(&mut self) {
328 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 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 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 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 pub fn get_balances(&self) -> Vec<&ICAggregatedBalance> {
388 self.balances.values().collect()
389 }
390
391 pub fn get_unmatched_balances(&self) -> Vec<&ICAggregatedBalance> {
393 self.balances.values().filter(|b| !b.is_matched).collect()
394 }
395
396 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 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 for company in &companies {
423 let mut position =
424 ICNettingPosition::new(company.clone(), self.config.base_currency.clone());
425
426 for ((creditor, _), balance) in &self.balances {
428 if creditor == company {
429 position.add_receivable(balance.receivable_balance);
430 }
431 }
432
433 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 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 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 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 pub fn clear(&mut self) {
489 self.balances.clear();
490 self.unmatched_items.clear();
491 }
492}
493
494#[derive(Debug, Clone)]
496struct UnmatchedItem {
497 company: String,
499 counterparty: String,
501 amount: Decimal,
503 is_receivable: bool,
505 ic_reference: Option<String>,
507 date: NaiveDate,
509 matched: bool,
511}
512
513#[derive(Debug, Clone)]
515pub struct MatchingStatistics {
516 pub total_company_pairs: usize,
518 pub matched_pairs: usize,
520 pub unmatched_pairs: usize,
522 pub total_receivables: Decimal,
524 pub total_payables: Decimal,
526 pub total_difference: Decimal,
528 pub match_rate: f64,
530}
531
532#[derive(Debug, Clone)]
534pub struct ICDiscrepancy {
535 pub creditor: String,
537 pub debtor: String,
539 pub receivable_amount: Decimal,
541 pub payable_amount: Decimal,
543 pub difference: Decimal,
545 pub suggested_action: DiscrepancyAction,
547 pub currency: String,
549}
550
551#[derive(Debug, Clone, Copy, PartialEq, Eq)]
553pub enum DiscrepancyAction {
554 Investigate,
556 AutoAdjust,
558 WriteOff,
560 CurrencyAdjust,
562}
563
564impl ICMatchingEngine {
565 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 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 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 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 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 engine.add_receivable("1000", "1100", dec!(50000), Some("IC001"), date);
692 engine.add_payable("1100", "1000", dec!(49950), Some("IC001"), date);
693
694 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}