rustkernel_accounting/
network.rs

1//! Intercompany network analysis kernel.
2//!
3//! This module provides intercompany network analysis for accounting:
4//! - Analyze intercompany relationships
5//! - Detect circular references
6//! - Generate elimination entries
7
8use crate::types::{
9    CircularReference, EliminationEntry, EntityBalance, EntityRelationship, IntercompanyStatus,
10    IntercompanyTransaction, IntercompanyType, NetworkAnalysisResult, NetworkStats,
11};
12use rustkernel_core::{domain::Domain, kernel::KernelMetadata, traits::GpuKernel};
13use std::collections::{HashMap, HashSet};
14
15// ============================================================================
16// Network Analysis Kernel
17// ============================================================================
18
19/// Intercompany network analysis kernel.
20///
21/// Analyzes intercompany transactions and relationships.
22#[derive(Debug, Clone)]
23pub struct NetworkAnalysis {
24    metadata: KernelMetadata,
25}
26
27impl Default for NetworkAnalysis {
28    fn default() -> Self {
29        Self::new()
30    }
31}
32
33impl NetworkAnalysis {
34    /// Create a new network analysis kernel.
35    #[must_use]
36    pub fn new() -> Self {
37        Self {
38            metadata: KernelMetadata::batch("accounting/network-analysis", Domain::Accounting)
39                .with_description("Intercompany network analysis")
40                .with_throughput(10_000)
41                .with_latency_us(200.0),
42        }
43    }
44
45    /// Analyze intercompany network.
46    pub fn analyze(
47        transactions: &[IntercompanyTransaction],
48        config: &NetworkConfig,
49    ) -> NetworkAnalysisResult {
50        // Calculate entity balances
51        let entity_balances = Self::calculate_entity_balances(transactions);
52
53        // Calculate relationships
54        let relationships = Self::calculate_relationships(transactions);
55
56        // Find circular references
57        let circular_refs = Self::find_circular_references(transactions, config);
58
59        // Generate elimination entries
60        let elimination_entries = Self::generate_eliminations(transactions, config);
61
62        let entities: HashSet<_> = transactions
63            .iter()
64            .flat_map(|t| [t.from_entity.clone(), t.to_entity.clone()])
65            .collect();
66
67        let total_volume: f64 = transactions.iter().map(|t| t.amount).sum();
68
69        NetworkAnalysisResult {
70            entity_balances,
71            relationships,
72            circular_refs: circular_refs.clone(),
73            elimination_entries: elimination_entries.clone(),
74            stats: NetworkStats {
75                total_entities: entities.len(),
76                total_transactions: transactions.len(),
77                total_volume,
78                circular_count: circular_refs.len(),
79                elimination_count: elimination_entries.len(),
80            },
81        }
82    }
83
84    /// Calculate entity balances.
85    fn calculate_entity_balances(
86        transactions: &[IntercompanyTransaction],
87    ) -> HashMap<String, EntityBalance> {
88        let mut balances: HashMap<String, EntityBalance> = HashMap::new();
89
90        for txn in transactions {
91            if txn.status == IntercompanyStatus::Eliminated {
92                continue;
93            }
94
95            // From entity has a receivable
96            let from_balance =
97                balances
98                    .entry(txn.from_entity.clone())
99                    .or_insert_with(|| EntityBalance {
100                        entity_id: txn.from_entity.clone(),
101                        total_receivables: 0.0,
102                        total_payables: 0.0,
103                        net_position: 0.0,
104                        counterparty_count: 0,
105                    });
106            from_balance.total_receivables += txn.amount;
107
108            // To entity has a payable
109            let to_balance =
110                balances
111                    .entry(txn.to_entity.clone())
112                    .or_insert_with(|| EntityBalance {
113                        entity_id: txn.to_entity.clone(),
114                        total_receivables: 0.0,
115                        total_payables: 0.0,
116                        net_position: 0.0,
117                        counterparty_count: 0,
118                    });
119            to_balance.total_payables += txn.amount;
120        }
121
122        // Calculate net positions and counterparty counts
123        let counterparty_counts = Self::count_counterparties(transactions);
124        for (entity_id, balance) in &mut balances {
125            balance.net_position = balance.total_receivables - balance.total_payables;
126            balance.counterparty_count = counterparty_counts.get(entity_id).copied().unwrap_or(0);
127        }
128
129        balances
130    }
131
132    /// Count counterparties for each entity.
133    fn count_counterparties(transactions: &[IntercompanyTransaction]) -> HashMap<String, usize> {
134        let mut counterparties: HashMap<String, HashSet<String>> = HashMap::new();
135
136        for txn in transactions {
137            counterparties
138                .entry(txn.from_entity.clone())
139                .or_default()
140                .insert(txn.to_entity.clone());
141            counterparties
142                .entry(txn.to_entity.clone())
143                .or_default()
144                .insert(txn.from_entity.clone());
145        }
146
147        counterparties
148            .into_iter()
149            .map(|(k, v)| (k, v.len()))
150            .collect()
151    }
152
153    /// Calculate entity relationships.
154    fn calculate_relationships(
155        transactions: &[IntercompanyTransaction],
156    ) -> Vec<EntityRelationship> {
157        let mut relationships: HashMap<(String, String), EntityRelationship> = HashMap::new();
158
159        for txn in transactions {
160            if txn.status == IntercompanyStatus::Eliminated {
161                continue;
162            }
163
164            let key = if txn.from_entity < txn.to_entity {
165                (txn.from_entity.clone(), txn.to_entity.clone())
166            } else {
167                (txn.to_entity.clone(), txn.from_entity.clone())
168            };
169
170            let rel = relationships
171                .entry(key.clone())
172                .or_insert_with(|| EntityRelationship {
173                    from_entity: key.0.clone(),
174                    to_entity: key.1.clone(),
175                    total_volume: 0.0,
176                    transaction_count: 0,
177                    net_balance: 0.0,
178                });
179
180            rel.total_volume += txn.amount;
181            rel.transaction_count += 1;
182
183            // Adjust net balance based on direction
184            if txn.from_entity == key.0 {
185                rel.net_balance += txn.amount;
186            } else {
187                rel.net_balance -= txn.amount;
188            }
189        }
190
191        relationships.into_values().collect()
192    }
193
194    /// Find circular references in the transaction network.
195    fn find_circular_references(
196        transactions: &[IntercompanyTransaction],
197        config: &NetworkConfig,
198    ) -> Vec<CircularReference> {
199        let mut circular_refs = Vec::new();
200
201        // Build adjacency list
202        let mut graph: HashMap<String, Vec<(String, f64)>> = HashMap::new();
203        for txn in transactions {
204            if txn.status == IntercompanyStatus::Eliminated {
205                continue;
206            }
207            graph
208                .entry(txn.from_entity.clone())
209                .or_default()
210                .push((txn.to_entity.clone(), txn.amount));
211        }
212
213        // Find cycles using DFS
214        let entities: HashSet<_> = graph.keys().cloned().collect();
215
216        for start in &entities {
217            let mut path = vec![start.clone()];
218            let mut visited: HashSet<String> = HashSet::new();
219            visited.insert(start.clone());
220
221            Self::dfs_find_cycles(
222                &graph,
223                start,
224                &mut path,
225                &mut visited,
226                &mut circular_refs,
227                config.max_cycle_length,
228            );
229        }
230
231        // Deduplicate cycles
232        let mut seen: HashSet<Vec<String>> = HashSet::new();
233        circular_refs.retain(|c| {
234            let mut sorted = c.entities.clone();
235            sorted.sort();
236            seen.insert(sorted)
237        });
238
239        circular_refs
240    }
241
242    /// DFS to find cycles.
243    fn dfs_find_cycles(
244        graph: &HashMap<String, Vec<(String, f64)>>,
245        current: &str,
246        path: &mut Vec<String>,
247        visited: &mut HashSet<String>,
248        cycles: &mut Vec<CircularReference>,
249        max_length: usize,
250    ) {
251        if path.len() > max_length {
252            return;
253        }
254
255        if let Some(neighbors) = graph.get(current) {
256            for (next, _amount) in neighbors {
257                if *next == path[0] && path.len() >= 2 {
258                    // Found a cycle
259                    let total_amount: f64 = path
260                        .windows(2)
261                        .filter_map(|w| {
262                            graph.get(&w[0]).and_then(|edges| {
263                                edges
264                                    .iter()
265                                    .find(|(to, _)| to == &w[1])
266                                    .map(|(_, amt)| *amt)
267                            })
268                        })
269                        .sum();
270
271                    cycles.push(CircularReference {
272                        entities: path.clone(),
273                        amount: total_amount,
274                        consolidation_impact: total_amount * 0.5, // Simplified impact
275                    });
276                } else if !visited.contains(next) {
277                    visited.insert(next.clone());
278                    path.push(next.clone());
279                    Self::dfs_find_cycles(graph, next, path, visited, cycles, max_length);
280                    path.pop();
281                    visited.remove(next);
282                }
283            }
284        }
285    }
286
287    /// Generate elimination entries.
288    fn generate_eliminations(
289        transactions: &[IntercompanyTransaction],
290        config: &NetworkConfig,
291    ) -> Vec<EliminationEntry> {
292        let mut eliminations = Vec::new();
293        let mut entry_id = 1;
294
295        // Group transactions that need elimination
296        for txn in transactions {
297            if txn.status != IntercompanyStatus::Confirmed {
298                continue;
299            }
300
301            if txn.amount < config.min_elimination_amount {
302                continue;
303            }
304
305            let (debit_account, credit_account) =
306                Self::get_elimination_accounts(&txn.transaction_type);
307
308            eliminations.push(EliminationEntry {
309                id: format!("ELIM{:05}", entry_id),
310                from_entity: txn.from_entity.clone(),
311                to_entity: txn.to_entity.clone(),
312                debit_account,
313                credit_account,
314                amount: txn.amount,
315                currency: txn.currency.clone(),
316            });
317
318            entry_id += 1;
319        }
320
321        eliminations
322    }
323
324    /// Get elimination accounts based on transaction type.
325    fn get_elimination_accounts(txn_type: &IntercompanyType) -> (String, String) {
326        match txn_type {
327            IntercompanyType::Trade => ("IC_PAYABLES".to_string(), "IC_RECEIVABLES".to_string()),
328            IntercompanyType::Loan => (
329                "IC_LOAN_PAYABLE".to_string(),
330                "IC_LOAN_RECEIVABLE".to_string(),
331            ),
332            IntercompanyType::Dividend => (
333                "DIVIDEND_INCOME".to_string(),
334                "DIVIDEND_EXPENSE".to_string(),
335            ),
336            IntercompanyType::ManagementFee => (
337                "MGMT_FEE_INCOME".to_string(),
338                "MGMT_FEE_EXPENSE".to_string(),
339            ),
340            IntercompanyType::Royalty => {
341                ("ROYALTY_INCOME".to_string(), "ROYALTY_EXPENSE".to_string())
342            }
343            IntercompanyType::Other => (
344                "IC_OTHER_PAYABLE".to_string(),
345                "IC_OTHER_RECEIVABLE".to_string(),
346            ),
347        }
348    }
349
350    /// Calculate netting opportunities.
351    pub fn calculate_netting(transactions: &[IntercompanyTransaction]) -> Vec<NettingOpportunity> {
352        let mut opportunities = Vec::new();
353
354        // Find bilateral netting opportunities
355        let mut bilateral: HashMap<(String, String), (f64, f64)> = HashMap::new();
356
357        for txn in transactions {
358            if txn.status == IntercompanyStatus::Eliminated {
359                continue;
360            }
361
362            let key = if txn.from_entity < txn.to_entity {
363                (txn.from_entity.clone(), txn.to_entity.clone())
364            } else {
365                (txn.to_entity.clone(), txn.from_entity.clone())
366            };
367
368            let entry = bilateral.entry(key.clone()).or_insert((0.0, 0.0));
369            if txn.from_entity == key.0 {
370                entry.0 += txn.amount;
371            } else {
372                entry.1 += txn.amount;
373            }
374        }
375
376        for ((from, to), (amount_forward, amount_backward)) in bilateral {
377            if amount_forward > 0.0 && amount_backward > 0.0 {
378                let net_amount = (amount_forward - amount_backward).abs();
379                let gross_reduction = amount_forward.min(amount_backward) * 2.0;
380
381                opportunities.push(NettingOpportunity {
382                    entities: vec![from, to],
383                    gross_amount: amount_forward + amount_backward,
384                    net_amount,
385                    reduction: gross_reduction,
386                });
387            }
388        }
389
390        opportunities
391    }
392}
393
394impl GpuKernel for NetworkAnalysis {
395    fn metadata(&self) -> &KernelMetadata {
396        &self.metadata
397    }
398}
399
400/// Network analysis configuration.
401#[derive(Debug, Clone)]
402pub struct NetworkConfig {
403    /// Maximum cycle length to detect.
404    pub max_cycle_length: usize,
405    /// Minimum amount for elimination.
406    pub min_elimination_amount: f64,
407    /// Include disputed transactions.
408    pub include_disputed: bool,
409}
410
411impl Default for NetworkConfig {
412    fn default() -> Self {
413        Self {
414            max_cycle_length: 5,
415            min_elimination_amount: 0.0,
416            include_disputed: false,
417        }
418    }
419}
420
421/// Netting opportunity.
422#[derive(Debug, Clone)]
423pub struct NettingOpportunity {
424    /// Entities involved.
425    pub entities: Vec<String>,
426    /// Gross amount.
427    pub gross_amount: f64,
428    /// Net amount.
429    pub net_amount: f64,
430    /// Reduction achieved.
431    pub reduction: f64,
432}
433
434#[cfg(test)]
435mod tests {
436    use super::*;
437
438    fn create_test_transactions() -> Vec<IntercompanyTransaction> {
439        vec![
440            IntercompanyTransaction {
441                id: "T1".to_string(),
442                from_entity: "CORP_A".to_string(),
443                to_entity: "CORP_B".to_string(),
444                amount: 1000.0,
445                currency: "USD".to_string(),
446                date: 1700000000,
447                transaction_type: IntercompanyType::Trade,
448                status: IntercompanyStatus::Confirmed,
449            },
450            IntercompanyTransaction {
451                id: "T2".to_string(),
452                from_entity: "CORP_B".to_string(),
453                to_entity: "CORP_C".to_string(),
454                amount: 500.0,
455                currency: "USD".to_string(),
456                date: 1700000000,
457                transaction_type: IntercompanyType::Trade,
458                status: IntercompanyStatus::Confirmed,
459            },
460            IntercompanyTransaction {
461                id: "T3".to_string(),
462                from_entity: "CORP_B".to_string(),
463                to_entity: "CORP_A".to_string(),
464                amount: 300.0,
465                currency: "USD".to_string(),
466                date: 1700000000,
467                transaction_type: IntercompanyType::ManagementFee,
468                status: IntercompanyStatus::Confirmed,
469            },
470        ]
471    }
472
473    #[test]
474    fn test_network_metadata() {
475        let kernel = NetworkAnalysis::new();
476        assert_eq!(kernel.metadata().id, "accounting/network-analysis");
477        assert_eq!(kernel.metadata().domain, Domain::Accounting);
478    }
479
480    #[test]
481    fn test_entity_balances() {
482        let transactions = create_test_transactions();
483        let config = NetworkConfig::default();
484
485        let result = NetworkAnalysis::analyze(&transactions, &config);
486
487        let corp_a = result.entity_balances.get("CORP_A").unwrap();
488        assert_eq!(corp_a.total_receivables, 1000.0);
489        assert_eq!(corp_a.total_payables, 300.0);
490        assert_eq!(corp_a.net_position, 700.0);
491
492        let corp_b = result.entity_balances.get("CORP_B").unwrap();
493        assert_eq!(corp_b.total_receivables, 800.0); // 500 + 300
494        assert_eq!(corp_b.total_payables, 1000.0);
495    }
496
497    #[test]
498    fn test_relationships() {
499        let transactions = create_test_transactions();
500        let config = NetworkConfig::default();
501
502        let result = NetworkAnalysis::analyze(&transactions, &config);
503
504        assert!(result.relationships.len() >= 2);
505
506        // Find A-B relationship
507        let ab_rel = result.relationships.iter().find(|r| {
508            (r.from_entity == "CORP_A" && r.to_entity == "CORP_B")
509                || (r.from_entity == "CORP_B" && r.to_entity == "CORP_A")
510        });
511        assert!(ab_rel.is_some());
512
513        let rel = ab_rel.unwrap();
514        assert_eq!(rel.total_volume, 1300.0); // 1000 + 300
515        assert_eq!(rel.transaction_count, 2);
516    }
517
518    #[test]
519    fn test_circular_reference() {
520        let transactions = vec![
521            IntercompanyTransaction {
522                id: "T1".to_string(),
523                from_entity: "A".to_string(),
524                to_entity: "B".to_string(),
525                amount: 100.0,
526                currency: "USD".to_string(),
527                date: 1700000000,
528                transaction_type: IntercompanyType::Trade,
529                status: IntercompanyStatus::Confirmed,
530            },
531            IntercompanyTransaction {
532                id: "T2".to_string(),
533                from_entity: "B".to_string(),
534                to_entity: "C".to_string(),
535                amount: 100.0,
536                currency: "USD".to_string(),
537                date: 1700000000,
538                transaction_type: IntercompanyType::Trade,
539                status: IntercompanyStatus::Confirmed,
540            },
541            IntercompanyTransaction {
542                id: "T3".to_string(),
543                from_entity: "C".to_string(),
544                to_entity: "A".to_string(),
545                amount: 100.0,
546                currency: "USD".to_string(),
547                date: 1700000000,
548                transaction_type: IntercompanyType::Trade,
549                status: IntercompanyStatus::Confirmed,
550            },
551        ];
552
553        let config = NetworkConfig::default();
554        let result = NetworkAnalysis::analyze(&transactions, &config);
555
556        assert!(!result.circular_refs.is_empty());
557        assert_eq!(result.circular_refs[0].entities.len(), 3);
558    }
559
560    #[test]
561    fn test_elimination_entries() {
562        let transactions = create_test_transactions();
563        let config = NetworkConfig::default();
564
565        let result = NetworkAnalysis::analyze(&transactions, &config);
566
567        assert!(!result.elimination_entries.is_empty());
568
569        // Check trade elimination
570        let trade_elim = result
571            .elimination_entries
572            .iter()
573            .find(|e| e.from_entity == "CORP_A" && e.to_entity == "CORP_B");
574        assert!(trade_elim.is_some());
575    }
576
577    #[test]
578    fn test_netting_opportunities() {
579        let transactions = create_test_transactions();
580
581        let netting = NetworkAnalysis::calculate_netting(&transactions);
582
583        // Should find A-B bilateral netting opportunity
584        let ab_netting = netting.iter().find(|n| {
585            n.entities.contains(&"CORP_A".to_string()) && n.entities.contains(&"CORP_B".to_string())
586        });
587        assert!(ab_netting.is_some());
588
589        let opportunity = ab_netting.unwrap();
590        assert_eq!(opportunity.gross_amount, 1300.0);
591        assert_eq!(opportunity.net_amount, 700.0);
592        assert_eq!(opportunity.reduction, 600.0); // 300 * 2
593    }
594
595    #[test]
596    fn test_network_stats() {
597        let transactions = create_test_transactions();
598        let config = NetworkConfig::default();
599
600        let result = NetworkAnalysis::analyze(&transactions, &config);
601
602        assert_eq!(result.stats.total_transactions, 3);
603        assert_eq!(result.stats.total_volume, 1800.0);
604        assert_eq!(result.stats.total_entities, 3);
605    }
606
607    #[test]
608    fn test_excluded_eliminated() {
609        let mut transactions = create_test_transactions();
610        transactions[0].status = IntercompanyStatus::Eliminated;
611
612        let config = NetworkConfig::default();
613        let result = NetworkAnalysis::analyze(&transactions, &config);
614
615        // Eliminated transaction should not affect balances
616        let corp_a = result.entity_balances.get("CORP_A").unwrap();
617        assert_eq!(corp_a.total_receivables, 0.0);
618        assert_eq!(corp_a.total_payables, 300.0);
619    }
620
621    #[test]
622    fn test_min_elimination_amount() {
623        let transactions = create_test_transactions();
624        let config = NetworkConfig {
625            min_elimination_amount: 400.0, // Should exclude 300 transaction
626            ..Default::default()
627        };
628
629        let result = NetworkAnalysis::analyze(&transactions, &config);
630
631        // Should only have eliminations for amounts >= 400
632        assert!(result.elimination_entries.iter().all(|e| e.amount >= 400.0));
633    }
634}