1use crate::types::{JournalEntry, JournalLine};
31use async_trait::async_trait;
32use rustkernel_core::{
33 domain::Domain,
34 error::Result,
35 kernel::KernelMetadata,
36 traits::{BatchKernel, GpuKernel},
37};
38use serde::{Deserialize, Serialize};
39use std::collections::{HashMap, HashSet};
40use std::time::Instant;
41
42#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
50pub struct FixedPoint128 {
51 pub value: i128,
53}
54
55impl FixedPoint128 {
56 pub const SCALE: i128 = 1_000_000_000_000_000_000;
58
59 pub const ZERO: Self = Self { value: 0 };
61
62 #[inline]
64 pub fn from_f64(f: f64) -> Self {
65 Self {
66 value: (f * Self::SCALE as f64) as i128,
67 }
68 }
69
70 #[inline]
72 pub fn to_f64(self) -> f64 {
73 self.value as f64 / Self::SCALE as f64
74 }
75
76 #[inline]
78 pub fn from_i64(i: i64) -> Self {
79 Self {
80 value: i as i128 * Self::SCALE,
81 }
82 }
83
84 #[inline]
86 pub fn abs(self) -> Self {
87 Self {
88 value: self.value.abs(),
89 }
90 }
91
92 #[inline]
94 pub fn approx_eq(self, other: Self, tolerance: Self) -> bool {
95 (self.value - other.value).abs() <= tolerance.value
96 }
97
98 #[inline]
100 pub fn is_zero(self, tolerance: Self) -> bool {
101 self.value.abs() <= tolerance.value
102 }
103}
104
105impl std::ops::Add for FixedPoint128 {
106 type Output = Self;
107
108 #[inline]
109 fn add(self, rhs: Self) -> Self {
110 Self {
111 value: self.value + rhs.value,
112 }
113 }
114}
115
116impl std::ops::Sub for FixedPoint128 {
117 type Output = Self;
118
119 #[inline]
120 fn sub(self, rhs: Self) -> Self {
121 Self {
122 value: self.value - rhs.value,
123 }
124 }
125}
126
127impl std::ops::Neg for FixedPoint128 {
128 type Output = Self;
129
130 #[inline]
131 fn neg(self) -> Self {
132 Self { value: -self.value }
133 }
134}
135
136impl std::ops::AddAssign for FixedPoint128 {
137 #[inline]
138 fn add_assign(&mut self, rhs: Self) {
139 self.value += rhs.value;
140 }
141}
142
143impl std::ops::SubAssign for FixedPoint128 {
144 #[inline]
145 fn sub_assign(&mut self, rhs: Self) {
146 self.value -= rhs.value;
147 }
148}
149
150impl PartialOrd for FixedPoint128 {
151 fn partial_cmp(&self, other: &Self) -> Option<std::cmp::Ordering> {
152 Some(self.cmp(other))
153 }
154}
155
156impl Ord for FixedPoint128 {
157 fn cmp(&self, other: &Self) -> std::cmp::Ordering {
158 self.value.cmp(&other.value)
159 }
160}
161
162impl Default for FixedPoint128 {
163 fn default() -> Self {
164 Self::ZERO
165 }
166}
167
168#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
174pub enum SolvingMethod {
175 MethodA,
177 MethodB,
179 MethodC,
181 MethodD,
183 MethodE,
185 Unsolvable,
187}
188
189impl SolvingMethod {
190 #[inline]
192 pub fn confidence(&self) -> f64 {
193 match self {
194 SolvingMethod::MethodA => 1.00,
195 SolvingMethod::MethodB => 0.95,
196 SolvingMethod::MethodC => 0.85,
197 SolvingMethod::MethodD => 0.70,
198 SolvingMethod::MethodE => 0.50,
199 SolvingMethod::Unsolvable => 0.00,
200 }
201 }
202
203 pub fn name(&self) -> &'static str {
205 match self {
206 SolvingMethod::MethodA => "A (1-to-1)",
207 SolvingMethod::MethodB => "B (n-to-n)",
208 SolvingMethod::MethodC => "C (n-to-m)",
209 SolvingMethod::MethodD => "D (aggregation)",
210 SolvingMethod::MethodE => "E (decomposition)",
211 SolvingMethod::Unsolvable => "Unsolvable",
212 }
213 }
214}
215
216#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
222pub enum AccountClass {
223 Asset,
225 Liability,
227 Equity,
229 Revenue,
231 COGS,
233 Expense,
235 OtherIncomeExpense,
237 Tax,
239 Intercompany,
241 Suspense,
243 Unknown,
245}
246
247impl AccountClass {
248 pub fn from_account_code(code: &str) -> Self {
250 if let Some(first_char) = code.chars().next() {
252 if first_char.is_ascii_digit() {
253 return Self::from_numeric_prefix(code);
254 }
255 }
256
257 Self::from_keywords(code)
259 }
260
261 fn from_numeric_prefix(code: &str) -> Self {
263 let prefix: u32 = code
264 .chars()
265 .take_while(|c| c.is_ascii_digit())
266 .collect::<String>()
267 .parse()
268 .unwrap_or(0);
269
270 match prefix {
271 1000..=1999 => AccountClass::Asset,
272 2000..=2999 => AccountClass::Liability,
273 3000..=3999 => AccountClass::Equity,
274 4000..=4999 => AccountClass::Revenue,
275 5000..=5999 => AccountClass::COGS,
276 6000..=7999 => AccountClass::Expense,
277 8000..=8999 => AccountClass::OtherIncomeExpense,
278 9000..=9999 => AccountClass::Suspense,
279 _ => {
280 let first_digit = prefix / 1000;
282 match first_digit {
283 1 => AccountClass::Asset,
284 2 => AccountClass::Liability,
285 3 => AccountClass::Equity,
286 4 => AccountClass::Revenue,
287 5 => AccountClass::COGS,
288 6 | 7 => AccountClass::Expense,
289 8 => AccountClass::OtherIncomeExpense,
290 9 => AccountClass::Suspense,
291 _ => AccountClass::Unknown,
292 }
293 }
294 }
295 }
296
297 fn from_keywords(code: &str) -> Self {
299 let upper = code.to_uppercase();
300
301 if upper.contains("VAT")
303 || upper.contains("TAX")
304 || upper.contains("GST")
305 || upper.contains("HST")
306 || upper.contains("WITHHOLD")
307 {
308 return AccountClass::Tax;
309 }
310
311 if upper.contains("IC_")
313 || upper.contains("INTERCO")
314 || upper.contains("INTERCOMPANY")
315 || upper.contains("DUE_FROM")
316 || upper.contains("DUE_TO")
317 {
318 return AccountClass::Intercompany;
319 }
320
321 if upper.contains("SUSPENSE") || upper.contains("CLEARING") || upper.contains("TRANSIT") {
323 return AccountClass::Suspense;
324 }
325
326 if upper.contains("CASH")
328 || upper.contains("BANK")
329 || upper.contains("AR")
330 || upper.contains("RECEIVABLE")
331 || upper.contains("INVENTORY")
332 || upper.contains("PREPAID")
333 || upper.contains("FIXED_ASSET")
334 || upper.contains("EQUIPMENT")
335 {
336 return AccountClass::Asset;
337 }
338
339 if upper.contains("AP")
341 || upper.contains("PAYABLE")
342 || upper.contains("ACCRUED")
343 || upper.contains("LOAN")
344 || upper.contains("DEBT")
345 || upper.contains("DEFERRED")
346 {
347 return AccountClass::Liability;
348 }
349
350 if upper.contains("EQUITY")
352 || upper.contains("CAPITAL")
353 || upper.contains("RETAINED")
354 || upper.contains("RESERVE")
355 {
356 return AccountClass::Equity;
357 }
358
359 if upper.contains("REVENUE")
361 || upper.contains("SALES")
362 || upper.contains("INCOME")
363 || upper.contains("SERVICE_REV")
364 {
365 return AccountClass::Revenue;
366 }
367
368 if upper.contains("COGS") || upper.contains("COST_OF") || upper.contains("PURCHASES") {
370 return AccountClass::COGS;
371 }
372
373 if upper.contains("EXPENSE")
375 || upper.contains("SALARY")
376 || upper.contains("RENT")
377 || upper.contains("UTILITIES")
378 || upper.contains("DEPRECIATION")
379 || upper.contains("AMORTIZATION")
380 {
381 return AccountClass::Expense;
382 }
383
384 AccountClass::Unknown
385 }
386
387 pub fn is_debit_normal(&self) -> bool {
389 matches!(
390 self,
391 AccountClass::Asset
392 | AccountClass::COGS
393 | AccountClass::Expense
394 | AccountClass::OtherIncomeExpense
395 )
396 }
397
398 pub fn is_credit_normal(&self) -> bool {
400 matches!(
401 self,
402 AccountClass::Liability | AccountClass::Equity | AccountClass::Revenue
403 )
404 }
405}
406
407#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)]
413pub struct VatRate {
414 pub rate: f64,
416 pub jurisdiction: VatJurisdiction,
418 pub rate_type: VatRateType,
420}
421
422#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
424pub enum VatJurisdiction {
425 EU,
427 UK,
429 US,
431 CA,
433 AU,
435 DE,
437 FR,
439 Generic,
441}
442
443#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
445pub enum VatRateType {
446 Standard,
448 Reduced,
450 SuperReduced,
452 Zero,
454 Exempt,
456}
457
458#[derive(Debug, Clone)]
460pub struct VatDetector {
461 known_rates: Vec<VatRate>,
463 tolerance: f64,
465}
466
467impl Default for VatDetector {
468 fn default() -> Self {
469 Self::new()
470 }
471}
472
473impl VatDetector {
474 pub fn new() -> Self {
476 Self {
477 known_rates: vec![
478 VatRate {
480 rate: 0.20,
481 jurisdiction: VatJurisdiction::UK,
482 rate_type: VatRateType::Standard,
483 },
484 VatRate {
485 rate: 0.19,
486 jurisdiction: VatJurisdiction::DE,
487 rate_type: VatRateType::Standard,
488 },
489 VatRate {
490 rate: 0.20,
491 jurisdiction: VatJurisdiction::FR,
492 rate_type: VatRateType::Standard,
493 },
494 VatRate {
495 rate: 0.21,
496 jurisdiction: VatJurisdiction::EU,
497 rate_type: VatRateType::Standard,
498 },
499 VatRate {
500 rate: 0.23,
501 jurisdiction: VatJurisdiction::EU,
502 rate_type: VatRateType::Standard,
503 },
504 VatRate {
505 rate: 0.25,
506 jurisdiction: VatJurisdiction::EU,
507 rate_type: VatRateType::Standard,
508 },
509 VatRate {
511 rate: 0.10,
512 jurisdiction: VatJurisdiction::Generic,
513 rate_type: VatRateType::Reduced,
514 },
515 VatRate {
516 rate: 0.07,
517 jurisdiction: VatJurisdiction::DE,
518 rate_type: VatRateType::Reduced,
519 },
520 VatRate {
521 rate: 0.055,
522 jurisdiction: VatJurisdiction::FR,
523 rate_type: VatRateType::Reduced,
524 },
525 VatRate {
526 rate: 0.05,
527 jurisdiction: VatJurisdiction::UK,
528 rate_type: VatRateType::Reduced,
529 },
530 VatRate {
532 rate: 0.10,
533 jurisdiction: VatJurisdiction::AU,
534 rate_type: VatRateType::Standard,
535 },
536 VatRate {
537 rate: 0.05,
538 jurisdiction: VatJurisdiction::CA,
539 rate_type: VatRateType::Standard,
540 },
541 VatRate {
542 rate: 0.13,
543 jurisdiction: VatJurisdiction::CA,
544 rate_type: VatRateType::Standard,
545 }, VatRate {
547 rate: 0.15,
548 jurisdiction: VatJurisdiction::CA,
549 rate_type: VatRateType::Standard,
550 }, ],
552 tolerance: 0.001,
553 }
554 }
555
556 pub fn add_rate(&mut self, rate: VatRate) {
558 self.known_rates.push(rate);
559 }
560
561 pub fn detect_vat_split(
564 &self,
565 amounts: &[FixedPoint128],
566 ) -> Option<(FixedPoint128, FixedPoint128, VatRate)> {
567 if amounts.len() < 2 {
568 return None;
569 }
570
571 let mut sorted: Vec<_> = amounts.to_vec();
573 sorted.sort_by(|a, b| b.cmp(a));
574
575 for i in 0..sorted.len() {
577 for j in (i + 1)..sorted.len() {
578 let potential_gross = sorted[i];
579 let potential_net = sorted[j];
580
581 if potential_net.value == 0 {
582 continue;
583 }
584
585 let implied_rate = (potential_gross.value - potential_net.value) as f64
587 / potential_net.value as f64;
588
589 for vat_rate in &self.known_rates {
591 if (implied_rate - vat_rate.rate).abs() < self.tolerance {
592 let tax_amount = potential_gross - potential_net;
593 return Some((potential_net, tax_amount, *vat_rate));
594 }
595 }
596 }
597 }
598
599 None
600 }
601
602 pub fn is_tax_account(account_code: &str) -> bool {
604 let upper = account_code.to_uppercase();
605 upper.contains("VAT")
606 || upper.contains("TAX")
607 || upper.contains("GST")
608 || upper.contains("HST")
609 || upper.contains("WITHHOLD")
610 || upper.contains("OUTPUT_TAX")
611 || upper.contains("INPUT_TAX")
612 }
613
614 pub fn detect_vat_pattern(&self, lines: &[ClassifiedLine]) -> Option<VatPattern> {
617 let tax_lines: Vec<_> = lines
619 .iter()
620 .filter(|l| Self::is_tax_account(&l.account_code))
621 .collect();
622
623 if tax_lines.is_empty() {
624 return None;
625 }
626
627 let non_tax_lines: Vec<_> = lines
629 .iter()
630 .filter(|l| !Self::is_tax_account(&l.account_code))
631 .collect();
632
633 if non_tax_lines.is_empty() {
634 return None;
635 }
636
637 let tax_total: FixedPoint128 = tax_lines
639 .iter()
640 .map(|l| l.amount)
641 .fold(FixedPoint128::ZERO, |a, b| a + b);
642 let non_tax_debit: FixedPoint128 = non_tax_lines
643 .iter()
644 .filter(|l| l.is_debit)
645 .map(|l| l.amount)
646 .fold(FixedPoint128::ZERO, |a, b| a + b);
647 let non_tax_credit: FixedPoint128 = non_tax_lines
648 .iter()
649 .filter(|l| !l.is_debit)
650 .map(|l| l.amount)
651 .fold(FixedPoint128::ZERO, |a, b| a + b);
652
653 let is_output_vat = tax_lines.iter().any(|l| !l.is_debit);
655
656 let base_amount = if is_output_vat {
658 non_tax_credit
659 } else {
660 non_tax_debit
661 };
662
663 if base_amount.value == 0 {
664 return None;
665 }
666
667 let implied_rate = tax_total.value as f64 / base_amount.value as f64;
668
669 for vat_rate in &self.known_rates {
670 if (implied_rate - vat_rate.rate).abs() < self.tolerance {
671 return Some(VatPattern {
672 is_output_vat,
673 net_amount: base_amount,
674 tax_amount: tax_total,
675 rate: *vat_rate,
676 tax_accounts: tax_lines.iter().map(|l| l.account_code.clone()).collect(),
677 });
678 }
679 }
680
681 Some(VatPattern {
683 is_output_vat,
684 net_amount: base_amount,
685 tax_amount: tax_total,
686 rate: VatRate {
687 rate: implied_rate,
688 jurisdiction: VatJurisdiction::Generic,
689 rate_type: VatRateType::Standard,
690 },
691 tax_accounts: tax_lines.iter().map(|l| l.account_code.clone()).collect(),
692 })
693 }
694}
695
696#[derive(Debug, Clone, Serialize, Deserialize)]
698pub struct VatPattern {
699 pub is_output_vat: bool,
701 pub net_amount: FixedPoint128,
703 pub tax_amount: FixedPoint128,
705 pub rate: VatRate,
707 pub tax_accounts: Vec<String>,
709}
710
711#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
717pub enum TransactionPattern {
718 SimpleSale,
720 SaleWithVat,
722 SimplePurchase,
724 PurchaseWithVat,
726 Payment,
728 Receipt,
730 Payroll,
732 Depreciation,
734 Accrual,
736 AccrualReversal,
738 Transfer,
740 Intercompany,
742 CostAllocation,
744 Adjustment,
746 Unknown,
748}
749
750impl TransactionPattern {
751 pub fn confidence_boost(&self) -> f64 {
753 match self {
754 TransactionPattern::SimpleSale => 0.10,
755 TransactionPattern::SaleWithVat => 0.15,
756 TransactionPattern::SimplePurchase => 0.10,
757 TransactionPattern::PurchaseWithVat => 0.15,
758 TransactionPattern::Payment => 0.10,
759 TransactionPattern::Receipt => 0.10,
760 TransactionPattern::Payroll => 0.12,
761 TransactionPattern::Depreciation => 0.15,
762 TransactionPattern::Accrual => 0.10,
763 TransactionPattern::AccrualReversal => 0.10,
764 TransactionPattern::Transfer => 0.08,
765 TransactionPattern::Intercompany => 0.05,
766 TransactionPattern::CostAllocation => 0.08,
767 TransactionPattern::Adjustment => 0.05,
768 TransactionPattern::Unknown => 0.00,
769 }
770 }
771}
772
773#[derive(Debug, Clone, Default)]
775pub struct PatternMatcher {
776 vat_detector: VatDetector,
777}
778
779impl PatternMatcher {
780 pub fn new() -> Self {
782 Self {
783 vat_detector: VatDetector::new(),
784 }
785 }
786
787 pub fn detect_pattern(
789 &self,
790 debits: &[ClassifiedLine],
791 credits: &[ClassifiedLine],
792 ) -> (TransactionPattern, Option<VatPattern>) {
793 let all_lines: Vec<_> = debits.iter().chain(credits.iter()).cloned().collect();
795 let vat_pattern = self.vat_detector.detect_vat_pattern(&all_lines);
796
797 let debit_classes: Vec<_> = debits
799 .iter()
800 .map(|l| AccountClass::from_account_code(&l.account_code))
801 .collect();
802 let credit_classes: Vec<_> = credits
803 .iter()
804 .map(|l| AccountClass::from_account_code(&l.account_code))
805 .collect();
806
807 let has_cross_entity = debits
809 .iter()
810 .any(|d| credits.iter().any(|c| d.entity_id != c.entity_id));
811
812 if has_cross_entity {
813 return (TransactionPattern::Intercompany, vat_pattern);
814 }
815
816 let pattern = if let Some(ref vat) = vat_pattern {
818 self.detect_vat_transaction_pattern(&debit_classes, &credit_classes, vat)
819 } else {
820 self.detect_non_vat_pattern(&debit_classes, &credit_classes)
821 };
822
823 (pattern, vat_pattern)
824 }
825
826 fn detect_vat_transaction_pattern(
827 &self,
828 debit_classes: &[AccountClass],
829 _credit_classes: &[AccountClass],
830 vat_pattern: &VatPattern,
831 ) -> TransactionPattern {
832 if vat_pattern.is_output_vat {
833 if debit_classes.contains(&AccountClass::Asset) {
835 TransactionPattern::SaleWithVat
836 } else {
837 TransactionPattern::Unknown
838 }
839 } else {
840 if debit_classes.contains(&AccountClass::Expense)
842 || debit_classes.contains(&AccountClass::Asset)
843 {
844 TransactionPattern::PurchaseWithVat
845 } else {
846 TransactionPattern::Unknown
847 }
848 }
849 }
850
851 fn detect_non_vat_pattern(
852 &self,
853 debit_classes: &[AccountClass],
854 credit_classes: &[AccountClass],
855 ) -> TransactionPattern {
856 let has_debit_asset = debit_classes.contains(&AccountClass::Asset);
857 let has_debit_expense = debit_classes.contains(&AccountClass::Expense);
858 let has_debit_liability = debit_classes.contains(&AccountClass::Liability);
859
860 let has_credit_asset = credit_classes.contains(&AccountClass::Asset);
861 let has_credit_revenue = credit_classes.contains(&AccountClass::Revenue);
862 let has_credit_liability = credit_classes.contains(&AccountClass::Liability);
863
864 if has_debit_asset && has_credit_revenue && credit_classes.len() == 1 {
866 return TransactionPattern::SimpleSale;
867 }
868
869 if has_debit_expense && (has_credit_asset || has_credit_liability) {
871 return TransactionPattern::SimplePurchase;
872 }
873
874 if has_debit_liability && has_credit_asset {
876 return TransactionPattern::Payment;
877 }
878
879 if has_debit_asset
881 && has_credit_asset
882 && debit_classes.len() == 1
883 && credit_classes.len() == 1
884 {
885 return TransactionPattern::Receipt;
886 }
887
888 if has_debit_asset && has_credit_asset {
890 return TransactionPattern::Transfer;
891 }
892
893 if has_debit_expense
895 && credit_classes
896 .iter()
897 .filter(|c| **c == AccountClass::Liability)
898 .count()
899 > 1
900 {
901 return TransactionPattern::Payroll;
902 }
903
904 if has_debit_expense && has_credit_liability && credit_classes.len() == 1 {
906 return TransactionPattern::Accrual;
907 }
908
909 TransactionPattern::Unknown
910 }
911}
912
913#[derive(Debug, Clone)]
915pub struct ClassifiedLine {
916 pub line_number: u32,
918 pub account_code: String,
920 pub amount: FixedPoint128,
922 pub is_debit: bool,
924 pub entity_id: String,
926 pub cost_center: Option<String>,
928}
929
930#[derive(Debug, Clone, Serialize, Deserialize)]
932pub struct AccountingFlow {
933 pub flow_id: u64,
935 pub entry_id: u64,
937 pub from_account: String,
939 pub to_account: String,
941 pub amount: FixedPoint128,
943 pub amount_f64: f64,
945 pub timestamp: u64,
947 pub method: SolvingMethod,
949 pub confidence: f64,
951 pub from_entity: String,
953 pub to_entity: String,
955 pub currency: String,
957 pub source_lines: Vec<u32>,
959 #[serde(default)]
962 pub from_account_class: Option<AccountClass>,
963 #[serde(default)]
965 pub to_account_class: Option<AccountClass>,
966 #[serde(default)]
968 pub pattern: Option<TransactionPattern>,
969 #[serde(default)]
971 pub is_tax_flow: bool,
972 #[serde(default)]
974 pub vat_rate: Option<f64>,
975 #[serde(default)]
977 pub is_intercompany: bool,
978 #[serde(default)]
980 pub confidence_factors: Vec<String>,
981}
982
983#[derive(Debug, Clone)]
985pub struct EntryNetworkResult {
986 pub entry_id: u64,
988 pub flows: Vec<AccountingFlow>,
990 pub method: SolvingMethod,
992 pub confidence: f64,
994 pub was_balanced: bool,
996 pub error: Option<String>,
998 pub pattern: TransactionPattern,
1000 pub vat_pattern: Option<VatPattern>,
1002}
1003
1004#[derive(Debug, Clone, Default)]
1006pub struct AccountingNetwork {
1007 pub flows: Vec<AccountingFlow>,
1009 pub accounts: HashSet<String>,
1011 pub account_index: HashMap<String, usize>,
1013 pub adjacency: HashMap<String, Vec<(String, usize)>>,
1015 pub stats: NetworkGenerationStats,
1017}
1018
1019impl AccountingNetwork {
1020 pub fn new() -> Self {
1022 Self::default()
1023 }
1024
1025 pub fn add_flow(&mut self, flow: AccountingFlow) {
1027 if !self.accounts.contains(&flow.from_account) {
1029 let idx = self.accounts.len();
1030 self.accounts.insert(flow.from_account.clone());
1031 self.account_index.insert(flow.from_account.clone(), idx);
1032 }
1033 if !self.accounts.contains(&flow.to_account) {
1034 let idx = self.accounts.len();
1035 self.accounts.insert(flow.to_account.clone());
1036 self.account_index.insert(flow.to_account.clone(), idx);
1037 }
1038
1039 let flow_idx = self.flows.len();
1041 self.adjacency
1042 .entry(flow.from_account.clone())
1043 .or_default()
1044 .push((flow.to_account.clone(), flow_idx));
1045
1046 self.flows.push(flow);
1048 }
1049
1050 pub fn outgoing_flows(&self, account: &str) -> Vec<&AccountingFlow> {
1052 self.adjacency
1053 .get(account)
1054 .map(|edges| edges.iter().map(|(_, idx)| &self.flows[*idx]).collect())
1055 .unwrap_or_default()
1056 }
1057
1058 pub fn incoming_flows(&self, account: &str) -> Vec<&AccountingFlow> {
1060 self.flows
1061 .iter()
1062 .filter(|f| f.to_account == account)
1063 .collect()
1064 }
1065
1066 pub fn query_temporal(&self, start_time: u64, end_time: u64) -> Vec<&AccountingFlow> {
1068 self.flows
1069 .iter()
1070 .filter(|f| f.timestamp >= start_time && f.timestamp <= end_time)
1071 .collect()
1072 }
1073
1074 pub fn total_volume(&self) -> f64 {
1076 self.flows.iter().map(|f| f.amount_f64).sum()
1077 }
1078
1079 pub fn weighted_confidence(&self) -> f64 {
1081 if self.flows.is_empty() {
1082 return 0.0;
1083 }
1084 let total_amount: f64 = self.flows.iter().map(|f| f.amount_f64).sum();
1085 if total_amount == 0.0 {
1086 return self.flows.iter().map(|f| f.confidence).sum::<f64>() / self.flows.len() as f64;
1087 }
1088 self.flows
1089 .iter()
1090 .map(|f| f.confidence * f.amount_f64)
1091 .sum::<f64>()
1092 / total_amount
1093 }
1094}
1095
1096#[derive(Debug, Clone, Default, Serialize, Deserialize)]
1098pub struct NetworkGenerationStats {
1099 pub total_entries: usize,
1101 pub method_a_count: usize,
1103 pub method_b_count: usize,
1105 pub method_c_count: usize,
1107 pub method_d_count: usize,
1109 pub method_e_count: usize,
1111 pub unsolvable_count: usize,
1113 pub total_flows: usize,
1115 pub total_volume: f64,
1117 pub weighted_confidence: f64,
1119 pub processing_time_us: u64,
1121 pub balance_errors: usize,
1123 pub vat_entries_count: usize,
1126 pub total_vat_amount: f64,
1128 pub sales_pattern_count: usize,
1130 pub purchase_pattern_count: usize,
1132 pub payment_pattern_count: usize,
1134 pub payroll_pattern_count: usize,
1136 pub intercompany_count: usize,
1138 pub avg_confidence_boost: f64,
1140}
1141
1142#[derive(Debug, Clone, Serialize, Deserialize)]
1148pub struct NetworkGenerationConfig {
1149 pub amount_tolerance: f64,
1151 pub max_lines_method_b: usize,
1153 pub max_lines_method_c: usize,
1155 pub max_partition_depth: usize,
1157 pub enable_aggregation: bool,
1159 pub enable_decomposition: bool,
1161 pub suspense_account: String,
1163 pub strict_balance: bool,
1165 pub enable_pattern_matching: bool,
1168 pub enable_vat_detection: bool,
1170 pub apply_confidence_boost: bool,
1172 pub annotate_account_classes: bool,
1174 #[serde(default)]
1176 pub custom_vat_rates: Vec<f64>,
1177}
1178
1179impl Default for NetworkGenerationConfig {
1180 fn default() -> Self {
1181 Self {
1182 amount_tolerance: 0.01,
1183 max_lines_method_b: 10,
1184 max_lines_method_c: 20,
1185 max_partition_depth: 1000,
1186 enable_aggregation: true,
1187 enable_decomposition: true,
1188 suspense_account: "SUSPENSE".to_string(),
1189 strict_balance: false,
1190 enable_pattern_matching: true,
1192 enable_vat_detection: true,
1193 apply_confidence_boost: true,
1194 annotate_account_classes: true,
1195 custom_vat_rates: Vec::new(),
1196 }
1197 }
1198}
1199
1200#[derive(Debug, Clone)]
1215pub struct NetworkGeneration {
1216 metadata: KernelMetadata,
1217 config: NetworkGenerationConfig,
1218 pattern_matcher: PatternMatcher,
1219}
1220
1221impl Default for NetworkGeneration {
1222 fn default() -> Self {
1223 Self::new()
1224 }
1225}
1226
1227impl NetworkGeneration {
1228 #[must_use]
1230 pub fn new() -> Self {
1231 Self::with_config(NetworkGenerationConfig::default())
1232 }
1233
1234 #[must_use]
1236 pub fn with_config(config: NetworkGenerationConfig) -> Self {
1237 let mut pattern_matcher = PatternMatcher::new();
1238
1239 for rate in &config.custom_vat_rates {
1241 pattern_matcher.vat_detector.add_rate(VatRate {
1242 rate: *rate,
1243 jurisdiction: VatJurisdiction::Generic,
1244 rate_type: VatRateType::Standard,
1245 });
1246 }
1247
1248 Self {
1249 metadata: KernelMetadata::batch("accounting/network-generation", Domain::Accounting)
1250 .with_description("Journal entry to accounting network transformation")
1251 .with_throughput(50_000)
1252 .with_latency_us(50.0)
1253 .with_gpu_native(true),
1254 config,
1255 pattern_matcher,
1256 }
1257 }
1258
1259 pub fn generate(&self, entries: &[JournalEntry]) -> AccountingNetwork {
1261 let start = Instant::now();
1262 let mut network = AccountingNetwork::new();
1263 let mut flow_id = 0u64;
1264 let mut total_confidence_boost = 0.0f64;
1265 let mut boosted_entries = 0usize;
1266
1267 let mut stats = NetworkGenerationStats {
1268 total_entries: entries.len(),
1269 ..Default::default()
1270 };
1271
1272 for entry in entries {
1273 let result = self.process_entry(entry, &mut flow_id);
1274
1275 match result.method {
1277 SolvingMethod::MethodA => stats.method_a_count += 1,
1278 SolvingMethod::MethodB => stats.method_b_count += 1,
1279 SolvingMethod::MethodC => stats.method_c_count += 1,
1280 SolvingMethod::MethodD => stats.method_d_count += 1,
1281 SolvingMethod::MethodE => stats.method_e_count += 1,
1282 SolvingMethod::Unsolvable => stats.unsolvable_count += 1,
1283 }
1284
1285 match result.pattern {
1287 TransactionPattern::SimpleSale | TransactionPattern::SaleWithVat => {
1288 stats.sales_pattern_count += 1;
1289 }
1290 TransactionPattern::SimplePurchase | TransactionPattern::PurchaseWithVat => {
1291 stats.purchase_pattern_count += 1;
1292 }
1293 TransactionPattern::Payment | TransactionPattern::Receipt => {
1294 stats.payment_pattern_count += 1;
1295 }
1296 TransactionPattern::Payroll => {
1297 stats.payroll_pattern_count += 1;
1298 }
1299 TransactionPattern::Intercompany => {
1300 stats.intercompany_count += 1;
1301 }
1302 _ => {}
1303 }
1304
1305 if let Some(ref vat) = result.vat_pattern {
1307 stats.vat_entries_count += 1;
1308 stats.total_vat_amount += vat.tax_amount.to_f64();
1309 }
1310
1311 let boost = result.pattern.confidence_boost();
1313 if boost > 0.0 {
1314 total_confidence_boost += boost;
1315 boosted_entries += 1;
1316 }
1317
1318 if !result.was_balanced {
1319 stats.balance_errors += 1;
1320 }
1321
1322 for flow in result.flows {
1324 stats.total_volume += flow.amount_f64;
1325 network.add_flow(flow);
1326 }
1327 }
1328
1329 stats.total_flows = network.flows.len();
1330 stats.weighted_confidence = network.weighted_confidence();
1331 stats.processing_time_us = start.elapsed().as_micros() as u64;
1332 stats.avg_confidence_boost = if boosted_entries > 0 {
1333 total_confidence_boost / boosted_entries as f64
1334 } else {
1335 0.0
1336 };
1337 network.stats = stats;
1338
1339 network
1340 }
1341
1342 fn process_entry(&self, entry: &JournalEntry, flow_id: &mut u64) -> EntryNetworkResult {
1344 let (debits, credits, balance_diff) = self.classify_lines(&entry.lines);
1346
1347 let tolerance = FixedPoint128::from_f64(self.config.amount_tolerance);
1349 let was_balanced = balance_diff.is_zero(tolerance);
1350
1351 if !was_balanced && self.config.strict_balance {
1352 return EntryNetworkResult {
1353 entry_id: entry.id,
1354 flows: Vec::new(),
1355 method: SolvingMethod::Unsolvable,
1356 confidence: 0.0,
1357 was_balanced: false,
1358 error: Some(format!("Entry unbalanced: diff={}", balance_diff.to_f64())),
1359 pattern: TransactionPattern::Unknown,
1360 vat_pattern: None,
1361 };
1362 }
1363
1364 let (pattern, vat_pattern) = if self.config.enable_pattern_matching {
1366 self.pattern_matcher.detect_pattern(&debits, &credits)
1367 } else {
1368 (TransactionPattern::Unknown, None)
1369 };
1370
1371 let debit_count = debits.len();
1372 let credit_count = credits.len();
1373
1374 let (mut flows, method) = if debit_count == 1 && credit_count == 1 {
1376 (
1378 self.solve_method_a(entry, &debits, &credits, flow_id),
1379 SolvingMethod::MethodA,
1380 )
1381 } else if debit_count == credit_count && debit_count <= self.config.max_lines_method_b {
1382 (
1384 self.solve_method_b(entry, &debits, &credits, flow_id),
1385 SolvingMethod::MethodB,
1386 )
1387 } else if (debit_count + credit_count) <= self.config.max_lines_method_c {
1388 (
1390 self.solve_method_c(entry, &debits, &credits, flow_id),
1391 SolvingMethod::MethodC,
1392 )
1393 } else if self.config.enable_aggregation {
1394 (
1396 self.solve_method_d(entry, &debits, &credits, flow_id),
1397 SolvingMethod::MethodD,
1398 )
1399 } else if self.config.enable_decomposition {
1400 (
1402 self.solve_method_e(entry, &debits, &credits, flow_id),
1403 SolvingMethod::MethodE,
1404 )
1405 } else {
1406 (
1408 self.create_suspense_flows(entry, &debits, &credits, flow_id),
1409 SolvingMethod::Unsolvable,
1410 )
1411 };
1412
1413 let base_confidence = method.confidence();
1415 let pattern_boost = if self.config.apply_confidence_boost {
1416 pattern.confidence_boost()
1417 } else {
1418 0.0
1419 };
1420 let final_confidence = (base_confidence + pattern_boost).min(1.0);
1421
1422 self.enhance_flows(&mut flows, pattern, vat_pattern.as_ref(), final_confidence);
1424
1425 EntryNetworkResult {
1426 entry_id: entry.id,
1427 flows,
1428 method,
1429 confidence: final_confidence,
1430 was_balanced,
1431 error: None,
1432 pattern,
1433 vat_pattern,
1434 }
1435 }
1436
1437 #[allow(clippy::too_many_arguments)]
1439 fn create_flow(
1440 flow_id: u64,
1441 entry_id: u64,
1442 from_account: String,
1443 to_account: String,
1444 amount: FixedPoint128,
1445 timestamp: u64,
1446 method: SolvingMethod,
1447 from_entity: String,
1448 to_entity: String,
1449 currency: String,
1450 source_lines: Vec<u32>,
1451 ) -> AccountingFlow {
1452 AccountingFlow {
1453 flow_id,
1454 entry_id,
1455 from_account,
1456 to_account,
1457 amount,
1458 amount_f64: amount.to_f64(),
1459 timestamp,
1460 method,
1461 confidence: method.confidence(),
1462 from_entity,
1463 to_entity,
1464 currency,
1465 source_lines,
1466 from_account_class: None,
1468 to_account_class: None,
1469 pattern: None,
1470 is_tax_flow: false,
1471 vat_rate: None,
1472 is_intercompany: false,
1473 confidence_factors: Vec::new(),
1474 }
1475 }
1476
1477 fn enhance_flows(
1479 &self,
1480 flows: &mut [AccountingFlow],
1481 pattern: TransactionPattern,
1482 vat_pattern: Option<&VatPattern>,
1483 confidence: f64,
1484 ) {
1485 let tax_accounts: HashSet<String> = vat_pattern
1486 .map(|v| v.tax_accounts.iter().cloned().collect())
1487 .unwrap_or_default();
1488 let vat_rate = vat_pattern.map(|v| v.rate.rate);
1489
1490 for flow in flows {
1491 flow.confidence = confidence;
1493
1494 if self.config.enable_pattern_matching {
1496 flow.pattern = Some(pattern);
1497 }
1498
1499 if self.config.annotate_account_classes {
1501 flow.from_account_class = Some(AccountClass::from_account_code(&flow.from_account));
1502 flow.to_account_class = Some(AccountClass::from_account_code(&flow.to_account));
1503 }
1504
1505 if self.config.enable_vat_detection {
1507 flow.is_tax_flow = tax_accounts.contains(&flow.from_account)
1508 || tax_accounts.contains(&flow.to_account);
1509 if flow.is_tax_flow {
1510 flow.vat_rate = vat_rate;
1511 }
1512 }
1513
1514 flow.is_intercompany = flow.from_entity != flow.to_entity;
1516
1517 if pattern != TransactionPattern::Unknown {
1519 flow.confidence_factors
1520 .push(format!("pattern:{:?}", pattern));
1521 }
1522 if flow.is_tax_flow {
1523 flow.confidence_factors.push("vat_detected".to_string());
1524 }
1525 if flow.is_intercompany {
1526 flow.confidence_factors.push("intercompany".to_string());
1527 }
1528 }
1529 }
1530
1531 fn classify_lines(
1533 &self,
1534 lines: &[JournalLine],
1535 ) -> (Vec<ClassifiedLine>, Vec<ClassifiedLine>, FixedPoint128) {
1536 let mut debits = Vec::new();
1537 let mut credits = Vec::new();
1538 let mut total_debit = FixedPoint128::ZERO;
1539 let mut total_credit = FixedPoint128::ZERO;
1540
1541 for line in lines {
1542 if line.debit > 0.0 {
1543 let amount = FixedPoint128::from_f64(line.debit);
1544 total_debit += amount;
1545 debits.push(ClassifiedLine {
1546 line_number: line.line_number,
1547 account_code: line.account_code.clone(),
1548 amount,
1549 is_debit: true,
1550 entity_id: line.entity_id.clone(),
1551 cost_center: line.cost_center.clone(),
1552 });
1553 }
1554 if line.credit > 0.0 {
1555 let amount = FixedPoint128::from_f64(line.credit);
1556 total_credit += amount;
1557 credits.push(ClassifiedLine {
1558 line_number: line.line_number,
1559 account_code: line.account_code.clone(),
1560 amount,
1561 is_debit: false,
1562 entity_id: line.entity_id.clone(),
1563 cost_center: line.cost_center.clone(),
1564 });
1565 }
1566 }
1567
1568 let balance_diff = total_debit - total_credit;
1569 (debits, credits, balance_diff)
1570 }
1571
1572 fn solve_method_a(
1579 &self,
1580 entry: &JournalEntry,
1581 debits: &[ClassifiedLine],
1582 credits: &[ClassifiedLine],
1583 flow_id: &mut u64,
1584 ) -> Vec<AccountingFlow> {
1585 if debits.len() != 1 || credits.len() != 1 {
1586 return Vec::new();
1587 }
1588
1589 let debit = &debits[0];
1590 let credit = &credits[0];
1591 let amount = debit.amount.min(credit.amount);
1592 let currency = entry
1593 .lines
1594 .first()
1595 .map(|l| l.currency.clone())
1596 .unwrap_or_else(|| "USD".to_string());
1597
1598 let flow = Self::create_flow(
1599 *flow_id,
1600 entry.id,
1601 debit.account_code.clone(),
1602 credit.account_code.clone(),
1603 amount,
1604 entry.posting_date,
1605 SolvingMethod::MethodA,
1606 debit.entity_id.clone(),
1607 credit.entity_id.clone(),
1608 currency,
1609 vec![debit.line_number, credit.line_number],
1610 );
1611
1612 *flow_id += 1;
1613 vec![flow]
1614 }
1615
1616 fn solve_method_b(
1623 &self,
1624 entry: &JournalEntry,
1625 debits: &[ClassifiedLine],
1626 credits: &[ClassifiedLine],
1627 flow_id: &mut u64,
1628 ) -> Vec<AccountingFlow> {
1629 if debits.len() != credits.len() {
1630 return self.solve_method_c(entry, debits, credits, flow_id);
1631 }
1632
1633 let currency = entry
1634 .lines
1635 .first()
1636 .map(|l| l.currency.clone())
1637 .unwrap_or_else(|| "USD".to_string());
1638 let tolerance = FixedPoint128::from_f64(self.config.amount_tolerance);
1639
1640 let mut flows = Vec::new();
1642 let mut matched_credits: HashSet<usize> = HashSet::new();
1643 let mut matched_debits: HashSet<usize> = HashSet::new();
1644
1645 for (di, debit) in debits.iter().enumerate() {
1647 for (ci, credit) in credits.iter().enumerate() {
1648 if matched_credits.contains(&ci) {
1649 continue;
1650 }
1651 if debit.amount.approx_eq(credit.amount, tolerance) {
1652 flows.push(Self::create_flow(
1653 *flow_id,
1654 entry.id,
1655 debit.account_code.clone(),
1656 credit.account_code.clone(),
1657 debit.amount,
1658 entry.posting_date,
1659 SolvingMethod::MethodB,
1660 debit.entity_id.clone(),
1661 credit.entity_id.clone(),
1662 currency.clone(),
1663 vec![debit.line_number, credit.line_number],
1664 ));
1665 *flow_id += 1;
1666 matched_credits.insert(ci);
1667 matched_debits.insert(di);
1668 break;
1669 }
1670 }
1671 }
1672
1673 let unmatched_debits: Vec<_> = debits
1675 .iter()
1676 .enumerate()
1677 .filter(|(i, _)| !matched_debits.contains(i))
1678 .map(|(_, d)| d)
1679 .collect();
1680 let unmatched_credits: Vec<_> = credits
1681 .iter()
1682 .enumerate()
1683 .filter(|(i, _)| !matched_credits.contains(i))
1684 .map(|(_, c)| c)
1685 .collect();
1686
1687 for (debit, credit) in unmatched_debits.iter().zip(unmatched_credits.iter()) {
1688 let amount = debit.amount.min(credit.amount);
1689 flows.push(Self::create_flow(
1690 *flow_id,
1691 entry.id,
1692 debit.account_code.clone(),
1693 credit.account_code.clone(),
1694 amount,
1695 entry.posting_date,
1696 SolvingMethod::MethodB,
1697 debit.entity_id.clone(),
1698 credit.entity_id.clone(),
1699 currency.clone(),
1700 vec![debit.line_number, credit.line_number],
1701 ));
1702 *flow_id += 1;
1703 }
1704
1705 flows
1706 }
1707
1708 fn solve_method_c(
1716 &self,
1717 entry: &JournalEntry,
1718 debits: &[ClassifiedLine],
1719 credits: &[ClassifiedLine],
1720 flow_id: &mut u64,
1721 ) -> Vec<AccountingFlow> {
1722 let currency = entry
1723 .lines
1724 .first()
1725 .map(|l| l.currency.clone())
1726 .unwrap_or_else(|| "USD".to_string());
1727 let tolerance = FixedPoint128::from_f64(self.config.amount_tolerance);
1728
1729 let mut flows = Vec::new();
1731 let mut remaining_credits: Vec<(usize, ClassifiedLine)> =
1732 credits.iter().cloned().enumerate().collect();
1733
1734 for debit in debits {
1735 if let Some(matching_subset) =
1737 self.find_partition_subset(&remaining_credits, debit.amount, tolerance)
1738 {
1739 for (ci, credit) in &matching_subset {
1741 flows.push(Self::create_flow(
1742 *flow_id,
1743 entry.id,
1744 debit.account_code.clone(),
1745 credit.account_code.clone(),
1746 credit.amount,
1747 entry.posting_date,
1748 SolvingMethod::MethodC,
1749 debit.entity_id.clone(),
1750 credit.entity_id.clone(),
1751 currency.clone(),
1752 vec![debit.line_number, credit.line_number],
1753 ));
1754 *flow_id += 1;
1755
1756 remaining_credits.retain(|(idx, _)| idx != ci);
1758 }
1759 } else {
1760 let total_remaining: FixedPoint128 = remaining_credits
1762 .iter()
1763 .map(|(_, c)| c.amount)
1764 .fold(FixedPoint128::ZERO, |acc, a| acc + a);
1765
1766 if total_remaining.value > 0 {
1767 for (_, credit) in &remaining_credits {
1768 let proportion = credit.amount.value as f64 / total_remaining.value as f64;
1770 let allocated = FixedPoint128::from_f64(debit.amount.to_f64() * proportion);
1771
1772 if allocated.value > 0 {
1773 let mut flow = Self::create_flow(
1774 *flow_id,
1775 entry.id,
1776 debit.account_code.clone(),
1777 credit.account_code.clone(),
1778 allocated,
1779 entry.posting_date,
1780 SolvingMethod::MethodC,
1781 debit.entity_id.clone(),
1782 credit.entity_id.clone(),
1783 currency.clone(),
1784 vec![debit.line_number, credit.line_number],
1785 );
1786 flow.confidence *= 0.9;
1788 flows.push(flow);
1789 *flow_id += 1;
1790 }
1791 }
1792 }
1793 }
1794 }
1795
1796 flows
1797 }
1798
1799 fn find_partition_subset(
1802 &self,
1803 credits: &[(usize, ClassifiedLine)],
1804 target: FixedPoint128,
1805 tolerance: FixedPoint128,
1806 ) -> Option<Vec<(usize, ClassifiedLine)>> {
1807 let n = credits.len();
1808 if n == 0 {
1809 return None;
1810 }
1811
1812 for (idx, credit) in credits {
1814 if credit.amount.approx_eq(target, tolerance) {
1815 return Some(vec![(*idx, credit.clone())]);
1816 }
1817 }
1818
1819 if n <= 12 {
1821 return self.exhaustive_subset_search(credits, target, tolerance);
1822 }
1823
1824 self.greedy_subset_search(credits, target, tolerance)
1826 }
1827
1828 fn exhaustive_subset_search(
1830 &self,
1831 credits: &[(usize, ClassifiedLine)],
1832 target: FixedPoint128,
1833 tolerance: FixedPoint128,
1834 ) -> Option<Vec<(usize, ClassifiedLine)>> {
1835 let n = credits.len();
1836 let max_subsets = (1u32 << n).min(self.config.max_partition_depth as u32);
1837
1838 for mask in 1..max_subsets {
1839 let mut sum = FixedPoint128::ZERO;
1840 let mut subset = Vec::new();
1841
1842 for (i, (idx, credit)) in credits.iter().enumerate() {
1843 if (mask >> i) & 1 == 1 {
1844 sum += credit.amount;
1845 subset.push((*idx, credit.clone()));
1846 }
1847 }
1848
1849 if sum.approx_eq(target, tolerance) {
1850 return Some(subset);
1851 }
1852 }
1853
1854 None
1855 }
1856
1857 fn greedy_subset_search(
1859 &self,
1860 credits: &[(usize, ClassifiedLine)],
1861 target: FixedPoint128,
1862 tolerance: FixedPoint128,
1863 ) -> Option<Vec<(usize, ClassifiedLine)>> {
1864 let mut sorted: Vec<_> = credits.to_vec();
1866 sorted.sort_by(|a, b| b.1.amount.cmp(&a.1.amount));
1867
1868 let mut remaining = target;
1869 let mut subset = Vec::new();
1870
1871 for (idx, credit) in sorted {
1872 if credit.amount <= remaining + tolerance {
1873 remaining -= credit.amount;
1874 subset.push((idx, credit));
1875
1876 if remaining.is_zero(tolerance) {
1877 return Some(subset);
1878 }
1879 }
1880 }
1881
1882 None
1883 }
1884
1885 fn solve_method_d(
1893 &self,
1894 entry: &JournalEntry,
1895 debits: &[ClassifiedLine],
1896 credits: &[ClassifiedLine],
1897 flow_id: &mut u64,
1898 ) -> Vec<AccountingFlow> {
1899 let currency = entry
1900 .lines
1901 .first()
1902 .map(|l| l.currency.clone())
1903 .unwrap_or_else(|| "USD".to_string());
1904
1905 let mut debit_aggregates: HashMap<String, FixedPoint128> = HashMap::new();
1907 let mut debit_entities: HashMap<String, String> = HashMap::new();
1908 let mut debit_lines: HashMap<String, Vec<u32>> = HashMap::new();
1909
1910 for debit in debits {
1911 *debit_aggregates
1912 .entry(debit.account_code.clone())
1913 .or_default() += debit.amount;
1914 debit_entities
1915 .entry(debit.account_code.clone())
1916 .or_insert_with(|| debit.entity_id.clone());
1917 debit_lines
1918 .entry(debit.account_code.clone())
1919 .or_default()
1920 .push(debit.line_number);
1921 }
1922
1923 let mut credit_aggregates: HashMap<String, FixedPoint128> = HashMap::new();
1925 let mut credit_entities: HashMap<String, String> = HashMap::new();
1926 let mut credit_lines: HashMap<String, Vec<u32>> = HashMap::new();
1927
1928 for credit in credits {
1929 *credit_aggregates
1930 .entry(credit.account_code.clone())
1931 .or_default() += credit.amount;
1932 credit_entities
1933 .entry(credit.account_code.clone())
1934 .or_insert_with(|| credit.entity_id.clone());
1935 credit_lines
1936 .entry(credit.account_code.clone())
1937 .or_default()
1938 .push(credit.line_number);
1939 }
1940
1941 let mut flows = Vec::new();
1943 let total_credit: FixedPoint128 = credit_aggregates
1944 .values()
1945 .copied()
1946 .fold(FixedPoint128::ZERO, |acc, a| acc + a);
1947
1948 for (debit_account, debit_amount) in &debit_aggregates {
1949 for (credit_account, credit_amount) in &credit_aggregates {
1950 let proportion = if total_credit.value > 0 {
1952 credit_amount.value as f64 / total_credit.value as f64
1953 } else {
1954 1.0 / credit_aggregates.len() as f64
1955 };
1956
1957 let allocated = FixedPoint128::from_f64(debit_amount.to_f64() * proportion);
1958
1959 if allocated.value > 0 {
1960 let mut source_lines =
1961 debit_lines.get(debit_account).cloned().unwrap_or_default();
1962 source_lines.extend(
1963 credit_lines
1964 .get(credit_account)
1965 .cloned()
1966 .unwrap_or_default(),
1967 );
1968
1969 flows.push(Self::create_flow(
1970 *flow_id,
1971 entry.id,
1972 debit_account.clone(),
1973 credit_account.clone(),
1974 allocated,
1975 entry.posting_date,
1976 SolvingMethod::MethodD,
1977 debit_entities
1978 .get(debit_account)
1979 .cloned()
1980 .unwrap_or_default(),
1981 credit_entities
1982 .get(credit_account)
1983 .cloned()
1984 .unwrap_or_default(),
1985 currency.clone(),
1986 source_lines,
1987 ));
1988 *flow_id += 1;
1989 }
1990 }
1991 }
1992
1993 flows
1994 }
1995
1996 fn solve_method_e(
2004 &self,
2005 entry: &JournalEntry,
2006 debits: &[ClassifiedLine],
2007 credits: &[ClassifiedLine],
2008 flow_id: &mut u64,
2009 ) -> Vec<AccountingFlow> {
2010 let currency = entry
2011 .lines
2012 .first()
2013 .map(|l| l.currency.clone())
2014 .unwrap_or_else(|| "USD".to_string());
2015
2016 let mut entity_debits: HashMap<String, Vec<&ClassifiedLine>> = HashMap::new();
2018 let mut entity_credits: HashMap<String, Vec<&ClassifiedLine>> = HashMap::new();
2019
2020 for debit in debits {
2021 entity_debits
2022 .entry(debit.entity_id.clone())
2023 .or_default()
2024 .push(debit);
2025 }
2026 for credit in credits {
2027 entity_credits
2028 .entry(credit.entity_id.clone())
2029 .or_default()
2030 .push(credit);
2031 }
2032
2033 let mut flows = Vec::new();
2034
2035 for (entity, entity_debits_list) in &entity_debits {
2037 let entity_credits_list = entity_credits.get(entity);
2038
2039 for debit in entity_debits_list {
2040 if let Some(credits_list) = entity_credits_list {
2042 let total_entity_credit: FixedPoint128 = credits_list
2043 .iter()
2044 .map(|c| c.amount)
2045 .fold(FixedPoint128::ZERO, |acc, a| acc + a);
2046
2047 for credit in credits_list.iter() {
2048 let proportion = if total_entity_credit.value > 0 {
2049 credit.amount.value as f64 / total_entity_credit.value as f64
2050 } else {
2051 1.0 / credits_list.len() as f64
2052 };
2053
2054 let allocated = FixedPoint128::from_f64(debit.amount.to_f64() * proportion);
2055
2056 if allocated.value > 0 {
2057 flows.push(Self::create_flow(
2058 *flow_id,
2059 entry.id,
2060 debit.account_code.clone(),
2061 credit.account_code.clone(),
2062 allocated,
2063 entry.posting_date,
2064 SolvingMethod::MethodE,
2065 debit.entity_id.clone(),
2066 credit.entity_id.clone(),
2067 currency.clone(),
2068 vec![debit.line_number, credit.line_number],
2069 ));
2070 *flow_id += 1;
2071 }
2072 }
2073 } else {
2074 let all_credits: Vec<_> = credits.iter().collect();
2076 let total_credit: FixedPoint128 = all_credits
2077 .iter()
2078 .map(|c| c.amount)
2079 .fold(FixedPoint128::ZERO, |acc, a| acc + a);
2080
2081 for credit in all_credits {
2082 let proportion = if total_credit.value > 0 {
2083 credit.amount.value as f64 / total_credit.value as f64
2084 } else {
2085 1.0 / credits.len() as f64
2086 };
2087
2088 let allocated = FixedPoint128::from_f64(debit.amount.to_f64() * proportion);
2089
2090 if allocated.value > 0 {
2091 let mut flow = Self::create_flow(
2092 *flow_id,
2093 entry.id,
2094 debit.account_code.clone(),
2095 credit.account_code.clone(),
2096 allocated,
2097 entry.posting_date,
2098 SolvingMethod::MethodE,
2099 debit.entity_id.clone(),
2100 credit.entity_id.clone(),
2101 currency.clone(),
2102 vec![debit.line_number, credit.line_number],
2103 );
2104 flow.confidence *= 0.8;
2106 flows.push(flow);
2107 *flow_id += 1;
2108 }
2109 }
2110 }
2111 }
2112 }
2113
2114 flows
2115 }
2116
2117 fn create_suspense_flows(
2123 &self,
2124 entry: &JournalEntry,
2125 debits: &[ClassifiedLine],
2126 credits: &[ClassifiedLine],
2127 flow_id: &mut u64,
2128 ) -> Vec<AccountingFlow> {
2129 let currency = entry
2130 .lines
2131 .first()
2132 .map(|l| l.currency.clone())
2133 .unwrap_or_else(|| "USD".to_string());
2134
2135 let mut flows = Vec::new();
2136
2137 for debit in debits {
2139 let mut flow = Self::create_flow(
2140 *flow_id,
2141 entry.id,
2142 debit.account_code.clone(),
2143 self.config.suspense_account.clone(),
2144 debit.amount,
2145 entry.posting_date,
2146 SolvingMethod::Unsolvable,
2147 debit.entity_id.clone(),
2148 "SUSPENSE".to_string(),
2149 currency.clone(),
2150 vec![debit.line_number],
2151 );
2152 flow.confidence = 0.0;
2153 flows.push(flow);
2154 *flow_id += 1;
2155 }
2156
2157 for credit in credits {
2159 let mut flow = Self::create_flow(
2160 *flow_id,
2161 entry.id,
2162 self.config.suspense_account.clone(),
2163 credit.account_code.clone(),
2164 credit.amount,
2165 entry.posting_date,
2166 SolvingMethod::Unsolvable,
2167 "SUSPENSE".to_string(),
2168 credit.entity_id.clone(),
2169 currency.clone(),
2170 vec![credit.line_number],
2171 );
2172 flow.confidence = 0.0;
2173 flows.push(flow);
2174 *flow_id += 1;
2175 }
2176
2177 flows
2178 }
2179}
2180
2181impl GpuKernel for NetworkGeneration {
2182 fn metadata(&self) -> &KernelMetadata {
2183 &self.metadata
2184 }
2185}
2186
2187#[derive(Debug, Clone, Serialize, Deserialize)]
2193pub struct NetworkGenerationInput {
2194 pub entries: Vec<JournalEntry>,
2196 pub config: Option<NetworkGenerationConfig>,
2198}
2199
2200#[derive(Debug, Clone, Serialize, Deserialize)]
2202pub struct NetworkGenerationOutput {
2203 pub flows: Vec<AccountingFlow>,
2205 pub stats: NetworkGenerationStats,
2207}
2208
2209#[async_trait]
2210impl BatchKernel<NetworkGenerationInput, NetworkGenerationOutput> for NetworkGeneration {
2211 async fn execute(&self, input: NetworkGenerationInput) -> Result<NetworkGenerationOutput> {
2212 let kernel = if let Some(config) = input.config {
2213 NetworkGeneration::with_config(config)
2214 } else {
2215 self.clone()
2216 };
2217
2218 let network = kernel.generate(&input.entries);
2219
2220 Ok(NetworkGenerationOutput {
2221 flows: network.flows,
2222 stats: network.stats,
2223 })
2224 }
2225}
2226
2227#[derive(Debug, Clone, Serialize, Deserialize)]
2233pub struct AddEntryRing {
2234 pub request_id: u64,
2236 pub entry_id: u64,
2238 pub posting_date: u64,
2240 pub lines_data: Vec<u8>,
2242}
2243
2244#[derive(Debug, Clone, Serialize, Deserialize)]
2246pub struct AddEntryResponse {
2247 pub request_id: u64,
2249 pub entry_id: u64,
2251 pub flow_count: u32,
2253 pub method: u8,
2255 pub confidence_fp: u64,
2257}
2258
2259#[derive(Debug, Clone, Serialize, Deserialize)]
2261pub struct QueryFlowsRing {
2262 pub request_id: u64,
2264 pub account: String,
2266 pub start_time: u64,
2268 pub end_time: u64,
2270 pub limit: u32,
2272}
2273
2274#[derive(Debug, Clone, Serialize, Deserialize)]
2276pub struct QueryFlowsResponse {
2277 pub request_id: u64,
2279 pub account: String,
2281 pub total_count: u32,
2283 pub total_volume_fp: i128,
2285 pub weighted_confidence_fp: u64,
2287}
2288
2289#[derive(Debug, Clone, Serialize, Deserialize)]
2291pub struct NetworkStatsRing {
2292 pub request_id: u64,
2294}
2295
2296#[derive(Debug, Clone, Serialize, Deserialize)]
2298pub struct NetworkStatsResponse {
2299 pub request_id: u64,
2301 pub total_accounts: u32,
2303 pub total_flows: u32,
2305 pub total_volume_fp: i128,
2307 pub method_counts: [u32; 6],
2309 pub weighted_confidence_fp: u64,
2311}
2312
2313#[derive(Debug)]
2319pub struct NetworkGenerationRing {
2320 metadata: KernelMetadata,
2321 config: NetworkGenerationConfig,
2322 network: std::sync::RwLock<AccountingNetwork>,
2324 next_flow_id: std::sync::atomic::AtomicU64,
2326}
2327
2328impl Clone for NetworkGenerationRing {
2329 fn clone(&self) -> Self {
2330 Self {
2331 metadata: self.metadata.clone(),
2332 config: self.config.clone(),
2333 network: std::sync::RwLock::new(self.network.read().unwrap().clone()),
2334 next_flow_id: std::sync::atomic::AtomicU64::new(
2335 self.next_flow_id.load(std::sync::atomic::Ordering::SeqCst),
2336 ),
2337 }
2338 }
2339}
2340
2341impl Default for NetworkGenerationRing {
2342 fn default() -> Self {
2343 Self::new()
2344 }
2345}
2346
2347impl NetworkGenerationRing {
2348 pub fn new() -> Self {
2350 Self::with_config(NetworkGenerationConfig::default())
2351 }
2352
2353 pub fn with_config(config: NetworkGenerationConfig) -> Self {
2355 Self {
2356 metadata: KernelMetadata::ring(
2357 "accounting/network-generation-ring",
2358 Domain::Accounting,
2359 )
2360 .with_description("Streaming accounting network generation")
2361 .with_throughput(100_000)
2362 .with_latency_us(5.0)
2363 .with_gpu_native(true),
2364 config,
2365 network: std::sync::RwLock::new(AccountingNetwork::new()),
2366 next_flow_id: std::sync::atomic::AtomicU64::new(0),
2367 }
2368 }
2369
2370 pub fn stats(&self) -> NetworkGenerationStats {
2372 self.network.read().unwrap().stats.clone()
2373 }
2374
2375 pub fn flow_count(&self) -> usize {
2377 self.network.read().unwrap().flows.len()
2378 }
2379
2380 pub fn account_count(&self) -> usize {
2382 self.network.read().unwrap().accounts.len()
2383 }
2384
2385 pub fn add_entry(&self, entry: &JournalEntry) -> Vec<AccountingFlow> {
2387 let batch_kernel = NetworkGeneration::with_config(self.config.clone());
2388 let mut flow_id = self
2389 .next_flow_id
2390 .fetch_add(100, std::sync::atomic::Ordering::SeqCst);
2391
2392 let result = batch_kernel.process_entry(entry, &mut flow_id);
2393
2394 {
2396 let mut network = self.network.write().unwrap();
2397 for flow in &result.flows {
2398 network.add_flow(flow.clone());
2399 }
2400
2401 network.stats.total_entries += 1;
2403 match result.method {
2404 SolvingMethod::MethodA => network.stats.method_a_count += 1,
2405 SolvingMethod::MethodB => network.stats.method_b_count += 1,
2406 SolvingMethod::MethodC => network.stats.method_c_count += 1,
2407 SolvingMethod::MethodD => network.stats.method_d_count += 1,
2408 SolvingMethod::MethodE => network.stats.method_e_count += 1,
2409 SolvingMethod::Unsolvable => network.stats.unsolvable_count += 1,
2410 }
2411 network.stats.total_flows = network.flows.len();
2412 network.stats.total_volume = network.total_volume();
2413 network.stats.weighted_confidence = network.weighted_confidence();
2414 }
2415
2416 result.flows
2417 }
2418
2419 pub fn query_flows(
2421 &self,
2422 account: &str,
2423 start_time: u64,
2424 end_time: u64,
2425 limit: usize,
2426 ) -> Vec<AccountingFlow> {
2427 let network = self.network.read().unwrap();
2428 let mut flows: Vec<_> = network
2429 .flows
2430 .iter()
2431 .filter(|f| {
2432 (f.from_account == account || f.to_account == account)
2433 && f.timestamp >= start_time
2434 && f.timestamp <= end_time
2435 })
2436 .cloned()
2437 .collect();
2438
2439 flows.sort_by(|a, b| b.timestamp.cmp(&a.timestamp));
2440 flows.truncate(limit);
2441 flows
2442 }
2443
2444 pub fn clear(&self) {
2446 let mut network = self.network.write().unwrap();
2447 *network = AccountingNetwork::new();
2448 self.next_flow_id
2449 .store(0, std::sync::atomic::Ordering::SeqCst);
2450 }
2451}
2452
2453impl GpuKernel for NetworkGenerationRing {
2454 fn metadata(&self) -> &KernelMetadata {
2455 &self.metadata
2456 }
2457}
2458
2459#[cfg(test)]
2464mod tests {
2465 use super::*;
2466 use crate::types::JournalStatus;
2467
2468 fn create_simple_entry() -> JournalEntry {
2469 JournalEntry {
2470 id: 1,
2471 date: 1700000000,
2472 posting_date: 1700000000,
2473 document_number: "JE001".to_string(),
2474 lines: vec![
2475 JournalLine {
2476 line_number: 1,
2477 account_code: "1000".to_string(), debit: 1000.0,
2479 credit: 0.0,
2480 currency: "USD".to_string(),
2481 entity_id: "CORP".to_string(),
2482 cost_center: None,
2483 description: "Cash debit".to_string(),
2484 },
2485 JournalLine {
2486 line_number: 2,
2487 account_code: "4000".to_string(), debit: 0.0,
2489 credit: 1000.0,
2490 currency: "USD".to_string(),
2491 entity_id: "CORP".to_string(),
2492 cost_center: None,
2493 description: "Revenue credit".to_string(),
2494 },
2495 ],
2496 status: JournalStatus::Posted,
2497 source_system: "TEST".to_string(),
2498 description: "Simple sale".to_string(),
2499 }
2500 }
2501
2502 fn create_multi_line_entry() -> JournalEntry {
2503 JournalEntry {
2504 id: 2,
2505 date: 1700000000,
2506 posting_date: 1700000000,
2507 document_number: "JE002".to_string(),
2508 lines: vec![
2509 JournalLine {
2510 line_number: 1,
2511 account_code: "1000".to_string(),
2512 debit: 500.0,
2513 credit: 0.0,
2514 currency: "USD".to_string(),
2515 entity_id: "CORP".to_string(),
2516 cost_center: None,
2517 description: "Cash 1".to_string(),
2518 },
2519 JournalLine {
2520 line_number: 2,
2521 account_code: "1100".to_string(),
2522 debit: 300.0,
2523 credit: 0.0,
2524 currency: "USD".to_string(),
2525 entity_id: "CORP".to_string(),
2526 cost_center: None,
2527 description: "AR".to_string(),
2528 },
2529 JournalLine {
2530 line_number: 3,
2531 account_code: "4000".to_string(),
2532 debit: 0.0,
2533 credit: 500.0,
2534 currency: "USD".to_string(),
2535 entity_id: "CORP".to_string(),
2536 cost_center: None,
2537 description: "Revenue 1".to_string(),
2538 },
2539 JournalLine {
2540 line_number: 4,
2541 account_code: "4100".to_string(),
2542 debit: 0.0,
2543 credit: 300.0,
2544 currency: "USD".to_string(),
2545 entity_id: "CORP".to_string(),
2546 cost_center: None,
2547 description: "Revenue 2".to_string(),
2548 },
2549 ],
2550 status: JournalStatus::Posted,
2551 source_system: "TEST".to_string(),
2552 description: "Multi-line sale".to_string(),
2553 }
2554 }
2555
2556 fn create_asymmetric_entry() -> JournalEntry {
2557 JournalEntry {
2558 id: 3,
2559 date: 1700000000,
2560 posting_date: 1700000000,
2561 document_number: "JE003".to_string(),
2562 lines: vec![
2563 JournalLine {
2564 line_number: 1,
2565 account_code: "1000".to_string(),
2566 debit: 1000.0,
2567 credit: 0.0,
2568 currency: "USD".to_string(),
2569 entity_id: "CORP".to_string(),
2570 cost_center: None,
2571 description: "Cash".to_string(),
2572 },
2573 JournalLine {
2574 line_number: 2,
2575 account_code: "4000".to_string(),
2576 debit: 0.0,
2577 credit: 600.0,
2578 currency: "USD".to_string(),
2579 entity_id: "CORP".to_string(),
2580 cost_center: None,
2581 description: "Revenue".to_string(),
2582 },
2583 JournalLine {
2584 line_number: 3,
2585 account_code: "4100".to_string(),
2586 debit: 0.0,
2587 credit: 400.0,
2588 currency: "USD".to_string(),
2589 entity_id: "CORP".to_string(),
2590 cost_center: None,
2591 description: "Service revenue".to_string(),
2592 },
2593 ],
2594 status: JournalStatus::Posted,
2595 source_system: "TEST".to_string(),
2596 description: "Asymmetric entry".to_string(),
2597 }
2598 }
2599
2600 #[test]
2601 fn test_fixed_point_arithmetic() {
2602 let a = FixedPoint128::from_f64(100.50);
2603 let b = FixedPoint128::from_f64(50.25);
2604
2605 let sum = a + b;
2606 assert!((sum.to_f64() - 150.75).abs() < 0.0001);
2607
2608 let diff = a - b;
2609 assert!((diff.to_f64() - 50.25).abs() < 0.0001);
2610
2611 assert!(a > b);
2612 assert_eq!(
2613 FixedPoint128::from_f64(100.0).abs(),
2614 FixedPoint128::from_f64(100.0)
2615 );
2616 assert_eq!(
2617 FixedPoint128::from_f64(-100.0).abs(),
2618 FixedPoint128::from_f64(100.0)
2619 );
2620 }
2621
2622 #[test]
2623 fn test_method_a_simple_entry() {
2624 let kernel = NetworkGeneration::new();
2625 let entry = create_simple_entry();
2626 let network = kernel.generate(&[entry]);
2627
2628 assert_eq!(network.flows.len(), 1);
2629 assert_eq!(network.stats.method_a_count, 1);
2630 assert_eq!(network.stats.method_b_count, 0);
2631
2632 let flow = &network.flows[0];
2633 assert_eq!(flow.from_account, "1000");
2634 assert_eq!(flow.to_account, "4000");
2635 assert!((flow.amount_f64 - 1000.0).abs() < 0.01);
2636 assert_eq!(flow.method, SolvingMethod::MethodA);
2637 assert!((flow.confidence - 1.0).abs() < 0.001);
2638 }
2639
2640 #[test]
2641 fn test_method_b_multi_line() {
2642 let kernel = NetworkGeneration::new();
2643 let entry = create_multi_line_entry();
2644 let network = kernel.generate(&[entry]);
2645
2646 assert_eq!(network.flows.len(), 2);
2647 assert_eq!(network.stats.method_b_count, 1);
2648
2649 let total_flow: f64 = network.flows.iter().map(|f| f.amount_f64).sum();
2651 assert!((total_flow - 800.0).abs() < 0.01);
2652 }
2653
2654 #[test]
2655 fn test_method_c_asymmetric() {
2656 let kernel = NetworkGeneration::new();
2657 let entry = create_asymmetric_entry();
2658 let network = kernel.generate(&[entry]);
2659
2660 assert!(network.flows.len() >= 2);
2662 assert_eq!(network.stats.method_c_count, 1);
2663
2664 let total_flow: f64 = network.flows.iter().map(|f| f.amount_f64).sum();
2666 assert!((total_flow - 1000.0).abs() < 0.01);
2667 }
2668
2669 #[test]
2670 fn test_network_statistics() {
2671 let kernel = NetworkGeneration::new();
2672 let entries = vec![
2673 create_simple_entry(),
2674 create_multi_line_entry(),
2675 create_asymmetric_entry(),
2676 ];
2677
2678 let network = kernel.generate(&entries);
2679
2680 assert_eq!(network.stats.total_entries, 3);
2681 assert!(network.stats.total_flows >= 5);
2682 assert!(network.stats.total_volume > 2000.0);
2683 assert!(network.stats.weighted_confidence > 0.5);
2684 }
2685
2686 #[test]
2687 fn test_temporal_query() {
2688 let kernel = NetworkGeneration::new();
2689 let mut entry1 = create_simple_entry();
2690 entry1.posting_date = 1000;
2691 entry1.id = 1;
2692
2693 let mut entry2 = create_simple_entry();
2694 entry2.posting_date = 2000;
2695 entry2.id = 2;
2696
2697 let mut entry3 = create_simple_entry();
2698 entry3.posting_date = 3000;
2699 entry3.id = 3;
2700
2701 let network = kernel.generate(&[entry1, entry2, entry3]);
2702
2703 let flows = network.query_temporal(1500, 2500);
2705 assert_eq!(flows.len(), 1);
2706 assert_eq!(flows[0].entry_id, 2);
2707 }
2708
2709 #[test]
2710 fn test_adjacency_list() {
2711 let kernel = NetworkGeneration::new();
2712 let entry = create_simple_entry();
2713 let network = kernel.generate(&[entry]);
2714
2715 let outgoing = network.outgoing_flows("1000");
2716 assert_eq!(outgoing.len(), 1);
2717 assert_eq!(outgoing[0].to_account, "4000");
2718
2719 let incoming = network.incoming_flows("4000");
2720 assert_eq!(incoming.len(), 1);
2721 assert_eq!(incoming[0].from_account, "1000");
2722 }
2723
2724 #[test]
2725 fn test_ring_mode() {
2726 let ring_kernel = NetworkGenerationRing::new();
2727
2728 let entry1 = create_simple_entry();
2730 let flows1 = ring_kernel.add_entry(&entry1);
2731 assert_eq!(flows1.len(), 1);
2732
2733 let entry2 = create_multi_line_entry();
2734 let flows2 = ring_kernel.add_entry(&entry2);
2735 assert!(flows2.len() >= 2);
2736
2737 let stats = ring_kernel.stats();
2739 assert_eq!(stats.total_entries, 2);
2740 assert!(stats.total_flows >= 3);
2741 }
2742
2743 #[test]
2744 fn test_ring_query() {
2745 let ring_kernel = NetworkGenerationRing::new();
2746
2747 let entry = create_simple_entry();
2748 ring_kernel.add_entry(&entry);
2749
2750 let flows = ring_kernel.query_flows("1000", 0, u64::MAX, 100);
2751 assert_eq!(flows.len(), 1);
2752 assert_eq!(flows[0].from_account, "1000");
2753 }
2754
2755 #[test]
2756 fn test_solving_method_confidence() {
2757 assert_eq!(SolvingMethod::MethodA.confidence(), 1.00);
2758 assert_eq!(SolvingMethod::MethodB.confidence(), 0.95);
2759 assert_eq!(SolvingMethod::MethodC.confidence(), 0.85);
2760 assert_eq!(SolvingMethod::MethodD.confidence(), 0.70);
2761 assert_eq!(SolvingMethod::MethodE.confidence(), 0.50);
2762 assert_eq!(SolvingMethod::Unsolvable.confidence(), 0.00);
2763 }
2764
2765 #[test]
2766 fn test_unbalanced_entry() {
2767 let kernel = NetworkGeneration::with_config(NetworkGenerationConfig {
2768 strict_balance: true,
2769 ..Default::default()
2770 });
2771
2772 let mut entry = create_simple_entry();
2773 entry.lines[0].debit = 1500.0; let network = kernel.generate(&[entry]);
2776
2777 assert!(network.flows.is_empty());
2778 assert_eq!(network.stats.balance_errors, 1);
2779 }
2780
2781 #[test]
2782 fn test_suspense_routing() {
2783 let kernel = NetworkGeneration::with_config(NetworkGenerationConfig {
2785 max_lines_method_b: 2,
2786 max_lines_method_c: 4,
2787 enable_aggregation: false,
2788 enable_decomposition: false,
2789 suspense_account: "SUSPENSE_ACCT".to_string(),
2790 ..Default::default()
2791 });
2792
2793 let entry = JournalEntry {
2795 id: 99,
2796 date: 1700000000,
2797 posting_date: 1700000000,
2798 document_number: "JE099".to_string(),
2799 lines: vec![
2800 JournalLine {
2801 line_number: 1,
2802 account_code: "1000".to_string(),
2803 debit: 100.0,
2804 credit: 0.0,
2805 currency: "USD".to_string(),
2806 entity_id: "A".to_string(),
2807 cost_center: None,
2808 description: "D1".to_string(),
2809 },
2810 JournalLine {
2811 line_number: 2,
2812 account_code: "1001".to_string(),
2813 debit: 100.0,
2814 credit: 0.0,
2815 currency: "USD".to_string(),
2816 entity_id: "A".to_string(),
2817 cost_center: None,
2818 description: "D2".to_string(),
2819 },
2820 JournalLine {
2821 line_number: 3,
2822 account_code: "1002".to_string(),
2823 debit: 100.0,
2824 credit: 0.0,
2825 currency: "USD".to_string(),
2826 entity_id: "A".to_string(),
2827 cost_center: None,
2828 description: "D3".to_string(),
2829 },
2830 JournalLine {
2831 line_number: 4,
2832 account_code: "2000".to_string(),
2833 debit: 0.0,
2834 credit: 75.0,
2835 currency: "USD".to_string(),
2836 entity_id: "A".to_string(),
2837 cost_center: None,
2838 description: "C1".to_string(),
2839 },
2840 JournalLine {
2841 line_number: 5,
2842 account_code: "2001".to_string(),
2843 debit: 0.0,
2844 credit: 75.0,
2845 currency: "USD".to_string(),
2846 entity_id: "A".to_string(),
2847 cost_center: None,
2848 description: "C2".to_string(),
2849 },
2850 JournalLine {
2851 line_number: 6,
2852 account_code: "2002".to_string(),
2853 debit: 0.0,
2854 credit: 75.0,
2855 currency: "USD".to_string(),
2856 entity_id: "A".to_string(),
2857 cost_center: None,
2858 description: "C3".to_string(),
2859 },
2860 JournalLine {
2861 line_number: 7,
2862 account_code: "2003".to_string(),
2863 debit: 0.0,
2864 credit: 75.0,
2865 currency: "USD".to_string(),
2866 entity_id: "A".to_string(),
2867 cost_center: None,
2868 description: "C4".to_string(),
2869 },
2870 ],
2871 status: JournalStatus::Posted,
2872 source_system: "TEST".to_string(),
2873 description: "Complex entry".to_string(),
2874 };
2875
2876 let network = kernel.generate(&[entry]);
2877
2878 assert_eq!(network.stats.unsolvable_count, 1);
2879 assert!(
2881 network
2882 .flows
2883 .iter()
2884 .any(|f| f.from_account == "SUSPENSE_ACCT" || f.to_account == "SUSPENSE_ACCT")
2885 );
2886 }
2887
2888 #[test]
2889 fn test_metadata() {
2890 let kernel = NetworkGeneration::new();
2891 assert_eq!(kernel.metadata().id, "accounting/network-generation");
2892 assert_eq!(kernel.metadata().domain, Domain::Accounting);
2893
2894 let ring_kernel = NetworkGenerationRing::new();
2895 assert_eq!(
2896 ring_kernel.metadata().id,
2897 "accounting/network-generation-ring"
2898 );
2899 }
2900
2901 #[test]
2902 fn test_weighted_confidence() {
2903 let kernel = NetworkGeneration::new();
2904 let entries = vec![
2905 create_simple_entry(), create_asymmetric_entry(), ];
2908
2909 let network = kernel.generate(&entries);
2910
2911 assert!(network.stats.weighted_confidence >= 0.85);
2913 assert!(network.stats.weighted_confidence <= 1.0);
2914 }
2915
2916 #[test]
2921 fn test_account_classification_numeric() {
2922 assert_eq!(AccountClass::from_account_code("1000"), AccountClass::Asset);
2924 assert_eq!(AccountClass::from_account_code("1500"), AccountClass::Asset);
2925 assert_eq!(
2926 AccountClass::from_account_code("2000"),
2927 AccountClass::Liability
2928 );
2929 assert_eq!(
2930 AccountClass::from_account_code("3000"),
2931 AccountClass::Equity
2932 );
2933 assert_eq!(
2934 AccountClass::from_account_code("4000"),
2935 AccountClass::Revenue
2936 );
2937 assert_eq!(AccountClass::from_account_code("5000"), AccountClass::COGS);
2938 assert_eq!(
2939 AccountClass::from_account_code("6000"),
2940 AccountClass::Expense
2941 );
2942 assert_eq!(
2943 AccountClass::from_account_code("7500"),
2944 AccountClass::Expense
2945 );
2946 assert_eq!(
2947 AccountClass::from_account_code("8000"),
2948 AccountClass::OtherIncomeExpense
2949 );
2950 assert_eq!(
2951 AccountClass::from_account_code("9000"),
2952 AccountClass::Suspense
2953 );
2954 }
2955
2956 #[test]
2957 fn test_account_classification_keywords() {
2958 assert_eq!(
2960 AccountClass::from_account_code("CASH_ON_HAND"),
2961 AccountClass::Asset
2962 );
2963 assert_eq!(
2964 AccountClass::from_account_code("BANK_ACCOUNT"),
2965 AccountClass::Asset
2966 );
2967 assert_eq!(
2968 AccountClass::from_account_code("ACCOUNTS_RECEIVABLE"),
2969 AccountClass::Asset
2970 );
2971 assert_eq!(
2972 AccountClass::from_account_code("ACCOUNTS_PAYABLE"),
2973 AccountClass::Liability
2974 );
2975 assert_eq!(
2976 AccountClass::from_account_code("VAT_PAYABLE"),
2977 AccountClass::Tax
2978 );
2979 assert_eq!(
2980 AccountClass::from_account_code("GST_RECEIVABLE"),
2981 AccountClass::Tax
2982 );
2983 assert_eq!(
2984 AccountClass::from_account_code("IC_DUE_FROM_SUB"),
2985 AccountClass::Intercompany
2986 );
2987 assert_eq!(
2988 AccountClass::from_account_code("SUSPENSE_CLEARING"),
2989 AccountClass::Suspense
2990 );
2991 assert_eq!(
2992 AccountClass::from_account_code("SALES_REVENUE"),
2993 AccountClass::Revenue
2994 );
2995 assert_eq!(
2996 AccountClass::from_account_code("RENT_EXPENSE"),
2997 AccountClass::Expense
2998 );
2999 }
3000
3001 #[test]
3002 fn test_vat_detection_20_percent() {
3003 let detector = VatDetector::new();
3004
3005 let amounts = vec![
3007 FixedPoint128::from_f64(1200.0), FixedPoint128::from_f64(1000.0), ];
3010
3011 let result = detector.detect_vat_split(&amounts);
3012 assert!(result.is_some());
3013 let (net, tax, rate) = result.unwrap();
3014 assert!((net.to_f64() - 1000.0).abs() < 0.01);
3015 assert!((tax.to_f64() - 200.0).abs() < 0.01);
3016 assert!((rate.rate - 0.20).abs() < 0.01);
3017 }
3018
3019 #[test]
3020 fn test_vat_detection_19_percent() {
3021 let detector = VatDetector::new();
3022
3023 let amounts = vec![
3025 FixedPoint128::from_f64(1190.0), FixedPoint128::from_f64(1000.0), ];
3028
3029 let result = detector.detect_vat_split(&amounts);
3030 assert!(result.is_some());
3031 let (net, tax, rate) = result.unwrap();
3032 assert!((net.to_f64() - 1000.0).abs() < 0.01);
3033 assert!((tax.to_f64() - 190.0).abs() < 0.01);
3034 assert!((rate.rate - 0.19).abs() < 0.01);
3035 }
3036
3037 #[test]
3038 fn test_sale_with_vat_pattern() {
3039 let kernel = NetworkGeneration::new();
3040
3041 let entry = JournalEntry {
3043 id: 100,
3044 date: 1700000000,
3045 posting_date: 1700000000,
3046 document_number: "SALE001".to_string(),
3047 lines: vec![
3048 JournalLine {
3049 line_number: 1,
3050 account_code: "1000".to_string(), debit: 1200.0,
3052 credit: 0.0,
3053 currency: "USD".to_string(),
3054 entity_id: "CORP".to_string(),
3055 cost_center: None,
3056 description: "Cash receipt".to_string(),
3057 },
3058 JournalLine {
3059 line_number: 2,
3060 account_code: "4000".to_string(), debit: 0.0,
3062 credit: 1000.0,
3063 currency: "USD".to_string(),
3064 entity_id: "CORP".to_string(),
3065 cost_center: None,
3066 description: "Sales revenue".to_string(),
3067 },
3068 JournalLine {
3069 line_number: 3,
3070 account_code: "VAT_OUTPUT".to_string(), debit: 0.0,
3072 credit: 200.0,
3073 currency: "USD".to_string(),
3074 entity_id: "CORP".to_string(),
3075 cost_center: None,
3076 description: "Output VAT".to_string(),
3077 },
3078 ],
3079 status: JournalStatus::Posted,
3080 source_system: "TEST".to_string(),
3081 description: "Sale with VAT".to_string(),
3082 };
3083
3084 let network = kernel.generate(&[entry]);
3085
3086 assert_eq!(network.stats.vat_entries_count, 1);
3088 assert!(network.stats.total_vat_amount > 0.0);
3089 assert_eq!(network.stats.sales_pattern_count, 1);
3090
3091 assert!(
3093 network
3094 .flows
3095 .iter()
3096 .any(|f| f.pattern == Some(TransactionPattern::SaleWithVat))
3097 );
3098 }
3099
3100 #[test]
3101 fn test_purchase_with_vat_pattern() {
3102 let kernel = NetworkGeneration::new();
3103
3104 let entry = JournalEntry {
3106 id: 101,
3107 date: 1700000000,
3108 posting_date: 1700000000,
3109 document_number: "PURCH001".to_string(),
3110 lines: vec![
3111 JournalLine {
3112 line_number: 1,
3113 account_code: "6000".to_string(), debit: 1000.0,
3115 credit: 0.0,
3116 currency: "USD".to_string(),
3117 entity_id: "CORP".to_string(),
3118 cost_center: None,
3119 description: "Office supplies".to_string(),
3120 },
3121 JournalLine {
3122 line_number: 2,
3123 account_code: "VAT_INPUT".to_string(), debit: 200.0,
3125 credit: 0.0,
3126 currency: "USD".to_string(),
3127 entity_id: "CORP".to_string(),
3128 cost_center: None,
3129 description: "Input VAT".to_string(),
3130 },
3131 JournalLine {
3132 line_number: 3,
3133 account_code: "1000".to_string(), debit: 0.0,
3135 credit: 1200.0,
3136 currency: "USD".to_string(),
3137 entity_id: "CORP".to_string(),
3138 cost_center: None,
3139 description: "Payment".to_string(),
3140 },
3141 ],
3142 status: JournalStatus::Posted,
3143 source_system: "TEST".to_string(),
3144 description: "Purchase with VAT".to_string(),
3145 };
3146
3147 let network = kernel.generate(&[entry]);
3148
3149 assert_eq!(network.stats.vat_entries_count, 1);
3150 assert_eq!(network.stats.purchase_pattern_count, 1);
3151 }
3152
3153 #[test]
3154 fn test_simple_sale_pattern() {
3155 let kernel = NetworkGeneration::new();
3156
3157 let entry = create_simple_entry();
3159 let network = kernel.generate(&[entry]);
3160
3161 assert_eq!(network.stats.sales_pattern_count, 1);
3162 assert!(
3163 network
3164 .flows
3165 .iter()
3166 .any(|f| f.pattern == Some(TransactionPattern::SimpleSale))
3167 );
3168 }
3169
3170 #[test]
3171 fn test_intercompany_detection() {
3172 let kernel = NetworkGeneration::new();
3173
3174 let entry = JournalEntry {
3176 id: 102,
3177 date: 1700000000,
3178 posting_date: 1700000000,
3179 document_number: "IC001".to_string(),
3180 lines: vec![
3181 JournalLine {
3182 line_number: 1,
3183 account_code: "1000".to_string(),
3184 debit: 5000.0,
3185 credit: 0.0,
3186 currency: "USD".to_string(),
3187 entity_id: "CORP_A".to_string(), cost_center: None,
3189 description: "IC receivable".to_string(),
3190 },
3191 JournalLine {
3192 line_number: 2,
3193 account_code: "4000".to_string(),
3194 debit: 0.0,
3195 credit: 5000.0,
3196 currency: "USD".to_string(),
3197 entity_id: "CORP_B".to_string(), cost_center: None,
3199 description: "IC revenue".to_string(),
3200 },
3201 ],
3202 status: JournalStatus::Posted,
3203 source_system: "TEST".to_string(),
3204 description: "Intercompany sale".to_string(),
3205 };
3206
3207 let network = kernel.generate(&[entry]);
3208
3209 assert_eq!(network.stats.intercompany_count, 1);
3210 assert!(network.flows.iter().any(|f| f.is_intercompany));
3211 assert!(
3212 network
3213 .flows
3214 .iter()
3215 .any(|f| f.pattern == Some(TransactionPattern::Intercompany))
3216 );
3217 }
3218
3219 #[test]
3220 fn test_confidence_boost_from_pattern() {
3221 let kernel = NetworkGeneration::new();
3222 let entry = create_simple_entry();
3223 let network = kernel.generate(&[entry]);
3224
3225 for flow in &network.flows {
3229 assert!(flow.confidence >= 1.0); assert!(
3231 flow.confidence_factors
3232 .iter()
3233 .any(|f| f.contains("pattern"))
3234 );
3235 }
3236 }
3237
3238 #[test]
3239 fn test_account_class_annotations() {
3240 let kernel = NetworkGeneration::new();
3241 let entry = create_simple_entry();
3242 let network = kernel.generate(&[entry]);
3243
3244 for flow in &network.flows {
3246 assert!(flow.from_account_class.is_some());
3247 assert!(flow.to_account_class.is_some());
3248 assert_eq!(flow.from_account_class, Some(AccountClass::Asset)); assert_eq!(flow.to_account_class, Some(AccountClass::Revenue)); }
3251 }
3252
3253 #[test]
3254 fn test_pattern_matcher() {
3255 let matcher = PatternMatcher::new();
3256
3257 let debits = vec![ClassifiedLine {
3259 line_number: 1,
3260 account_code: "1000".to_string(),
3261 amount: FixedPoint128::from_f64(1000.0),
3262 is_debit: true,
3263 entity_id: "CORP".to_string(),
3264 cost_center: None,
3265 }];
3266
3267 let credits = vec![ClassifiedLine {
3268 line_number: 2,
3269 account_code: "4000".to_string(),
3270 amount: FixedPoint128::from_f64(1000.0),
3271 is_debit: false,
3272 entity_id: "CORP".to_string(),
3273 cost_center: None,
3274 }];
3275
3276 let (pattern, vat) = matcher.detect_pattern(&debits, &credits);
3277 assert_eq!(pattern, TransactionPattern::SimpleSale);
3278 assert!(vat.is_none());
3279 }
3280
3281 #[test]
3282 fn test_vat_pattern_detection_with_tax_accounts() {
3283 let detector = VatDetector::new();
3284
3285 let lines = vec![
3286 ClassifiedLine {
3287 line_number: 1,
3288 account_code: "1000".to_string(),
3289 amount: FixedPoint128::from_f64(1200.0),
3290 is_debit: true,
3291 entity_id: "CORP".to_string(),
3292 cost_center: None,
3293 },
3294 ClassifiedLine {
3295 line_number: 2,
3296 account_code: "4000".to_string(),
3297 amount: FixedPoint128::from_f64(1000.0),
3298 is_debit: false,
3299 entity_id: "CORP".to_string(),
3300 cost_center: None,
3301 },
3302 ClassifiedLine {
3303 line_number: 3,
3304 account_code: "VAT_OUTPUT".to_string(),
3305 amount: FixedPoint128::from_f64(200.0),
3306 is_debit: false,
3307 entity_id: "CORP".to_string(),
3308 cost_center: None,
3309 },
3310 ];
3311
3312 let result = detector.detect_vat_pattern(&lines);
3313 assert!(result.is_some());
3314 let vat = result.unwrap();
3315 assert!(vat.is_output_vat);
3316 assert!((vat.rate.rate - 0.20).abs() < 0.01);
3317 assert!(vat.tax_accounts.contains(&"VAT_OUTPUT".to_string()));
3318 }
3319
3320 #[test]
3321 fn test_custom_vat_rate() {
3322 let config = NetworkGenerationConfig {
3323 custom_vat_rates: vec![0.15], ..Default::default()
3325 };
3326 let kernel = NetworkGeneration::with_config(config);
3327
3328 let entry = JournalEntry {
3330 id: 103,
3331 date: 1700000000,
3332 posting_date: 1700000000,
3333 document_number: "VAT15".to_string(),
3334 lines: vec![
3335 JournalLine {
3336 line_number: 1,
3337 account_code: "1000".to_string(),
3338 debit: 1150.0, credit: 0.0,
3340 currency: "USD".to_string(),
3341 entity_id: "CORP".to_string(),
3342 cost_center: None,
3343 description: "Cash".to_string(),
3344 },
3345 JournalLine {
3346 line_number: 2,
3347 account_code: "4000".to_string(),
3348 debit: 0.0,
3349 credit: 1000.0,
3350 currency: "USD".to_string(),
3351 entity_id: "CORP".to_string(),
3352 cost_center: None,
3353 description: "Revenue".to_string(),
3354 },
3355 JournalLine {
3356 line_number: 3,
3357 account_code: "VAT_OUTPUT".to_string(),
3358 debit: 0.0,
3359 credit: 150.0,
3360 currency: "USD".to_string(),
3361 entity_id: "CORP".to_string(),
3362 cost_center: None,
3363 description: "VAT".to_string(),
3364 },
3365 ],
3366 status: JournalStatus::Posted,
3367 source_system: "TEST".to_string(),
3368 description: "Sale with 15% VAT".to_string(),
3369 };
3370
3371 let network = kernel.generate(&[entry]);
3372 assert_eq!(network.stats.vat_entries_count, 1);
3373 }
3374
3375 #[test]
3376 fn test_disable_pattern_matching() {
3377 let config = NetworkGenerationConfig {
3378 enable_pattern_matching: false,
3379 ..Default::default()
3380 };
3381 let kernel = NetworkGeneration::with_config(config);
3382 let entry = create_simple_entry();
3383 let network = kernel.generate(&[entry]);
3384
3385 for flow in &network.flows {
3387 assert!(flow.pattern.is_none());
3388 }
3389 }
3390
3391 #[test]
3392 fn test_enhanced_stats() {
3393 let kernel = NetworkGeneration::new();
3394
3395 let entries = vec![
3396 create_simple_entry(), ];
3398
3399 let network = kernel.generate(&entries);
3400
3401 assert_eq!(network.stats.sales_pattern_count, 1);
3403 assert_eq!(network.stats.purchase_pattern_count, 0);
3404 assert_eq!(network.stats.payment_pattern_count, 0);
3405 assert_eq!(network.stats.payroll_pattern_count, 0);
3406 assert_eq!(network.stats.intercompany_count, 0);
3407 }
3408}