1use crate::types::{
8 AccountType, GaapViolation, GaapViolationResult, GaapViolationSeverity, GaapViolationType,
9 JournalEntry, JournalLine, SuspenseAccountCandidate, SuspenseAccountResult, SuspenseIndicator,
10 SuspenseRiskLevel,
11};
12use rustkernel_core::{domain::Domain, kernel::KernelMetadata, traits::GpuKernel};
13use std::collections::{HashMap, HashSet};
14
15#[derive(Debug, Clone)]
24pub struct SuspenseAccountDetection {
25 metadata: KernelMetadata,
26}
27
28impl Default for SuspenseAccountDetection {
29 fn default() -> Self {
30 Self::new()
31 }
32}
33
34impl SuspenseAccountDetection {
35 #[must_use]
37 pub fn new() -> Self {
38 Self {
39 metadata: KernelMetadata::batch("accounting/suspense-detection", Domain::Accounting)
40 .with_description("Centrality-based suspense account detection")
41 .with_throughput(20_000)
42 .with_latency_us(150.0),
43 }
44 }
45
46 pub fn detect(
48 entries: &[JournalEntry],
49 config: &SuspenseDetectionConfig,
50 ) -> SuspenseAccountResult {
51 if entries.is_empty() {
52 return SuspenseAccountResult {
53 candidates: Vec::new(),
54 high_risk_accounts: Vec::new(),
55 accounts_analyzed: 0,
56 risk_score: 0.0,
57 };
58 }
59
60 let account_graph = Self::build_account_graph(entries);
62
63 let mut candidates = Vec::new();
65 let mut high_risk_accounts = Vec::new();
66
67 for (account_code, metrics) in &account_graph.account_metrics {
68 let indicators = Self::check_indicators(metrics, config);
69
70 if indicators.is_empty() {
71 continue;
72 }
73
74 let suspense_score = Self::calculate_suspense_score(&indicators, metrics);
75 let risk_level = Self::determine_risk_level(suspense_score, &indicators);
76
77 let candidate = SuspenseAccountCandidate {
78 account_code: account_code.clone(),
79 account_name: metrics.account_name.clone(),
80 suspense_score,
81 centrality_score: metrics.betweenness_centrality,
82 turnover_volume: metrics.total_debit + metrics.total_credit,
83 avg_holding_period: metrics.avg_holding_days,
84 counterparty_count: metrics.counterparty_count,
85 balance_ratio: metrics.balance_ratio,
86 risk_level,
87 indicators: indicators.clone(),
88 };
89
90 if matches!(
91 risk_level,
92 SuspenseRiskLevel::High | SuspenseRiskLevel::Critical
93 ) {
94 high_risk_accounts.push(account_code.clone());
95 }
96
97 candidates.push(candidate);
98 }
99
100 candidates.sort_by(|a, b| {
102 b.suspense_score
103 .partial_cmp(&a.suspense_score)
104 .unwrap_or(std::cmp::Ordering::Equal)
105 });
106
107 let risk_score = if candidates.is_empty() {
108 0.0
109 } else {
110 (high_risk_accounts.len() as f64 / candidates.len().max(1) as f64 * 50.0
111 + candidates.iter().map(|c| c.suspense_score).sum::<f64>()
112 / candidates.len().max(1) as f64)
113 .min(100.0)
114 };
115
116 SuspenseAccountResult {
117 candidates,
118 high_risk_accounts,
119 accounts_analyzed: account_graph.account_metrics.len(),
120 risk_score,
121 }
122 }
123
124 fn build_account_graph(entries: &[JournalEntry]) -> AccountGraph {
126 let mut graph = AccountGraph::new();
127
128 for entry in entries {
129 let debits: Vec<_> = entry.lines.iter().filter(|l| l.debit > 0.0).collect();
131 let credits: Vec<_> = entry.lines.iter().filter(|l| l.credit > 0.0).collect();
132
133 for debit_line in &debits {
135 for credit_line in &credits {
136 let amount = debit_line.debit.min(credit_line.credit);
137 graph.add_edge(
138 &credit_line.account_code,
139 &debit_line.account_code,
140 amount,
141 entry.posting_date,
142 );
143 }
144 }
145
146 for line in &entry.lines {
148 graph.update_account_metrics(line, entry.posting_date);
149 }
150 }
151
152 graph.calculate_betweenness_centrality();
154
155 graph
156 }
157
158 fn check_indicators(
160 metrics: &AccountMetrics,
161 config: &SuspenseDetectionConfig,
162 ) -> Vec<SuspenseIndicator> {
163 let mut indicators = Vec::new();
164
165 if metrics.betweenness_centrality >= config.centrality_threshold {
167 indicators.push(SuspenseIndicator::HighCentrality);
168 }
169
170 let turnover = metrics.total_debit + metrics.total_credit;
172 let avg_balance = (metrics.total_debit - metrics.total_credit).abs() / 2.0;
173 if avg_balance > 0.0 && turnover / avg_balance >= config.turnover_ratio_threshold {
174 indicators.push(SuspenseIndicator::HighTurnover);
175 }
176
177 if metrics.avg_holding_days <= config.holding_period_threshold {
179 indicators.push(SuspenseIndicator::ShortHoldingPeriod);
180 }
181
182 if metrics.balance_ratio >= config.balance_ratio_threshold {
184 indicators.push(SuspenseIndicator::BalancedFlows);
185 }
186
187 if metrics.counterparty_count >= config.counterparty_threshold {
189 indicators.push(SuspenseIndicator::ManyCounterparties);
190 }
191
192 if metrics.end_balance.abs() < config.zero_balance_threshold {
194 indicators.push(SuspenseIndicator::ZeroEndBalance);
195 }
196
197 let name_lower = metrics.account_name.to_lowercase();
199 if name_lower.contains("suspense")
200 || name_lower.contains("clearing")
201 || name_lower.contains("holding")
202 || name_lower.contains("temporary")
203 || name_lower.contains("wash")
204 {
205 indicators.push(SuspenseIndicator::SuspenseNaming);
206 }
207
208 indicators
209 }
210
211 fn calculate_suspense_score(indicators: &[SuspenseIndicator], metrics: &AccountMetrics) -> f64 {
213 let mut score = 0.0;
214
215 for indicator in indicators {
216 score += match indicator {
217 SuspenseIndicator::HighCentrality => 20.0,
218 SuspenseIndicator::HighTurnover => 15.0,
219 SuspenseIndicator::ShortHoldingPeriod => 15.0,
220 SuspenseIndicator::BalancedFlows => 15.0,
221 SuspenseIndicator::ManyCounterparties => 10.0,
222 SuspenseIndicator::ZeroEndBalance => 15.0,
223 SuspenseIndicator::SuspenseNaming => 10.0,
224 };
225 }
226
227 if indicators.contains(&SuspenseIndicator::HighCentrality) && indicators.len() >= 3 {
229 score += metrics.betweenness_centrality * 10.0;
230 }
231
232 score.min(100.0)
233 }
234
235 fn determine_risk_level(score: f64, indicators: &[SuspenseIndicator]) -> SuspenseRiskLevel {
237 let has_critical_indicators = indicators.contains(&SuspenseIndicator::HighCentrality)
238 && indicators.contains(&SuspenseIndicator::BalancedFlows)
239 && indicators.contains(&SuspenseIndicator::ZeroEndBalance);
240
241 if has_critical_indicators || score >= 80.0 {
242 SuspenseRiskLevel::Critical
243 } else if score >= 60.0 {
244 SuspenseRiskLevel::High
245 } else if score >= 40.0 {
246 SuspenseRiskLevel::Medium
247 } else {
248 SuspenseRiskLevel::Low
249 }
250 }
251}
252
253impl GpuKernel for SuspenseAccountDetection {
254 fn metadata(&self) -> &KernelMetadata {
255 &self.metadata
256 }
257}
258
259#[derive(Debug, Clone)]
261pub struct SuspenseDetectionConfig {
262 pub centrality_threshold: f64,
264 pub turnover_ratio_threshold: f64,
266 pub holding_period_threshold: f64,
268 pub balance_ratio_threshold: f64,
270 pub counterparty_threshold: usize,
272 pub zero_balance_threshold: f64,
274}
275
276impl Default for SuspenseDetectionConfig {
277 fn default() -> Self {
278 Self {
279 centrality_threshold: 0.1,
280 turnover_ratio_threshold: 10.0,
281 holding_period_threshold: 7.0,
282 balance_ratio_threshold: 0.9,
283 counterparty_threshold: 5,
284 zero_balance_threshold: 100.0,
285 }
286 }
287}
288
289#[derive(Debug, Clone)]
297pub struct GaapViolationDetection {
298 metadata: KernelMetadata,
299}
300
301impl Default for GaapViolationDetection {
302 fn default() -> Self {
303 Self::new()
304 }
305}
306
307impl GaapViolationDetection {
308 #[must_use]
310 pub fn new() -> Self {
311 Self {
312 metadata: KernelMetadata::batch("accounting/gaap-violation", Domain::Accounting)
313 .with_description("GAAP prohibited flow pattern detection")
314 .with_throughput(15_000)
315 .with_latency_us(200.0),
316 }
317 }
318
319 pub fn detect(
321 entries: &[JournalEntry],
322 account_types: &HashMap<String, AccountType>,
323 config: &GaapDetectionConfig,
324 ) -> GaapViolationResult {
325 if entries.is_empty() {
326 return GaapViolationResult {
327 violations: Vec::new(),
328 entries_analyzed: 0,
329 amount_at_risk: 0.0,
330 compliance_score: 100.0,
331 violation_counts: HashMap::new(),
332 };
333 }
334
335 let mut violations = Vec::new();
336 let mut violation_id = 1;
337
338 for entry in entries {
340 let rev_exp = Self::check_revenue_expense_transfer(entry, account_types);
342 if let Some(mut v) = rev_exp {
343 v.id = format!("GAAP{:05}", violation_id);
344 violation_id += 1;
345 violations.push(v);
346 }
347
348 let asset_exp = Self::check_improper_asset_expense(entry, account_types);
350 if let Some(mut v) = asset_exp {
351 v.id = format!("GAAP{:05}", violation_id);
352 violation_id += 1;
353 violations.push(v);
354 }
355
356 let suspense_misuse = Self::check_suspense_misuse(entry, config);
358 if let Some(mut v) = suspense_misuse {
359 v.id = format!("GAAP{:05}", violation_id);
360 violation_id += 1;
361 violations.push(v);
362 }
363 }
364
365 let circular = Self::check_circular_flows(entries, account_types);
367 for mut v in circular {
368 v.id = format!("GAAP{:05}", violation_id);
369 violation_id += 1;
370 violations.push(v);
371 }
372
373 let amount_at_risk: f64 = violations.iter().map(|v| v.amount).sum();
375 let entries_analyzed = entries.len();
376
377 let mut violation_counts: HashMap<String, usize> = HashMap::new();
379 for v in &violations {
380 let type_name = format!("{:?}", v.violation_type);
381 *violation_counts.entry(type_name).or_insert(0) += 1;
382 }
383
384 let major_violations = violations
386 .iter()
387 .filter(|v| {
388 matches!(
389 v.severity,
390 GaapViolationSeverity::Major | GaapViolationSeverity::Critical
391 )
392 })
393 .count();
394
395 let compliance_score =
396 (100.0 - (violations.len() as f64 * 2.0) - (major_violations as f64 * 10.0)).max(0.0);
397
398 GaapViolationResult {
399 violations,
400 entries_analyzed,
401 amount_at_risk,
402 compliance_score,
403 violation_counts,
404 }
405 }
406
407 fn check_revenue_expense_transfer(
409 entry: &JournalEntry,
410 account_types: &HashMap<String, AccountType>,
411 ) -> Option<GaapViolation> {
412 let debits: Vec<_> = entry.lines.iter().filter(|l| l.debit > 0.0).collect();
413 let credits: Vec<_> = entry.lines.iter().filter(|l| l.credit > 0.0).collect();
414
415 for debit in &debits {
416 for credit in &credits {
417 let debit_type = account_types.get(&debit.account_code);
418 let credit_type = account_types.get(&credit.account_code);
419
420 if matches!(credit_type, Some(AccountType::Revenue))
422 && matches!(debit_type, Some(AccountType::Expense))
423 {
424 return Some(GaapViolation {
425 id: String::new(),
426 violation_type: GaapViolationType::DirectRevenueExpense,
427 accounts: vec![credit.account_code.clone(), debit.account_code.clone()],
428 entry_ids: vec![entry.id],
429 amount: debit.debit.min(credit.credit),
430 description: format!(
431 "Direct transfer from revenue ({}) to expense ({}) without capital account",
432 credit.account_code, debit.account_code
433 ),
434 severity: GaapViolationSeverity::Major,
435 remediation:
436 "Route through retained earnings or appropriate capital account"
437 .to_string(),
438 });
439 }
440 }
441 }
442
443 None
444 }
445
446 fn check_improper_asset_expense(
448 entry: &JournalEntry,
449 account_types: &HashMap<String, AccountType>,
450 ) -> Option<GaapViolation> {
451 let debits: Vec<_> = entry.lines.iter().filter(|l| l.debit > 0.0).collect();
452 let credits: Vec<_> = entry.lines.iter().filter(|l| l.credit > 0.0).collect();
453
454 for debit in &debits {
455 for credit in &credits {
456 let debit_type = account_types.get(&debit.account_code);
457 let credit_type = account_types.get(&credit.account_code);
458
459 if matches!(credit_type, Some(AccountType::Asset))
461 && matches!(debit_type, Some(AccountType::Expense))
462 {
463 let amount = debit.debit.min(credit.credit);
465 if amount > 5000.0 {
466 return Some(GaapViolation {
467 id: String::new(),
468 violation_type: GaapViolationType::ImproperAssetExpense,
469 accounts: vec![credit.account_code.clone(), debit.account_code.clone()],
470 entry_ids: vec![entry.id],
471 amount,
472 description: format!(
473 "Large asset ({}) expensed directly to {} without depreciation",
474 credit.account_code, debit.account_code
475 ),
476 severity: GaapViolationSeverity::Moderate,
477 remediation:
478 "Use depreciation schedule for asset disposal or verify expensing is appropriate"
479 .to_string(),
480 });
481 }
482 }
483 }
484 }
485
486 None
487 }
488
489 fn check_suspense_misuse(
491 entry: &JournalEntry,
492 config: &GaapDetectionConfig,
493 ) -> Option<GaapViolation> {
494 let suspense_keywords = ["suspense", "clearing", "holding", "temporary"];
495
496 for line in &entry.lines {
497 let account_lower = line.account_code.to_lowercase();
498 let is_suspense = suspense_keywords
499 .iter()
500 .any(|kw| account_lower.contains(kw));
501
502 if is_suspense {
503 let amount = line.debit.max(line.credit);
504 if amount > config.suspense_amount_threshold {
505 return Some(GaapViolation {
506 id: String::new(),
507 violation_type: GaapViolationType::SuspenseAccountMisuse,
508 accounts: vec![line.account_code.clone()],
509 entry_ids: vec![entry.id],
510 amount,
511 description: format!(
512 "Large amount ({:.2}) posted to suspense account {}",
513 amount, line.account_code
514 ),
515 severity: GaapViolationSeverity::Minor,
516 remediation:
517 "Clear suspense account to proper account within reporting period"
518 .to_string(),
519 });
520 }
521 }
522 }
523
524 None
525 }
526
527 fn check_circular_flows(
529 entries: &[JournalEntry],
530 account_types: &HashMap<String, AccountType>,
531 ) -> Vec<GaapViolation> {
532 let mut violations = Vec::new();
533
534 let mut flows: HashMap<(String, String), (f64, Vec<u64>)> = HashMap::new();
536
537 for entry in entries {
538 let debits: Vec<_> = entry.lines.iter().filter(|l| l.debit > 0.0).collect();
539 let credits: Vec<_> = entry.lines.iter().filter(|l| l.credit > 0.0).collect();
540
541 for debit in &debits {
542 for credit in &credits {
543 let key = (credit.account_code.clone(), debit.account_code.clone());
544 let amount = debit.debit.min(credit.credit);
545
546 let entry_data = flows.entry(key).or_insert((0.0, Vec::new()));
547 entry_data.0 += amount;
548 entry_data.1.push(entry.id);
549 }
550 }
551 }
552
553 let mut checked: HashSet<(String, String)> = HashSet::new();
555
556 for ((from, to), (amount, entry_ids)) in &flows {
557 if checked.contains(&(from.clone(), to.clone()))
558 || checked.contains(&(to.clone(), from.clone()))
559 {
560 continue;
561 }
562
563 if let Some((reverse_amount, reverse_ids)) = flows.get(&(to.clone(), from.clone())) {
564 let from_type = account_types.get(from);
566 let to_type = account_types.get(to);
567
568 let involves_revenue = matches!(from_type, Some(AccountType::Revenue))
569 || matches!(to_type, Some(AccountType::Revenue));
570
571 if involves_revenue && *amount > 1000.0 && *reverse_amount > 1000.0 {
572 let min_amount = amount.min(*reverse_amount);
573 let mut all_entries = entry_ids.clone();
574 all_entries.extend(reverse_ids.iter());
575
576 violations.push(GaapViolation {
577 id: String::new(),
578 violation_type: GaapViolationType::RevenueInflation,
579 accounts: vec![from.clone(), to.clone()],
580 entry_ids: all_entries,
581 amount: min_amount,
582 description: format!(
583 "Circular flow detected between {} and {} involving revenue accounts",
584 from, to
585 ),
586 severity: GaapViolationSeverity::Critical,
587 remediation:
588 "Review entries for potential revenue inflation or wash transactions"
589 .to_string(),
590 });
591 }
592
593 checked.insert((from.clone(), to.clone()));
594 }
595 }
596
597 violations
598 }
599}
600
601impl GpuKernel for GaapViolationDetection {
602 fn metadata(&self) -> &KernelMetadata {
603 &self.metadata
604 }
605}
606
607#[derive(Debug, Clone)]
609pub struct GaapDetectionConfig {
610 pub suspense_amount_threshold: f64,
612 pub asset_expense_threshold: f64,
614 pub circular_flow_threshold: f64,
616}
617
618impl Default for GaapDetectionConfig {
619 fn default() -> Self {
620 Self {
621 suspense_amount_threshold: 10_000.0,
622 asset_expense_threshold: 5_000.0,
623 circular_flow_threshold: 1_000.0,
624 }
625 }
626}
627
628struct AccountGraph {
634 edges: HashMap<(String, String), f64>,
636 account_metrics: HashMap<String, AccountMetrics>,
638}
639
640impl AccountGraph {
641 fn new() -> Self {
642 Self {
643 edges: HashMap::new(),
644 account_metrics: HashMap::new(),
645 }
646 }
647
648 fn add_edge(&mut self, from: &str, to: &str, amount: f64, _timestamp: u64) {
649 *self
650 .edges
651 .entry((from.to_string(), to.to_string()))
652 .or_insert(0.0) += amount;
653
654 self.account_metrics
656 .entry(from.to_string())
657 .or_default()
658 .outgoing_counterparties
659 .insert(to.to_string());
660 self.account_metrics
661 .entry(to.to_string())
662 .or_default()
663 .incoming_counterparties
664 .insert(from.to_string());
665 }
666
667 fn update_account_metrics(&mut self, line: &JournalLine, timestamp: u64) {
668 let metrics = self
669 .account_metrics
670 .entry(line.account_code.clone())
671 .or_default();
672
673 metrics.account_name = line.description.clone();
674 metrics.total_debit += line.debit;
675 metrics.total_credit += line.credit;
676 metrics.transaction_count += 1;
677 metrics.last_activity = metrics.last_activity.max(timestamp);
678 if metrics.first_activity == 0 {
679 metrics.first_activity = timestamp;
680 } else {
681 metrics.first_activity = metrics.first_activity.min(timestamp);
682 }
683 }
684
685 fn calculate_betweenness_centrality(&mut self) {
686 let total_paths = self.edges.len() as f64;
688
689 if total_paths == 0.0 {
690 return;
691 }
692
693 for (account_code, metrics) in &mut self.account_metrics {
694 let paths_through: f64 = self
696 .edges
697 .keys()
698 .filter(|(from, to)| from == account_code || to == account_code)
699 .count() as f64;
700
701 metrics.betweenness_centrality = paths_through / total_paths;
702 }
703
704 for metrics in self.account_metrics.values_mut() {
706 metrics.counterparty_count = metrics
707 .incoming_counterparties
708 .len()
709 .max(metrics.outgoing_counterparties.len());
710
711 let total = metrics.total_debit + metrics.total_credit;
713 if total > 0.0 {
714 let diff = (metrics.total_debit - metrics.total_credit).abs();
715 metrics.balance_ratio = 1.0 - (diff / total);
716 }
717
718 if metrics.first_activity > 0 && metrics.last_activity > metrics.first_activity {
720 let days = (metrics.last_activity - metrics.first_activity) as f64 / 86400.0;
721 metrics.avg_holding_days = days / metrics.transaction_count.max(1) as f64;
722 }
723
724 metrics.end_balance = metrics.total_debit - metrics.total_credit;
726 }
727 }
728}
729
730#[derive(Debug, Clone, Default)]
732struct AccountMetrics {
733 account_name: String,
734 total_debit: f64,
735 total_credit: f64,
736 end_balance: f64,
737 transaction_count: usize,
738 betweenness_centrality: f64,
739 balance_ratio: f64,
740 avg_holding_days: f64,
741 counterparty_count: usize,
742 first_activity: u64,
743 last_activity: u64,
744 incoming_counterparties: HashSet<String>,
745 outgoing_counterparties: HashSet<String>,
746}
747
748#[cfg(test)]
749mod tests {
750 use super::*;
751 use crate::types::JournalStatus;
752
753 fn create_test_entry(
754 id: u64,
755 debit_account: &str,
756 credit_account: &str,
757 amount: f64,
758 ) -> JournalEntry {
759 JournalEntry {
760 id,
761 date: 1700000000,
762 posting_date: 1700000000,
763 document_number: format!("DOC{}", id),
764 lines: vec![
765 JournalLine {
766 line_number: 1,
767 account_code: debit_account.to_string(),
768 debit: amount,
769 credit: 0.0,
770 currency: "USD".to_string(),
771 entity_id: "CORP".to_string(),
772 cost_center: None,
773 description: debit_account.to_string(),
774 },
775 JournalLine {
776 line_number: 2,
777 account_code: credit_account.to_string(),
778 debit: 0.0,
779 credit: amount,
780 currency: "USD".to_string(),
781 entity_id: "CORP".to_string(),
782 cost_center: None,
783 description: credit_account.to_string(),
784 },
785 ],
786 status: JournalStatus::Posted,
787 source_system: "TEST".to_string(),
788 description: "Test entry".to_string(),
789 }
790 }
791
792 #[test]
793 fn test_suspense_detection_metadata() {
794 let kernel = SuspenseAccountDetection::new();
795 assert_eq!(kernel.metadata().id, "accounting/suspense-detection");
796 assert_eq!(kernel.metadata().domain, Domain::Accounting);
797 }
798
799 #[test]
800 fn test_gaap_violation_metadata() {
801 let kernel = GaapViolationDetection::new();
802 assert_eq!(kernel.metadata().id, "accounting/gaap-violation");
803 assert_eq!(kernel.metadata().domain, Domain::Accounting);
804 }
805
806 #[test]
807 fn test_suspense_detection_empty() {
808 let entries: Vec<JournalEntry> = vec![];
809 let config = SuspenseDetectionConfig::default();
810 let result = SuspenseAccountDetection::detect(&entries, &config);
811
812 assert!(result.candidates.is_empty());
813 assert_eq!(result.accounts_analyzed, 0);
814 }
815
816 #[test]
817 fn test_suspense_detection_naming() {
818 let entries = vec![
819 create_test_entry(1, "EXPENSE", "SUSPENSE_CLEARING", 5000.0),
820 create_test_entry(2, "CASH", "SUSPENSE_CLEARING", 3000.0),
821 create_test_entry(3, "SUSPENSE_CLEARING", "PAYABLES", 4000.0),
822 create_test_entry(4, "SUSPENSE_CLEARING", "RECEIVABLES", 4000.0),
823 ];
824
825 let config = SuspenseDetectionConfig::default();
826 let result = SuspenseAccountDetection::detect(&entries, &config);
827
828 let suspense_candidate = result
830 .candidates
831 .iter()
832 .find(|c| c.account_code == "SUSPENSE_CLEARING");
833 assert!(suspense_candidate.is_some());
834
835 let candidate = suspense_candidate.unwrap();
836 assert!(
837 candidate
838 .indicators
839 .contains(&SuspenseIndicator::SuspenseNaming)
840 );
841 }
842
843 #[test]
844 fn test_gaap_violation_empty() {
845 let entries: Vec<JournalEntry> = vec![];
846 let account_types = HashMap::new();
847 let config = GaapDetectionConfig::default();
848 let result = GaapViolationDetection::detect(&entries, &account_types, &config);
849
850 assert!(result.violations.is_empty());
851 assert_eq!(result.compliance_score, 100.0);
852 }
853
854 #[test]
855 fn test_gaap_direct_revenue_expense() {
856 let entries = vec![create_test_entry(
857 1,
858 "SALARIES_EXPENSE",
859 "SALES_REVENUE",
860 10000.0,
861 )];
862
863 let mut account_types = HashMap::new();
864 account_types.insert("SALES_REVENUE".to_string(), AccountType::Revenue);
865 account_types.insert("SALARIES_EXPENSE".to_string(), AccountType::Expense);
866
867 let config = GaapDetectionConfig::default();
868 let result = GaapViolationDetection::detect(&entries, &account_types, &config);
869
870 assert!(!result.violations.is_empty());
871
872 let rev_exp_violation = result
873 .violations
874 .iter()
875 .find(|v| v.violation_type == GaapViolationType::DirectRevenueExpense);
876 assert!(rev_exp_violation.is_some());
877 }
878
879 #[test]
880 fn test_gaap_suspense_misuse() {
881 let entries = vec![create_test_entry(1, "EXPENSE", "SUSPENSE_ACCOUNT", 50000.0)];
882
883 let account_types = HashMap::new();
884 let config = GaapDetectionConfig {
885 suspense_amount_threshold: 10_000.0,
886 ..Default::default()
887 };
888
889 let result = GaapViolationDetection::detect(&entries, &account_types, &config);
890
891 let suspense_violation = result
892 .violations
893 .iter()
894 .find(|v| v.violation_type == GaapViolationType::SuspenseAccountMisuse);
895 assert!(suspense_violation.is_some());
896 }
897
898 #[test]
899 fn test_gaap_circular_flow() {
900 let entries = vec![
902 create_test_entry(1, "ACCOUNT_B", "SALES_REVENUE", 5000.0),
903 create_test_entry(2, "SALES_REVENUE", "ACCOUNT_B", 5000.0),
904 ];
905
906 let mut account_types = HashMap::new();
907 account_types.insert("SALES_REVENUE".to_string(), AccountType::Revenue);
908 account_types.insert("ACCOUNT_B".to_string(), AccountType::Asset);
909
910 let config = GaapDetectionConfig::default();
911 let result = GaapViolationDetection::detect(&entries, &account_types, &config);
912
913 let circular_violation = result
914 .violations
915 .iter()
916 .find(|v| v.violation_type == GaapViolationType::RevenueInflation);
917 assert!(circular_violation.is_some());
918 }
919
920 #[test]
921 fn test_gaap_improper_asset_expense() {
922 let entries = vec![create_test_entry(
923 1,
924 "OFFICE_EXPENSE",
925 "EQUIPMENT_ASSET",
926 15000.0,
927 )];
928
929 let mut account_types = HashMap::new();
930 account_types.insert("EQUIPMENT_ASSET".to_string(), AccountType::Asset);
931 account_types.insert("OFFICE_EXPENSE".to_string(), AccountType::Expense);
932
933 let config = GaapDetectionConfig::default();
934 let result = GaapViolationDetection::detect(&entries, &account_types, &config);
935
936 let asset_exp_violation = result
937 .violations
938 .iter()
939 .find(|v| v.violation_type == GaapViolationType::ImproperAssetExpense);
940 assert!(asset_exp_violation.is_some());
941 }
942
943 #[test]
944 fn test_compliance_score() {
945 let entries = vec![create_test_entry(1, "CASH", "RECEIVABLES", 1000.0)];
947
948 let account_types = HashMap::new();
949 let config = GaapDetectionConfig::default();
950 let result = GaapViolationDetection::detect(&entries, &account_types, &config);
951
952 assert!(result.compliance_score >= 90.0);
953 }
954}