use chrono::{DateTime, NaiveDate, Utc};
use datasynth_core::models::banking::{
AmlTypology, Direction, LaunderingStage, Sophistication, TransactionCategory,
TransactionChannel,
};
use datasynth_core::DeterministicUuidFactory;
use rand::prelude::*;
use rand_chacha::ChaCha8Rng;
use rust_decimal::Decimal;
use crate::models::{BankAccount, BankTransaction, BankingCustomer, CounterpartyRef};
use crate::seed_offsets::LAYERING_INJECTOR_SEED_OFFSET;
pub struct LayeringInjector {
rng: ChaCha8Rng,
uuid_factory: DeterministicUuidFactory,
}
impl LayeringInjector {
pub fn new(seed: u64) -> Self {
Self {
rng: ChaCha8Rng::seed_from_u64(seed.wrapping_add(LAYERING_INJECTOR_SEED_OFFSET)),
uuid_factory: DeterministicUuidFactory::new(
seed,
datasynth_core::GeneratorType::Anomaly,
),
}
}
pub fn generate(
&mut self,
_customer: &BankingCustomer,
account: &BankAccount,
start_date: NaiveDate,
end_date: NaiveDate,
sophistication: Sophistication,
) -> Vec<BankTransaction> {
let mut transactions = Vec::new();
let (num_layers, total_amount, jitter_range) = match sophistication {
Sophistication::Basic => (2..4, 15_000.0..50_000.0, 1..3),
Sophistication::Standard => (3..5, 30_000.0..100_000.0, 2..5),
Sophistication::Professional => (4..7, 75_000.0..300_000.0, 3..10),
Sophistication::Advanced => (5..10, 150_000.0..750_000.0, 5..20),
Sophistication::StateLevel => (8..15, 500_000.0..3_000_000.0, 10..45),
};
let layers = self.rng.random_range(num_layers);
let total: f64 = self.rng.random_range(total_amount);
let scenario_id = format!("LAY-{:06}", self.rng.random::<u32>());
let available_days = (end_date - start_date).num_days().max(1);
let placement_date = start_date;
let placement_timestamp = self.random_timestamp(placement_date);
let placement_txn = BankTransaction::new(
self.uuid_factory.next(),
account.account_id,
Decimal::from_f64_retain(total).unwrap_or(Decimal::ZERO),
&account.currency,
Direction::Inbound,
TransactionChannel::Wire,
TransactionCategory::TransferIn,
CounterpartyRef::business("Initial Source LLC"),
"Initial transfer",
placement_timestamp,
)
.mark_suspicious(AmlTypology::Layering, &scenario_id)
.with_laundering_stage(LaunderingStage::Placement)
.with_scenario(&scenario_id, 0);
transactions.push(placement_txn);
let mut current_amount = total;
let mut current_date = placement_date;
let mut seq = 1u32;
for layer in 0..layers {
let jitter = self.rng.random_range(jitter_range.clone()) as i64;
current_date += chrono::Duration::days(jitter);
if current_date > end_date {
current_date = end_date;
}
let num_slices = if matches!(
sophistication,
Sophistication::Professional
| Sophistication::Advanced
| Sophistication::StateLevel
) {
self.rng.random_range(2..4)
} else {
1
};
let mut remaining = current_amount;
for slice in 0..num_slices {
let slice_amount = if slice == num_slices - 1 {
remaining * 0.98 } else {
let portion = remaining / ((num_slices - slice) as f64);
let variance = portion * 0.2;
self.rng
.random_range((portion - variance)..(portion + variance))
};
remaining -= slice_amount;
let out_timestamp = self.random_timestamp(current_date);
let (out_channel, counterparty_name) = self.random_layer_destination(layer);
let out_txn = BankTransaction::new(
self.uuid_factory.next(),
account.account_id,
Decimal::from_f64_retain(slice_amount).unwrap_or(Decimal::ZERO),
&account.currency,
Direction::Outbound,
out_channel,
TransactionCategory::TransferOut,
CounterpartyRef::business(&counterparty_name),
&format!("Layer {} transfer {}", layer + 1, slice + 1),
out_timestamp,
)
.mark_suspicious(AmlTypology::Layering, &scenario_id)
.with_laundering_stage(LaunderingStage::Layering)
.with_scenario(&scenario_id, seq);
transactions.push(out_txn);
seq += 1;
if layer < layers - 1 && self.rng.random::<f64>() < 0.6 {
let return_jitter = self.rng.random_range(1..3) as i64;
let return_date = current_date + chrono::Duration::days(return_jitter);
let return_timestamp = self.random_timestamp(return_date);
let return_amount = slice_amount * 0.97;
let in_txn = BankTransaction::new(
self.uuid_factory.next(),
account.account_id,
Decimal::from_f64_retain(return_amount).unwrap_or(Decimal::ZERO),
&account.currency,
Direction::Inbound,
TransactionChannel::Wire,
TransactionCategory::TransferIn,
CounterpartyRef::business(&format!("Intermediary {} Holdings", layer + 1)),
&format!("Return transfer layer {}", layer + 1),
return_timestamp,
)
.mark_suspicious(AmlTypology::Layering, &scenario_id)
.with_laundering_stage(LaunderingStage::Layering)
.with_scenario(&scenario_id, seq);
transactions.push(in_txn);
seq += 1;
current_amount = return_amount;
}
}
}
if matches!(
sophistication,
Sophistication::Professional | Sophistication::Advanced | Sophistication::StateLevel
) {
let cover_count = match sophistication {
Sophistication::Professional => 2..5,
Sophistication::Advanced => 4..8,
Sophistication::StateLevel => 6..12,
_ => 1..2,
};
for _ in 0..self.rng.random_range(cover_count) {
let cover_day = self.rng.random_range(0..available_days);
let cover_date = start_date + chrono::Duration::days(cover_day);
let cover_timestamp = self.random_timestamp(cover_date);
let cover_amount = self.rng.random_range(100.0..5000.0);
let direction = if self.rng.random::<bool>() {
Direction::Inbound
} else {
Direction::Outbound
};
let cover_txn = BankTransaction::new(
self.uuid_factory.next(),
account.account_id,
Decimal::from_f64_retain(cover_amount).unwrap_or(Decimal::ZERO),
&account.currency,
direction,
TransactionChannel::CardPresent,
TransactionCategory::Shopping,
CounterpartyRef::merchant_by_name("Regular Merchant", "5411"),
"Regular purchase",
cover_timestamp,
)
.mark_suspicious(AmlTypology::Layering, &scenario_id)
.with_laundering_stage(LaunderingStage::Layering)
.with_scenario(&scenario_id, seq);
transactions.push(cover_txn);
seq += 1;
}
}
if matches!(
sophistication,
Sophistication::Professional | Sophistication::Advanced | Sophistication::StateLevel
) {
for txn in &mut transactions {
txn.is_spoofed = true;
txn.spoofing_intensity = Some(sophistication.spoofing_intensity());
}
}
transactions
}
fn random_layer_destination(&mut self, layer: usize) -> (TransactionChannel, String) {
let destinations = [
(
TransactionChannel::Wire,
format!("Offshore Holdings {}", layer + 1),
),
(
TransactionChannel::Ach,
format!("Investment Co {}", layer + 1),
),
(
TransactionChannel::Swift,
format!("Trade Finance {} Ltd", layer + 1),
),
(
TransactionChannel::Wire,
format!("Consulting {} LLC", layer + 1),
),
];
let idx = self.rng.random_range(0..destinations.len());
destinations[idx].clone()
}
fn random_timestamp(&mut self, date: NaiveDate) -> DateTime<Utc> {
let hour: u32 = self.rng.random_range(6..22);
let minute: u32 = self.rng.random_range(0..60);
let second: u32 = self.rng.random_range(0..60);
date.and_hms_opt(hour, minute, second)
.map(|dt| DateTime::<Utc>::from_naive_utc_and_offset(dt, Utc))
.unwrap_or_else(Utc::now)
}
}
#[cfg(test)]
#[allow(clippy::unwrap_used)]
mod tests {
use super::*;
use uuid::Uuid;
#[test]
fn test_layering_generation() {
let mut injector = LayeringInjector::new(12345);
let customer = BankingCustomer::new_retail(
Uuid::new_v4(),
"Test",
"User",
"US",
NaiveDate::from_ymd_opt(2024, 1, 1).unwrap(),
);
let account = BankAccount::new(
Uuid::new_v4(),
"****1234".to_string(),
datasynth_core::models::banking::BankAccountType::Checking,
customer.customer_id,
"USD",
NaiveDate::from_ymd_opt(2024, 1, 1).unwrap(),
);
let start = NaiveDate::from_ymd_opt(2024, 1, 1).unwrap();
let end = NaiveDate::from_ymd_opt(2024, 3, 31).unwrap();
let transactions = injector.generate(
&customer,
&account,
start,
end,
Sophistication::Professional,
);
assert!(!transactions.is_empty());
assert!(transactions.len() >= 3);
for txn in &transactions {
assert!(txn.is_suspicious);
assert_eq!(txn.suspicion_reason, Some(AmlTypology::Layering));
}
}
}