1use super::{AccountType, Decimal128, HybridTimestamp};
7use rkyv::{Archive, Deserialize, Serialize};
8use uuid::Uuid;
9
10#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Archive, Serialize, Deserialize)]
12#[archive(compare(PartialEq))]
13#[repr(u8)]
14pub enum FraudPatternType {
15 CircularFlow = 0,
17
18 SelfLoop = 1,
20
21 BenfordViolation = 2,
23
24 ThresholdClustering = 3,
26
27 AfterHoursEntry = 4,
29
30 HighVelocity = 5,
32
33 UnusualPairing = 6,
35
36 DormantActivation = 7,
38
39 RoundAmounts = 8,
41
42 DuplicateTransaction = 9,
44
45 StructuredTransactions = 10,
47
48 ReversalAnomaly = 11,
50}
51
52impl FraudPatternType {
53 pub fn risk_weight(&self) -> f32 {
55 match self {
56 FraudPatternType::CircularFlow => 0.95,
57 FraudPatternType::HighVelocity => 0.90,
58 FraudPatternType::ThresholdClustering => 0.85,
59 FraudPatternType::StructuredTransactions => 0.85,
60 FraudPatternType::DormantActivation => 0.80,
61 FraudPatternType::UnusualPairing => 0.75,
62 FraudPatternType::BenfordViolation => 0.70,
63 FraudPatternType::AfterHoursEntry => 0.60,
64 FraudPatternType::RoundAmounts => 0.50,
65 FraudPatternType::SelfLoop => 0.65,
66 FraudPatternType::DuplicateTransaction => 0.55,
67 FraudPatternType::ReversalAnomaly => 0.60,
68 }
69 }
70
71 pub fn description(&self) -> &'static str {
73 match self {
74 FraudPatternType::CircularFlow => "Circular money flow detected (A→B→C→A)",
75 FraudPatternType::SelfLoop => "Bidirectional flow between accounts",
76 FraudPatternType::BenfordViolation => "Amount distribution violates Benford's Law",
77 FraudPatternType::ThresholdClustering => "Amounts clustered below approval threshold",
78 FraudPatternType::AfterHoursEntry => "Entry posted outside business hours",
79 FraudPatternType::HighVelocity => "Rapid multi-hop money movement",
80 FraudPatternType::UnusualPairing => "Implausible account combination",
81 FraudPatternType::DormantActivation => "Dormant account suddenly activated",
82 FraudPatternType::RoundAmounts => "Suspicious round-number amounts",
83 FraudPatternType::DuplicateTransaction => "Potential duplicate transaction",
84 FraudPatternType::StructuredTransactions => "Structured to avoid detection",
85 FraudPatternType::ReversalAnomaly => "Unusual reversal pattern",
86 }
87 }
88
89 pub fn icon(&self) -> &'static str {
91 match self {
92 FraudPatternType::CircularFlow => "🔄",
93 FraudPatternType::SelfLoop => "↔️",
94 FraudPatternType::BenfordViolation => "📊",
95 FraudPatternType::ThresholdClustering => "📍",
96 FraudPatternType::AfterHoursEntry => "🌙",
97 FraudPatternType::HighVelocity => "⚡",
98 FraudPatternType::UnusualPairing => "❓",
99 FraudPatternType::DormantActivation => "💤",
100 FraudPatternType::RoundAmounts => "🔢",
101 FraudPatternType::DuplicateTransaction => "📋",
102 FraudPatternType::StructuredTransactions => "✂️",
103 FraudPatternType::ReversalAnomaly => "↩️",
104 }
105 }
106}
107
108#[derive(Debug, Clone, Archive, Serialize, Deserialize)]
110#[repr(C)]
111pub struct FraudPattern {
112 pub id: Uuid,
114 pub pattern_type: FraudPatternType,
116 pub risk_score: f32,
118 pub amount: Decimal128,
120 pub account_count: u16,
122 pub transaction_count: u16,
124 pub timeframe_days: u16,
126 pub _pad: u16,
128 pub first_seen: HybridTimestamp,
130 pub last_seen: HybridTimestamp,
132 pub involved_accounts: [u16; 8],
134}
135
136impl FraudPattern {
137 pub fn new(pattern_type: FraudPatternType) -> Self {
139 Self {
140 id: Uuid::new_v4(),
141 pattern_type,
142 risk_score: pattern_type.risk_weight(),
143 amount: Decimal128::ZERO,
144 account_count: 0,
145 transaction_count: 0,
146 timeframe_days: 0,
147 _pad: 0,
148 first_seen: HybridTimestamp::zero(),
149 last_seen: HybridTimestamp::zero(),
150 involved_accounts: [u16::MAX; 8],
151 }
152 }
153
154 pub fn add_account(&mut self, account_index: u16) {
156 for i in 0..8 {
157 if self.involved_accounts[i] == u16::MAX {
158 self.involved_accounts[i] = account_index;
159 self.account_count += 1;
160 break;
161 }
162 }
163 }
164
165 pub fn get_involved_accounts(&self) -> Vec<u16> {
167 self.involved_accounts
168 .iter()
169 .filter(|&&idx| idx != u16::MAX)
170 .copied()
171 .collect()
172 }
173}
174
175#[derive(
177 Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Archive, Serialize, Deserialize,
178)]
179#[archive(compare(PartialEq))]
180#[repr(u8)]
181pub enum ViolationSeverity {
182 Low = 0,
184 Medium = 1,
186 High = 2,
188 Critical = 3,
190}
191
192impl ViolationSeverity {
193 pub fn color(&self) -> [u8; 3] {
195 match self {
196 ViolationSeverity::Low => [255, 235, 59], ViolationSeverity::Medium => [255, 152, 0], ViolationSeverity::High => [244, 67, 54], ViolationSeverity::Critical => [183, 28, 28], }
201 }
202}
203
204#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Archive, Serialize, Deserialize)]
206#[archive(compare(PartialEq))]
207#[repr(u8)]
208pub enum GaapViolationType {
209 RevenueToCashDirect = 0,
211
212 RevenueToExpense = 1,
214
215 CashToRevenue = 2,
217
218 ExpenseToAsset = 3,
220
221 LiabilityToRevenue = 4,
223
224 CogsWithoutInventory = 5,
226
227 AccumDepreciationIncrease = 6,
229
230 RetainedEarningsModification = 7,
232
233 IntercompanyImbalance = 8,
235
236 UnbalancedEntry = 9,
238}
239
240impl GaapViolationType {
241 pub fn default_severity(&self) -> ViolationSeverity {
243 match self {
244 GaapViolationType::RevenueToExpense => ViolationSeverity::Critical,
245 GaapViolationType::UnbalancedEntry => ViolationSeverity::Critical,
246 GaapViolationType::RetainedEarningsModification => ViolationSeverity::High,
247 GaapViolationType::AccumDepreciationIncrease => ViolationSeverity::High,
248 GaapViolationType::RevenueToCashDirect => ViolationSeverity::Medium,
249 GaapViolationType::CashToRevenue => ViolationSeverity::Medium,
250 GaapViolationType::LiabilityToRevenue => ViolationSeverity::High,
251 GaapViolationType::ExpenseToAsset => ViolationSeverity::Medium,
252 GaapViolationType::CogsWithoutInventory => ViolationSeverity::Medium,
253 GaapViolationType::IntercompanyImbalance => ViolationSeverity::Low,
254 }
255 }
256
257 pub fn description(&self) -> &'static str {
259 match self {
260 GaapViolationType::RevenueToCashDirect => "Revenue directly to Cash (bypass A/R)",
261 GaapViolationType::RevenueToExpense => {
262 "Revenue to Expense (accounting equation violation)"
263 }
264 GaapViolationType::CashToRevenue => "Cash to Revenue (backward flow)",
265 GaapViolationType::ExpenseToAsset => "Expense to Asset (improper capitalization)",
266 GaapViolationType::LiabilityToRevenue => "Liability to Revenue (misclassification)",
267 GaapViolationType::CogsWithoutInventory => "COGS without Inventory movement",
268 GaapViolationType::AccumDepreciationIncrease => "Direct Accum. Depreciation increase",
269 GaapViolationType::RetainedEarningsModification => {
270 "Direct Retained Earnings modification"
271 }
272 GaapViolationType::IntercompanyImbalance => "Intercompany accounts don't balance",
273 GaapViolationType::UnbalancedEntry => "Debits ≠ Credits",
274 }
275 }
276
277 pub fn matches(&self, source_type: AccountType, target_type: AccountType) -> bool {
279 match self {
280 GaapViolationType::RevenueToCashDirect => {
281 source_type == AccountType::Revenue && target_type == AccountType::Asset
282 }
283 GaapViolationType::RevenueToExpense => {
284 source_type == AccountType::Revenue && target_type == AccountType::Expense
285 }
286 GaapViolationType::CashToRevenue => {
287 source_type == AccountType::Asset && target_type == AccountType::Revenue
288 }
289 GaapViolationType::ExpenseToAsset => {
290 source_type == AccountType::Expense && target_type == AccountType::Asset
291 }
292 GaapViolationType::LiabilityToRevenue => {
293 source_type == AccountType::Liability && target_type == AccountType::Revenue
294 }
295 _ => false, }
297 }
298}
299
300#[derive(Debug, Clone, Archive, Serialize, Deserialize)]
302#[repr(C)]
303pub struct GaapViolationRule {
304 pub rule_id: u32,
306 pub violation_type: GaapViolationType,
308 pub source_type: Option<AccountType>,
310 pub target_type: Option<AccountType>,
312 pub severity: ViolationSeverity,
314 pub min_amount: f64,
316 pub rule_name_hash: u64,
318}
319
320#[derive(Debug, Clone)]
322pub struct GaapViolation {
323 pub id: Uuid,
325 pub violation_type: GaapViolationType,
327 pub severity: ViolationSeverity,
329 pub source_account: u16,
331 pub target_account: u16,
333 pub amount: Decimal128,
335 pub journal_entry_id: Uuid,
337 pub detected_at: HybridTimestamp,
339 pub description: String,
341}
342
343impl GaapViolation {
344 pub fn new(
346 violation_type: GaapViolationType,
347 source: u16,
348 target: u16,
349 amount: Decimal128,
350 journal_entry_id: Uuid,
351 ) -> Self {
352 Self {
353 id: Uuid::new_v4(),
354 violation_type,
355 severity: violation_type.default_severity(),
356 source_account: source,
357 target_account: target,
358 amount,
359 journal_entry_id,
360 detected_at: HybridTimestamp::now(),
361 description: violation_type.description().to_string(),
362 }
363 }
364}
365
366pub const BENFORD_EXPECTED: [f64; 9] = [
368 0.301, 0.176, 0.125, 0.097, 0.079, 0.067, 0.058, 0.051, 0.046, ];
378
379pub fn benford_chi_squared(observed_counts: &[u32; 9], total: u32) -> f64 {
381 if total == 0 {
382 return 0.0;
383 }
384
385 let mut chi_sq = 0.0;
386 for i in 0..9 {
387 let expected = BENFORD_EXPECTED[i] * total as f64;
388 let observed = observed_counts[i] as f64;
389 if expected > 0.0 {
390 chi_sq += (observed - expected).powi(2) / expected;
391 }
392 }
393 chi_sq
394}
395
396pub const BENFORD_CHI_SQ_CRITICAL: f64 = 15.507;
398
399pub fn is_benford_violation(chi_squared: f64) -> bool {
401 chi_squared > BENFORD_CHI_SQ_CRITICAL
402}
403
404#[cfg(test)]
405mod tests {
406 use super::*;
407
408 #[test]
409 fn test_benford_perfect_distribution() {
410 let observed = [301, 176, 125, 97, 79, 67, 58, 51, 46];
412 let chi_sq = benford_chi_squared(&observed, 1000);
413 assert!(chi_sq < BENFORD_CHI_SQ_CRITICAL);
414 }
415
416 #[test]
417 fn test_benford_uniform_violation() {
418 let observed = [111, 111, 111, 111, 111, 111, 111, 111, 112];
420 let chi_sq = benford_chi_squared(&observed, 1000);
421 assert!(is_benford_violation(chi_sq));
422 }
423
424 #[test]
425 fn test_gaap_violation_matching() {
426 assert!(
427 GaapViolationType::RevenueToExpense.matches(AccountType::Revenue, AccountType::Expense)
428 );
429 assert!(
430 !GaapViolationType::RevenueToExpense.matches(AccountType::Asset, AccountType::Expense)
431 );
432 }
433
434 #[test]
435 fn test_fraud_pattern_accounts() {
436 let mut pattern = FraudPattern::new(FraudPatternType::CircularFlow);
437 pattern.add_account(0);
438 pattern.add_account(1);
439 pattern.add_account(2);
440
441 let accounts = pattern.get_involved_accounts();
442 assert_eq!(accounts, vec![0, 1, 2]);
443 }
444}