ringkernel_txmon/factory/
patterns.rs

1//! Transaction generation patterns.
2
3use crate::types::{CustomerRiskProfile, Transaction, TransactionType};
4use rand::prelude::*;
5
6/// Predefined transaction patterns for testing different scenarios.
7#[derive(Debug, Clone, Copy, PartialEq, Eq)]
8pub enum TransactionPattern {
9    /// Normal retail transaction - small amounts, local destinations.
10    NormalRetail,
11    /// High-value wire transfer - legitimate large transfer.
12    HighValueWire,
13    /// Velocity burst - many small transactions in quick succession.
14    VelocityBurst,
15    /// Structured transaction - just under reporting threshold (smurfing).
16    Smurfing,
17    /// Geographic anomaly - unusual destination country.
18    GeographicAnomaly,
19    /// Round-trip - money going out and coming back.
20    RoundTrip,
21}
22
23impl TransactionPattern {
24    /// Generate a transaction matching this pattern.
25    pub fn generate(
26        &self,
27        transaction_id: u64,
28        customer: &CustomerRiskProfile,
29        timestamp: u64,
30        rng: &mut impl Rng,
31    ) -> Transaction {
32        let mut tx = Transaction::new(
33            transaction_id,
34            customer.customer_id,
35            0, // Amount set below
36            customer.country_code,
37            timestamp,
38        );
39
40        match self {
41            TransactionPattern::NormalRetail => {
42                // Small retail transaction: $10 - $500
43                tx.amount_cents = rng.gen_range(1000..50_000);
44                tx.tx_type = TransactionType::Card as u8;
45                tx.country_code = customer.country_code; // Local
46            }
47
48            TransactionPattern::HighValueWire => {
49                // Large legitimate wire: $15,000 - $100,000
50                tx.amount_cents = rng.gen_range(1_500_000..10_000_000);
51                tx.tx_type = TransactionType::Wire as u8;
52                // Mix of domestic and international
53                if rng.gen::<f32>() < 0.3 {
54                    tx.country_code = rng.gen_range(1..16);
55                }
56            }
57
58            TransactionPattern::VelocityBurst => {
59                // Small amounts, meant to be sent in quick succession
60                tx.amount_cents = rng.gen_range(5000..50_000);
61                tx.tx_type = TransactionType::ACH as u8;
62                tx.country_code = customer.country_code;
63            }
64
65            TransactionPattern::Smurfing => {
66                // Just under $10,000 threshold (90-99% of threshold)
67                let threshold = 1_000_000u64; // $10,000 in cents
68                let percentage = rng.gen_range(90..99) as u64;
69                tx.amount_cents = (threshold * percentage) / 100;
70                tx.tx_type = TransactionType::Cash as u8;
71                tx.country_code = customer.country_code;
72            }
73
74            TransactionPattern::GeographicAnomaly => {
75                // Transaction to unusual/high-risk country
76                tx.amount_cents = rng.gen_range(100_000..2_000_000);
77                tx.tx_type = TransactionType::Wire as u8;
78                // Pick a country different from customer's home country
79                loop {
80                    tx.country_code = rng.gen_range(1..16);
81                    if tx.country_code != customer.country_code {
82                        break;
83                    }
84                }
85                // Prefer "high-risk" countries (using 7=RU, 6=CN as examples)
86                if rng.gen::<f32>() < 0.5 {
87                    tx.country_code = if rng.gen::<bool>() { 7 } else { 6 };
88                }
89            }
90
91            TransactionPattern::RoundTrip => {
92                // Round-trip transaction (money comes back)
93                tx.amount_cents = rng.gen_range(500_000..5_000_000);
94                tx.tx_type = TransactionType::Internal as u8;
95                tx.destination_id = customer.customer_id; // Same customer
96                tx.flags |= 0x01; // Mark as round-trip
97            }
98        }
99
100        tx
101    }
102
103    /// Get a random normal pattern (weighted distribution).
104    /// These patterns should NOT trigger alerts under normal circumstances.
105    pub fn random_normal(rng: &mut impl Rng) -> Self {
106        // Normal patterns are 100% retail - no high-value wires or velocity bursts
107        // that could accidentally trigger alerts
108        let roll: f32 = rng.gen();
109        if roll < 0.95 {
110            TransactionPattern::NormalRetail
111        } else {
112            // Small percentage of slightly larger but still safe transactions
113            TransactionPattern::NormalRetail
114        }
115    }
116
117    /// Get a random suspicious pattern (weighted distribution).
118    pub fn random_suspicious(rng: &mut impl Rng) -> Self {
119        let roll: f32 = rng.gen();
120        if roll < 0.40 {
121            TransactionPattern::Smurfing
122        } else if roll < 0.70 {
123            TransactionPattern::GeographicAnomaly
124        } else if roll < 0.90 {
125            TransactionPattern::VelocityBurst
126        } else {
127            TransactionPattern::RoundTrip
128        }
129    }
130}
131
132impl std::fmt::Display for TransactionPattern {
133    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
134        match self {
135            TransactionPattern::NormalRetail => write!(f, "Normal Retail"),
136            TransactionPattern::HighValueWire => write!(f, "High Value Wire"),
137            TransactionPattern::VelocityBurst => write!(f, "Velocity Burst"),
138            TransactionPattern::Smurfing => write!(f, "Smurfing"),
139            TransactionPattern::GeographicAnomaly => write!(f, "Geographic Anomaly"),
140            TransactionPattern::RoundTrip => write!(f, "Round Trip"),
141        }
142    }
143}
144
145#[cfg(test)]
146mod tests {
147    use super::*;
148    use crate::factory::AccountGenerator;
149
150    #[test]
151    fn test_smurfing_under_threshold() {
152        let mut rng = SmallRng::seed_from_u64(42);
153        let mut acct_gen = AccountGenerator::new(42);
154        let customer = acct_gen.generate_customer();
155
156        for _ in 0..100 {
157            let tx = TransactionPattern::Smurfing.generate(1, &customer, 0, &mut rng);
158            // Should always be under $10,000 but above 90%
159            assert!(tx.amount_cents < 1_000_000);
160            assert!(tx.amount_cents >= 900_000);
161        }
162    }
163
164    #[test]
165    fn test_geographic_anomaly_different_country() {
166        let mut rng = SmallRng::seed_from_u64(42);
167        let mut acct_gen = AccountGenerator::new(42);
168        let customer = acct_gen.generate_customer();
169
170        for _ in 0..100 {
171            let tx = TransactionPattern::GeographicAnomaly.generate(1, &customer, 0, &mut rng);
172            assert_ne!(tx.country_code, customer.country_code);
173        }
174    }
175}