rustkernel_accounting/
detection.rs

1//! Account detection kernels for suspense accounts and GAAP violations.
2//!
3//! This module provides detection algorithms for:
4//! - Suspense account identification using centrality analysis
5//! - GAAP violation detection for prohibited transaction patterns
6
7use 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// ============================================================================
16// Suspense Account Detection Kernel
17// ============================================================================
18
19/// Suspense account detection kernel.
20///
21/// Detects accounts that exhibit suspense account characteristics using
22/// centrality-based analysis on the account transaction graph.
23#[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    /// Create a new suspense account detection kernel.
36    #[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    /// Detect suspense accounts from journal entries.
47    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        // Build account graph from journal entries
61        let account_graph = Self::build_account_graph(entries);
62
63        // Calculate metrics for each account
64        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        // Sort by suspense score descending
101        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    /// Build account graph from journal entries.
125    fn build_account_graph(entries: &[JournalEntry]) -> AccountGraph {
126        let mut graph = AccountGraph::new();
127
128        for entry in entries {
129            // Extract debit and credit accounts
130            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            // Create edges between debit and credit accounts
134            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            // Update account metrics
147            for line in &entry.lines {
148                graph.update_account_metrics(line, entry.posting_date);
149            }
150        }
151
152        // Calculate centrality
153        graph.calculate_betweenness_centrality();
154
155        graph
156    }
157
158    /// Check which suspense indicators apply to an account.
159    fn check_indicators(
160        metrics: &AccountMetrics,
161        config: &SuspenseDetectionConfig,
162    ) -> Vec<SuspenseIndicator> {
163        let mut indicators = Vec::new();
164
165        // Check centrality
166        if metrics.betweenness_centrality >= config.centrality_threshold {
167            indicators.push(SuspenseIndicator::HighCentrality);
168        }
169
170        // Check turnover ratio
171        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        // Check holding period
178        if metrics.avg_holding_days <= config.holding_period_threshold {
179            indicators.push(SuspenseIndicator::ShortHoldingPeriod);
180        }
181
182        // Check balance ratio (debit/credit balance)
183        if metrics.balance_ratio >= config.balance_ratio_threshold {
184            indicators.push(SuspenseIndicator::BalancedFlows);
185        }
186
187        // Check counterparty count
188        if metrics.counterparty_count >= config.counterparty_threshold {
189            indicators.push(SuspenseIndicator::ManyCounterparties);
190        }
191
192        // Check for zero end balance
193        if metrics.end_balance.abs() < config.zero_balance_threshold {
194            indicators.push(SuspenseIndicator::ZeroEndBalance);
195        }
196
197        // Check naming
198        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    /// Calculate suspense score from indicators.
212    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        // Bonus for high centrality combined with other factors
228        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    /// Determine risk level from score and indicators.
236    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/// Configuration for suspense account detection.
260#[derive(Debug, Clone)]
261pub struct SuspenseDetectionConfig {
262    /// Minimum betweenness centrality to flag.
263    pub centrality_threshold: f64,
264    /// Minimum turnover ratio (turnover/balance).
265    pub turnover_ratio_threshold: f64,
266    /// Maximum average holding period (days).
267    pub holding_period_threshold: f64,
268    /// Minimum balance ratio to consider balanced (0-1).
269    pub balance_ratio_threshold: f64,
270    /// Minimum counterparty count to flag.
271    pub counterparty_threshold: usize,
272    /// Maximum balance to consider "zero".
273    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// ============================================================================
290// GAAP Violation Detection Kernel
291// ============================================================================
292
293/// GAAP violation detection kernel.
294///
295/// Detects prohibited transaction patterns that violate GAAP principles.
296#[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    /// Create a new GAAP violation detection kernel.
309    #[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    /// Detect GAAP violations from journal entries.
320    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        // Check each entry for violations
339        for entry in entries {
340            // Check direct revenue-to-expense transfers
341            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            // Check improper asset-to-expense
349            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            // Check suspense account misuse
357            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        // Check for circular flows across entries
366        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        // Calculate metrics
374        let amount_at_risk: f64 = violations.iter().map(|v| v.amount).sum();
375        let entries_analyzed = entries.len();
376
377        // Calculate violation counts by type
378        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        // Calculate compliance score
385        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    /// Check for direct revenue-to-expense transfers.
408    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                // Revenue credited directly to expense
421                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    /// Check for improper asset-to-expense (without depreciation).
447    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                // Asset credited directly to expense without depreciation
460                if matches!(credit_type, Some(AccountType::Asset))
461                    && matches!(debit_type, Some(AccountType::Expense))
462                {
463                    // Check if it's a large amount (potential capitalization issue)
464                    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    /// Check for suspense account misuse.
490    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    /// Check for circular flows that may inflate revenue.
528    fn check_circular_flows(
529        entries: &[JournalEntry],
530        account_types: &HashMap<String, AccountType>,
531    ) -> Vec<GaapViolation> {
532        let mut violations = Vec::new();
533
534        // Build flow graph
535        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        // Find circular patterns (A -> B and B -> A)
554        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                // Check if one is revenue and this creates inflation
565                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/// Configuration for GAAP violation detection.
608#[derive(Debug, Clone)]
609pub struct GaapDetectionConfig {
610    /// Threshold for suspense account amounts.
611    pub suspense_amount_threshold: f64,
612    /// Minimum amount for asset-to-expense flag.
613    pub asset_expense_threshold: f64,
614    /// Minimum circular flow amount.
615    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
628// ============================================================================
629// Internal Types
630// ============================================================================
631
632/// Account graph for suspense detection.
633struct AccountGraph {
634    /// Edges: (from, to) -> total amount
635    edges: HashMap<(String, String), f64>,
636    /// Account metrics.
637    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        // Update counterparty counts
655        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        // Simplified betweenness: ratio of paths going through account
687        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            // Count paths through this account
695            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        // Finalize other metrics
705        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            // Calculate balance ratio
712            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            // Calculate average holding period
719            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            // Calculate end balance
725            metrics.end_balance = metrics.total_debit - metrics.total_credit;
726        }
727    }
728}
729
730/// Metrics for an account.
731#[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        // Should detect suspense_clearing as candidate due to naming
829        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        // A -> B and B -> A with revenue
901        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        // Entry with no violations
946        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}