Skip to main content

datasynth_banking/typologies/
layering.rs

1//! Layering chain typology implementation.
2
3use chrono::{DateTime, NaiveDate, Utc};
4use datasynth_core::models::banking::{
5    AmlTypology, Direction, LaunderingStage, Sophistication, TransactionCategory,
6    TransactionChannel,
7};
8use datasynth_core::DeterministicUuidFactory;
9use rand::prelude::*;
10use rand_chacha::ChaCha8Rng;
11use rust_decimal::Decimal;
12
13use crate::models::{BankAccount, BankTransaction, BankingCustomer, CounterpartyRef};
14
15/// Layering chain pattern injector.
16///
17/// Layering involves:
18/// - Multi-hop transfers to obscure fund trail
19/// - Amount slicing to create complexity
20/// - Time jitter between hops
21/// - Cover traffic insertion for camouflage
22pub struct LayeringInjector {
23    rng: ChaCha8Rng,
24    uuid_factory: DeterministicUuidFactory,
25}
26
27impl LayeringInjector {
28    /// Create a new layering injector.
29    pub fn new(seed: u64) -> Self {
30        Self {
31            rng: ChaCha8Rng::seed_from_u64(seed.wrapping_add(6200)),
32            uuid_factory: DeterministicUuidFactory::new(
33                seed,
34                datasynth_core::GeneratorType::Anomaly,
35            ),
36        }
37    }
38
39    /// Generate layering chain transactions.
40    pub fn generate(
41        &mut self,
42        _customer: &BankingCustomer,
43        account: &BankAccount,
44        start_date: NaiveDate,
45        end_date: NaiveDate,
46        sophistication: Sophistication,
47    ) -> Vec<BankTransaction> {
48        let mut transactions = Vec::new();
49
50        // Layering parameters based on sophistication
51        let (num_layers, total_amount, jitter_range) = match sophistication {
52            Sophistication::Basic => (2..4, 15_000.0..50_000.0, 1..3),
53            Sophistication::Standard => (3..5, 30_000.0..100_000.0, 2..5),
54            Sophistication::Professional => (4..7, 75_000.0..300_000.0, 3..10),
55            Sophistication::Advanced => (5..10, 150_000.0..750_000.0, 5..20),
56            Sophistication::StateLevel => (8..15, 500_000.0..3_000_000.0, 10..45),
57        };
58
59        let layers = self.rng.gen_range(num_layers);
60        let total: f64 = self.rng.gen_range(total_amount);
61        let scenario_id = format!("LAY-{:06}", self.rng.gen::<u32>());
62
63        let available_days = (end_date - start_date).num_days().max(1);
64
65        // Initial placement
66        let placement_date = start_date;
67        let placement_timestamp = self.random_timestamp(placement_date);
68
69        let placement_txn = BankTransaction::new(
70            self.uuid_factory.next(),
71            account.account_id,
72            Decimal::from_f64_retain(total).unwrap_or(Decimal::ZERO),
73            &account.currency,
74            Direction::Inbound,
75            TransactionChannel::Wire,
76            TransactionCategory::TransferIn,
77            CounterpartyRef::business("Initial Source LLC"),
78            "Initial transfer",
79            placement_timestamp,
80        )
81        .mark_suspicious(AmlTypology::Layering, &scenario_id)
82        .with_laundering_stage(LaunderingStage::Placement)
83        .with_scenario(&scenario_id, 0);
84
85        transactions.push(placement_txn);
86
87        // Generate layering hops
88        let mut current_amount = total;
89        let mut current_date = placement_date;
90        let mut seq = 1u32;
91
92        for layer in 0..layers {
93            // Time jitter between layers
94            let jitter = self.rng.gen_range(jitter_range.clone()) as i64;
95            current_date += chrono::Duration::days(jitter);
96
97            if current_date > end_date {
98                current_date = end_date;
99            }
100
101            // Slice amount for complexity (for professional+ sophistication)
102            let num_slices = if matches!(
103                sophistication,
104                Sophistication::Professional
105                    | Sophistication::Advanced
106                    | Sophistication::StateLevel
107            ) {
108                self.rng.gen_range(2..4)
109            } else {
110                1
111            };
112
113            let mut remaining = current_amount;
114
115            for slice in 0..num_slices {
116                let slice_amount = if slice == num_slices - 1 {
117                    remaining * 0.98 // Small "fee" deduction
118                } else {
119                    let portion = remaining / ((num_slices - slice) as f64);
120                    let variance = portion * 0.2;
121                    self.rng
122                        .gen_range((portion - variance)..(portion + variance))
123                };
124                remaining -= slice_amount;
125
126                // Outbound transfer
127                let out_timestamp = self.random_timestamp(current_date);
128                let (out_channel, counterparty_name) = self.random_layer_destination(layer);
129
130                let out_txn = BankTransaction::new(
131                    self.uuid_factory.next(),
132                    account.account_id,
133                    Decimal::from_f64_retain(slice_amount).unwrap_or(Decimal::ZERO),
134                    &account.currency,
135                    Direction::Outbound,
136                    out_channel,
137                    TransactionCategory::TransferOut,
138                    CounterpartyRef::business(&counterparty_name),
139                    &format!("Layer {} transfer {}", layer + 1, slice + 1),
140                    out_timestamp,
141                )
142                .mark_suspicious(AmlTypology::Layering, &scenario_id)
143                .with_laundering_stage(LaunderingStage::Layering)
144                .with_scenario(&scenario_id, seq);
145
146                transactions.push(out_txn);
147                seq += 1;
148
149                // Corresponding inbound (simulating round-trip or return)
150                if layer < layers - 1 && self.rng.gen::<f64>() < 0.6 {
151                    let return_jitter = self.rng.gen_range(1..3) as i64;
152                    let return_date = current_date + chrono::Duration::days(return_jitter);
153                    let return_timestamp = self.random_timestamp(return_date);
154
155                    let return_amount = slice_amount * 0.97; // More fees
156
157                    let in_txn = BankTransaction::new(
158                        self.uuid_factory.next(),
159                        account.account_id,
160                        Decimal::from_f64_retain(return_amount).unwrap_or(Decimal::ZERO),
161                        &account.currency,
162                        Direction::Inbound,
163                        TransactionChannel::Wire,
164                        TransactionCategory::TransferIn,
165                        CounterpartyRef::business(&format!("Intermediary {} Holdings", layer + 1)),
166                        &format!("Return transfer layer {}", layer + 1),
167                        return_timestamp,
168                    )
169                    .mark_suspicious(AmlTypology::Layering, &scenario_id)
170                    .with_laundering_stage(LaunderingStage::Layering)
171                    .with_scenario(&scenario_id, seq);
172
173                    transactions.push(in_txn);
174                    seq += 1;
175                    current_amount = return_amount;
176                }
177            }
178        }
179
180        // Insert cover traffic for professional+ sophistication
181        if matches!(
182            sophistication,
183            Sophistication::Professional | Sophistication::Advanced | Sophistication::StateLevel
184        ) {
185            let cover_count = match sophistication {
186                Sophistication::Professional => 2..5,
187                Sophistication::Advanced => 4..8,
188                Sophistication::StateLevel => 6..12,
189                _ => 1..2,
190            };
191
192            for _ in 0..self.rng.gen_range(cover_count) {
193                let cover_day = self.rng.gen_range(0..available_days);
194                let cover_date = start_date + chrono::Duration::days(cover_day);
195                let cover_timestamp = self.random_timestamp(cover_date);
196
197                // Cover traffic - legitimate-looking transactions
198                let cover_amount = self.rng.gen_range(100.0..5000.0);
199                let direction = if self.rng.gen::<bool>() {
200                    Direction::Inbound
201                } else {
202                    Direction::Outbound
203                };
204
205                let cover_txn = BankTransaction::new(
206                    self.uuid_factory.next(),
207                    account.account_id,
208                    Decimal::from_f64_retain(cover_amount).unwrap_or(Decimal::ZERO),
209                    &account.currency,
210                    direction,
211                    TransactionChannel::CardPresent,
212                    TransactionCategory::Shopping,
213                    CounterpartyRef::merchant_by_name("Regular Merchant", "5411"),
214                    "Regular purchase",
215                    cover_timestamp,
216                )
217                .mark_suspicious(AmlTypology::Layering, &scenario_id)
218                .with_laundering_stage(LaunderingStage::Layering)
219                .with_scenario(&scenario_id, seq);
220
221                transactions.push(cover_txn);
222                seq += 1;
223            }
224        }
225
226        // Apply spoofing for sophisticated patterns
227        if matches!(
228            sophistication,
229            Sophistication::Professional | Sophistication::Advanced | Sophistication::StateLevel
230        ) {
231            for txn in &mut transactions {
232                txn.is_spoofed = true;
233                txn.spoofing_intensity = Some(sophistication.spoofing_intensity());
234            }
235        }
236
237        transactions
238    }
239
240    /// Generate random layer destination.
241    fn random_layer_destination(&mut self, layer: usize) -> (TransactionChannel, String) {
242        let destinations = [
243            (
244                TransactionChannel::Wire,
245                format!("Offshore Holdings {}", layer + 1),
246            ),
247            (
248                TransactionChannel::Ach,
249                format!("Investment Co {}", layer + 1),
250            ),
251            (
252                TransactionChannel::Swift,
253                format!("Trade Finance {} Ltd", layer + 1),
254            ),
255            (
256                TransactionChannel::Wire,
257                format!("Consulting {} LLC", layer + 1),
258            ),
259        ];
260
261        let idx = self.rng.gen_range(0..destinations.len());
262        destinations[idx].clone()
263    }
264
265    /// Generate random timestamp for a date.
266    fn random_timestamp(&mut self, date: NaiveDate) -> DateTime<Utc> {
267        let hour: u32 = self.rng.gen_range(6..22);
268        let minute: u32 = self.rng.gen_range(0..60);
269        let second: u32 = self.rng.gen_range(0..60);
270
271        date.and_hms_opt(hour, minute, second)
272            .map(|dt| DateTime::<Utc>::from_naive_utc_and_offset(dt, Utc))
273            .unwrap_or_else(Utc::now)
274    }
275}
276
277#[cfg(test)]
278mod tests {
279    use super::*;
280    use uuid::Uuid;
281
282    #[test]
283    fn test_layering_generation() {
284        let mut injector = LayeringInjector::new(12345);
285
286        let customer = BankingCustomer::new_retail(
287            Uuid::new_v4(),
288            "Test",
289            "User",
290            "US",
291            NaiveDate::from_ymd_opt(2024, 1, 1).unwrap(),
292        );
293
294        let account = BankAccount::new(
295            Uuid::new_v4(),
296            "****1234".to_string(),
297            datasynth_core::models::banking::BankAccountType::Checking,
298            customer.customer_id,
299            "USD",
300            NaiveDate::from_ymd_opt(2024, 1, 1).unwrap(),
301        );
302
303        let start = NaiveDate::from_ymd_opt(2024, 1, 1).unwrap();
304        let end = NaiveDate::from_ymd_opt(2024, 3, 31).unwrap();
305
306        let transactions = injector.generate(
307            &customer,
308            &account,
309            start,
310            end,
311            Sophistication::Professional,
312        );
313
314        assert!(!transactions.is_empty());
315
316        // Should have multiple layering transactions
317        assert!(transactions.len() >= 3);
318
319        // All should be marked as layering
320        for txn in &transactions {
321            assert!(txn.is_suspicious);
322            assert_eq!(txn.suspicion_reason, Some(AmlTypology::Layering));
323        }
324    }
325}