1use 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#[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 #[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 pub fn analyze(
47 transactions: &[IntercompanyTransaction],
48 config: &NetworkConfig,
49 ) -> NetworkAnalysisResult {
50 let entity_balances = Self::calculate_entity_balances(transactions);
52
53 let relationships = Self::calculate_relationships(transactions);
55
56 let circular_refs = Self::find_circular_references(transactions, config);
58
59 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 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 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 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 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 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 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 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 fn find_circular_references(
196 transactions: &[IntercompanyTransaction],
197 config: &NetworkConfig,
198 ) -> Vec<CircularReference> {
199 let mut circular_refs = Vec::new();
200
201 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 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 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 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 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, });
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 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 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 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 pub fn calculate_netting(transactions: &[IntercompanyTransaction]) -> Vec<NettingOpportunity> {
352 let mut opportunities = Vec::new();
353
354 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#[derive(Debug, Clone)]
402pub struct NetworkConfig {
403 pub max_cycle_length: usize,
405 pub min_elimination_amount: f64,
407 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#[derive(Debug, Clone)]
423pub struct NettingOpportunity {
424 pub entities: Vec<String>,
426 pub gross_amount: f64,
428 pub net_amount: f64,
430 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); 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 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); 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 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 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); }
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 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, ..Default::default()
627 };
628
629 let result = NetworkAnalysis::analyze(&transactions, &config);
630
631 assert!(result.elimination_entries.iter().all(|e| e.amount >= 400.0));
633 }
634}