rustkernel_accounting/
reconciliation.rs

1//! GL reconciliation kernel.
2//!
3//! This module provides GL reconciliation for accounting:
4//! - Match items between sources
5//! - Identify exceptions
6//! - Calculate variances
7
8use crate::types::{
9    ExceptionType, MatchType, MatchedPair, ReconciliationException, ReconciliationItem,
10    ReconciliationResult, ReconciliationStats,
11};
12use rustkernel_core::{domain::Domain, kernel::KernelMetadata, traits::GpuKernel};
13use std::collections::HashMap;
14
15// ============================================================================
16// GL Reconciliation Kernel
17// ============================================================================
18
19/// GL reconciliation kernel.
20///
21/// Reconciles items between general ledger and sub-ledgers.
22#[derive(Debug, Clone)]
23pub struct GLReconciliation {
24    metadata: KernelMetadata,
25}
26
27impl Default for GLReconciliation {
28    fn default() -> Self {
29        Self::new()
30    }
31}
32
33impl GLReconciliation {
34    /// Create a new GL reconciliation kernel.
35    #[must_use]
36    pub fn new() -> Self {
37        Self {
38            metadata: KernelMetadata::batch("accounting/gl-reconciliation", Domain::Accounting)
39                .with_description("General ledger reconciliation")
40                .with_throughput(20_000)
41                .with_latency_us(100.0),
42        }
43    }
44
45    /// Reconcile items between two sources.
46    pub fn reconcile(
47        source_items: &[ReconciliationItem],
48        target_items: &[ReconciliationItem],
49        config: &ReconciliationConfig,
50    ) -> ReconciliationResult {
51        let mut matched_pairs = Vec::new();
52        let mut unmatched = Vec::new();
53        let mut exceptions = Vec::new();
54        let mut total_variance = 0.0;
55
56        let mut used_targets: Vec<bool> = vec![false; target_items.len()];
57
58        // Group source items by account for efficient matching
59        let source_by_account: HashMap<&str, Vec<(usize, &ReconciliationItem)>> = source_items
60            .iter()
61            .enumerate()
62            .fold(HashMap::new(), |mut acc, (i, item)| {
63                acc.entry(item.account_code.as_str())
64                    .or_default()
65                    .push((i, item));
66                acc
67            });
68
69        let target_by_account: HashMap<&str, Vec<(usize, &ReconciliationItem)>> = target_items
70            .iter()
71            .enumerate()
72            .fold(HashMap::new(), |mut acc, (i, item)| {
73                acc.entry(item.account_code.as_str())
74                    .or_default()
75                    .push((i, item));
76                acc
77            });
78
79        // Match items
80        for (account, sources) in &source_by_account {
81            if let Some(targets) = target_by_account.get(account) {
82                for (_source_idx, source_item) in sources {
83                    let best_match =
84                        Self::find_best_match(source_item, targets, &used_targets, config);
85
86                    match best_match {
87                        Some((target_idx, confidence, variance, match_type)) => {
88                            used_targets[target_idx] = true;
89                            total_variance += variance.abs();
90
91                            matched_pairs.push(MatchedPair {
92                                source_id: source_item.id.clone(),
93                                target_id: target_items[target_idx].id.clone(),
94                                confidence,
95                                variance,
96                                match_type,
97                            });
98
99                            // Check for variance exception
100                            if variance.abs() > config.variance_threshold {
101                                exceptions.push(ReconciliationException {
102                                    item_id: source_item.id.clone(),
103                                    exception_type: ExceptionType::AmountVariance,
104                                    description: format!(
105                                        "Amount variance of {} exceeds threshold",
106                                        variance
107                                    ),
108                                    suggested_action: Some("Review and adjust".to_string()),
109                                });
110                            }
111                        }
112                        None => {
113                            unmatched.push(source_item.id.clone());
114                            exceptions.push(ReconciliationException {
115                                item_id: source_item.id.clone(),
116                                exception_type: ExceptionType::MissingCounterpart,
117                                description: "No matching item found in target".to_string(),
118                                suggested_action: Some("Investigate missing item".to_string()),
119                            });
120                        }
121                    }
122                }
123            } else {
124                // No targets for this account
125                for (_, source_item) in sources {
126                    unmatched.push(source_item.id.clone());
127                }
128            }
129        }
130
131        // Add unmatched targets
132        for (i, target) in target_items.iter().enumerate() {
133            if !used_targets[i] {
134                unmatched.push(target.id.clone());
135                exceptions.push(ReconciliationException {
136                    item_id: target.id.clone(),
137                    exception_type: ExceptionType::MissingCounterpart,
138                    description: "Target item has no matching source".to_string(),
139                    suggested_action: Some("Investigate orphan item".to_string()),
140                });
141            }
142        }
143
144        let total_items = source_items.len() + target_items.len();
145        let matched_count = matched_pairs.len() * 2;
146        let match_rate = if total_items > 0 {
147            matched_count as f64 / total_items as f64
148        } else {
149            0.0
150        };
151
152        let exception_count = exceptions.len();
153
154        ReconciliationResult {
155            matched_pairs,
156            unmatched,
157            exceptions,
158            stats: ReconciliationStats {
159                total_items,
160                matched_count,
161                unmatched_count: total_items - matched_count,
162                exception_count,
163                match_rate,
164                total_variance,
165            },
166        }
167    }
168
169    /// Find best matching target for a source item.
170    fn find_best_match(
171        source: &ReconciliationItem,
172        targets: &[(usize, &ReconciliationItem)],
173        used_targets: &[bool],
174        config: &ReconciliationConfig,
175    ) -> Option<(usize, f64, f64, MatchType)> {
176        let mut best: Option<(usize, f64, f64, MatchType)> = None;
177
178        for &(target_idx, target) in targets {
179            if used_targets[target_idx] {
180                continue;
181            }
182
183            // Check currency match
184            if source.currency != target.currency {
185                continue;
186            }
187
188            // Calculate variance
189            let variance = source.amount - target.amount;
190            let abs_variance = variance.abs();
191            let pct_variance = if source.amount.abs() > 0.0 {
192                abs_variance / source.amount.abs()
193            } else {
194                0.0
195            };
196
197            // Determine match type and confidence
198            let (match_type, confidence) = if abs_variance < 0.001 {
199                (MatchType::Exact, 1.0)
200            } else if abs_variance <= config.amount_tolerance
201                || pct_variance <= config.percentage_tolerance
202            {
203                let conf = 1.0 - (pct_variance / config.percentage_tolerance).min(1.0);
204                (MatchType::Tolerance, conf * 0.9)
205            } else {
206                continue; // No match
207            };
208
209            // Check date tolerance
210            let date_diff = (source.date as i64 - target.date as i64).unsigned_abs();
211            if date_diff > config.date_tolerance_days as u64 * 86400 {
212                continue;
213            }
214
215            // Check reference match boost
216            let ref_boost = if config.match_on_reference
217                && !source.reference.is_empty()
218                && source.reference == target.reference
219            {
220                0.1
221            } else {
222                0.0
223            };
224
225            let final_confidence = (confidence + ref_boost).min(1.0);
226
227            // Keep best match
228            if best.is_none() || final_confidence > best.as_ref().unwrap().1 {
229                best = Some((target_idx, final_confidence, variance, match_type));
230            }
231        }
232
233        best
234    }
235
236    /// Reconcile with many-to-one matching.
237    pub fn reconcile_many_to_one(
238        source_items: &[ReconciliationItem],
239        target_items: &[ReconciliationItem],
240        config: &ReconciliationConfig,
241    ) -> ReconciliationResult {
242        let mut matched_pairs = Vec::new();
243        let mut unmatched = Vec::new();
244        let mut exceptions = Vec::new();
245        let mut total_variance = 0.0;
246
247        // Group source items by account and sum
248        let mut source_totals: HashMap<String, (f64, Vec<String>)> = HashMap::new();
249        for item in source_items {
250            let entry = source_totals
251                .entry(item.account_code.clone())
252                .or_insert((0.0, Vec::new()));
253            entry.0 += item.amount;
254            entry.1.push(item.id.clone());
255        }
256
257        // Match against targets
258        for target in target_items {
259            if let Some((source_total, source_ids)) = source_totals.get(&target.account_code) {
260                let variance = *source_total - target.amount;
261
262                if variance.abs() <= config.amount_tolerance
263                    || (variance.abs() / target.amount.abs()) <= config.percentage_tolerance
264                {
265                    // Create many-to-one match
266                    for source_id in source_ids {
267                        matched_pairs.push(MatchedPair {
268                            source_id: source_id.clone(),
269                            target_id: target.id.clone(),
270                            confidence: 0.9,
271                            variance: variance / source_ids.len() as f64,
272                            match_type: MatchType::ManyToOne,
273                        });
274                    }
275                    total_variance += variance.abs();
276                } else {
277                    // Variance too large
278                    unmatched.push(target.id.clone());
279                    exceptions.push(ReconciliationException {
280                        item_id: target.id.clone(),
281                        exception_type: ExceptionType::AmountVariance,
282                        description: format!("Sum variance of {} exceeds tolerance", variance),
283                        suggested_action: None,
284                    });
285                }
286            } else {
287                unmatched.push(target.id.clone());
288            }
289        }
290
291        let total_items = source_items.len() + target_items.len();
292        let matched_count = matched_pairs.len();
293        let exception_count = exceptions.len();
294
295        ReconciliationResult {
296            matched_pairs,
297            unmatched,
298            exceptions,
299            stats: ReconciliationStats {
300                total_items,
301                matched_count,
302                unmatched_count: total_items - matched_count,
303                exception_count,
304                match_rate: matched_count as f64 / total_items.max(1) as f64,
305                total_variance,
306            },
307        }
308    }
309
310    /// Identify potential duplicates.
311    pub fn find_duplicates(
312        items: &[ReconciliationItem],
313        config: &DuplicateConfig,
314    ) -> Vec<DuplicateGroup> {
315        let mut groups: Vec<DuplicateGroup> = Vec::new();
316
317        for i in 0..items.len() {
318            for j in (i + 1)..items.len() {
319                let a = &items[i];
320                let b = &items[j];
321
322                let is_dup = Self::check_duplicate(a, b, config);
323
324                if is_dup {
325                    // Check if either is already in a group
326                    let mut found_group = false;
327                    for group in &mut groups {
328                        if group.item_ids.contains(&a.id) || group.item_ids.contains(&b.id) {
329                            if !group.item_ids.contains(&a.id) {
330                                group.item_ids.push(a.id.clone());
331                            }
332                            if !group.item_ids.contains(&b.id) {
333                                group.item_ids.push(b.id.clone());
334                            }
335                            found_group = true;
336                            break;
337                        }
338                    }
339
340                    if !found_group {
341                        groups.push(DuplicateGroup {
342                            item_ids: vec![a.id.clone(), b.id.clone()],
343                            total_amount: a.amount + b.amount,
344                            account_code: a.account_code.clone(),
345                        });
346                    }
347                }
348            }
349        }
350
351        groups
352    }
353
354    /// Check if two items are duplicates.
355    fn check_duplicate(
356        a: &ReconciliationItem,
357        b: &ReconciliationItem,
358        config: &DuplicateConfig,
359    ) -> bool {
360        // Same account
361        if a.account_code != b.account_code {
362            return false;
363        }
364
365        // Same or very close amount
366        if (a.amount - b.amount).abs() > config.amount_threshold {
367            return false;
368        }
369
370        // Same currency
371        if a.currency != b.currency {
372            return false;
373        }
374
375        // Within date range
376        let date_diff = (a.date as i64 - b.date as i64).unsigned_abs();
377        if date_diff > config.date_range_days as u64 * 86400 {
378            return false;
379        }
380
381        // Check reference if configured
382        if config.match_reference && !a.reference.is_empty() && a.reference == b.reference {
383            return true;
384        }
385
386        // Check description similarity (simplified)
387        if config.match_description && a.source == b.source {
388            return true;
389        }
390
391        // Default: consider duplicate if all basic criteria match
392        true
393    }
394}
395
396impl GpuKernel for GLReconciliation {
397    fn metadata(&self) -> &KernelMetadata {
398        &self.metadata
399    }
400}
401
402/// Reconciliation configuration.
403#[derive(Debug, Clone)]
404pub struct ReconciliationConfig {
405    /// Amount tolerance (absolute).
406    pub amount_tolerance: f64,
407    /// Percentage tolerance.
408    pub percentage_tolerance: f64,
409    /// Date tolerance in days.
410    pub date_tolerance_days: u32,
411    /// Match on reference field.
412    pub match_on_reference: bool,
413    /// Variance threshold for exceptions.
414    pub variance_threshold: f64,
415}
416
417impl Default for ReconciliationConfig {
418    fn default() -> Self {
419        Self {
420            amount_tolerance: 0.01,
421            percentage_tolerance: 0.001,
422            date_tolerance_days: 3,
423            match_on_reference: true,
424            variance_threshold: 1.0,
425        }
426    }
427}
428
429/// Duplicate detection configuration.
430#[derive(Debug, Clone)]
431pub struct DuplicateConfig {
432    /// Amount threshold for duplicates.
433    pub amount_threshold: f64,
434    /// Date range in days.
435    pub date_range_days: u32,
436    /// Match on reference.
437    pub match_reference: bool,
438    /// Match on description.
439    pub match_description: bool,
440}
441
442impl Default for DuplicateConfig {
443    fn default() -> Self {
444        Self {
445            amount_threshold: 0.01,
446            date_range_days: 7,
447            match_reference: true,
448            match_description: false,
449        }
450    }
451}
452
453/// Duplicate group.
454#[derive(Debug, Clone)]
455pub struct DuplicateGroup {
456    /// Item IDs in this group.
457    pub item_ids: Vec<String>,
458    /// Total amount.
459    pub total_amount: f64,
460    /// Account code.
461    pub account_code: String,
462}
463
464#[cfg(test)]
465mod tests {
466    use super::*;
467    use crate::types::{ReconciliationSource, ReconciliationStatus};
468
469    fn create_test_source() -> Vec<ReconciliationItem> {
470        vec![
471            ReconciliationItem {
472                id: "S1".to_string(),
473                source: ReconciliationSource::GeneralLedger,
474                account_code: "1000".to_string(),
475                amount: 1000.0,
476                currency: "USD".to_string(),
477                date: 1700000000,
478                reference: "REF001".to_string(),
479                status: ReconciliationStatus::Unmatched,
480                matched_with: None,
481            },
482            ReconciliationItem {
483                id: "S2".to_string(),
484                source: ReconciliationSource::GeneralLedger,
485                account_code: "2000".to_string(),
486                amount: 2500.0,
487                currency: "USD".to_string(),
488                date: 1700000000,
489                reference: "REF002".to_string(),
490                status: ReconciliationStatus::Unmatched,
491                matched_with: None,
492            },
493        ]
494    }
495
496    fn create_test_target() -> Vec<ReconciliationItem> {
497        vec![
498            ReconciliationItem {
499                id: "T1".to_string(),
500                source: ReconciliationSource::SubLedger,
501                account_code: "1000".to_string(),
502                amount: 1000.0,
503                currency: "USD".to_string(),
504                date: 1700000000,
505                reference: "REF001".to_string(),
506                status: ReconciliationStatus::Unmatched,
507                matched_with: None,
508            },
509            ReconciliationItem {
510                id: "T2".to_string(),
511                source: ReconciliationSource::SubLedger,
512                account_code: "2000".to_string(),
513                amount: 2500.5, // Slight variance
514                currency: "USD".to_string(),
515                date: 1700000000,
516                reference: "REF002".to_string(),
517                status: ReconciliationStatus::Unmatched,
518                matched_with: None,
519            },
520        ]
521    }
522
523    #[test]
524    fn test_reconciliation_metadata() {
525        let kernel = GLReconciliation::new();
526        assert_eq!(kernel.metadata().id, "accounting/gl-reconciliation");
527        assert_eq!(kernel.metadata().domain, Domain::Accounting);
528    }
529
530    #[test]
531    fn test_exact_match() {
532        let source = create_test_source();
533        let target = create_test_target();
534        let config = ReconciliationConfig::default();
535
536        let result = GLReconciliation::reconcile(&source, &target, &config);
537
538        // First pair should be exact match
539        let first_match = result
540            .matched_pairs
541            .iter()
542            .find(|p| p.source_id == "S1")
543            .unwrap();
544        assert_eq!(first_match.match_type, MatchType::Exact);
545        assert!((first_match.confidence - 1.0).abs() < 0.001);
546    }
547
548    #[test]
549    fn test_tolerance_match() {
550        let source = create_test_source();
551        let target = create_test_target();
552        let config = ReconciliationConfig {
553            amount_tolerance: 1.0,
554            ..Default::default()
555        };
556
557        let result = GLReconciliation::reconcile(&source, &target, &config);
558
559        // Second pair should be tolerance match
560        let second_match = result
561            .matched_pairs
562            .iter()
563            .find(|p| p.source_id == "S2")
564            .unwrap();
565        assert_eq!(second_match.match_type, MatchType::Tolerance);
566        assert!((second_match.variance - (-0.5)).abs() < 0.01);
567    }
568
569    #[test]
570    fn test_no_match() {
571        let source = vec![ReconciliationItem {
572            id: "S1".to_string(),
573            source: ReconciliationSource::GeneralLedger,
574            account_code: "9999".to_string(), // Different account
575            amount: 1000.0,
576            currency: "USD".to_string(),
577            date: 1700000000,
578            reference: "REF001".to_string(),
579            status: ReconciliationStatus::Unmatched,
580            matched_with: None,
581        }];
582
583        let target = create_test_target();
584        let config = ReconciliationConfig::default();
585
586        let result = GLReconciliation::reconcile(&source, &target, &config);
587
588        assert!(result.matched_pairs.is_empty());
589        assert!(!result.unmatched.is_empty());
590    }
591
592    #[test]
593    fn test_currency_mismatch() {
594        let source = vec![ReconciliationItem {
595            id: "S1".to_string(),
596            source: ReconciliationSource::GeneralLedger,
597            account_code: "1000".to_string(),
598            amount: 1000.0,
599            currency: "EUR".to_string(), // Different currency
600            date: 1700000000,
601            reference: "REF001".to_string(),
602            status: ReconciliationStatus::Unmatched,
603            matched_with: None,
604        }];
605
606        let target = create_test_target();
607        let config = ReconciliationConfig::default();
608
609        let result = GLReconciliation::reconcile(&source, &target, &config);
610
611        assert!(result.matched_pairs.is_empty());
612    }
613
614    #[test]
615    fn test_variance_exception() {
616        let source = create_test_source();
617        let mut target = create_test_target();
618        target[0].amount = 1002.0; // Variance > threshold
619
620        let config = ReconciliationConfig {
621            amount_tolerance: 5.0,
622            variance_threshold: 1.0,
623            ..Default::default()
624        };
625
626        let result = GLReconciliation::reconcile(&source, &target, &config);
627
628        assert!(
629            result
630                .exceptions
631                .iter()
632                .any(|e| e.exception_type == ExceptionType::AmountVariance)
633        );
634    }
635
636    #[test]
637    fn test_many_to_one() {
638        let source = vec![
639            ReconciliationItem {
640                id: "S1".to_string(),
641                source: ReconciliationSource::GeneralLedger,
642                account_code: "1000".to_string(),
643                amount: 500.0,
644                currency: "USD".to_string(),
645                date: 1700000000,
646                reference: "".to_string(),
647                status: ReconciliationStatus::Unmatched,
648                matched_with: None,
649            },
650            ReconciliationItem {
651                id: "S2".to_string(),
652                source: ReconciliationSource::GeneralLedger,
653                account_code: "1000".to_string(),
654                amount: 500.0,
655                currency: "USD".to_string(),
656                date: 1700000000,
657                reference: "".to_string(),
658                status: ReconciliationStatus::Unmatched,
659                matched_with: None,
660            },
661        ];
662
663        let target = vec![ReconciliationItem {
664            id: "T1".to_string(),
665            source: ReconciliationSource::SubLedger,
666            account_code: "1000".to_string(),
667            amount: 1000.0,
668            currency: "USD".to_string(),
669            date: 1700000000,
670            reference: "".to_string(),
671            status: ReconciliationStatus::Unmatched,
672            matched_with: None,
673        }];
674
675        let config = ReconciliationConfig::default();
676        let result = GLReconciliation::reconcile_many_to_one(&source, &target, &config);
677
678        assert_eq!(result.matched_pairs.len(), 2);
679        assert!(
680            result
681                .matched_pairs
682                .iter()
683                .all(|p| p.match_type == MatchType::ManyToOne)
684        );
685    }
686
687    #[test]
688    fn test_find_duplicates() {
689        let items = vec![
690            ReconciliationItem {
691                id: "S1".to_string(),
692                source: ReconciliationSource::GeneralLedger,
693                account_code: "1000".to_string(),
694                amount: 1000.0,
695                currency: "USD".to_string(),
696                date: 1700000000,
697                reference: "REF001".to_string(),
698                status: ReconciliationStatus::Unmatched,
699                matched_with: None,
700            },
701            ReconciliationItem {
702                id: "S2".to_string(),
703                source: ReconciliationSource::GeneralLedger,
704                account_code: "1000".to_string(),
705                amount: 1000.0,
706                currency: "USD".to_string(),
707                date: 1700000000 + 86400, // Next day
708                reference: "REF001".to_string(),
709                status: ReconciliationStatus::Unmatched,
710                matched_with: None,
711            },
712        ];
713
714        let config = DuplicateConfig::default();
715        let duplicates = GLReconciliation::find_duplicates(&items, &config);
716
717        assert_eq!(duplicates.len(), 1);
718        assert_eq!(duplicates[0].item_ids.len(), 2);
719    }
720
721    #[test]
722    fn test_match_rate() {
723        let source = create_test_source();
724        let target = create_test_target();
725        let config = ReconciliationConfig {
726            amount_tolerance: 1.0,
727            ..Default::default()
728        };
729
730        let result = GLReconciliation::reconcile(&source, &target, &config);
731
732        // All 4 items should be matched (2 pairs)
733        assert!((result.stats.match_rate - 1.0).abs() < 0.001);
734    }
735}