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::STRUCTURING_INJECTOR_SEED_OFFSET;
pub struct StructuringInjector {
rng: ChaCha8Rng,
uuid_factory: DeterministicUuidFactory,
}
impl StructuringInjector {
pub fn new(seed: u64) -> Self {
Self {
rng: ChaCha8Rng::seed_from_u64(seed.wrapping_add(STRUCTURING_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 threshold = 10_000.0;
let total_amount: f64 = self.rng.random_range(30_000.0..100_000.0);
let num_deposits = match sophistication {
Sophistication::Basic => self.rng.random_range(3..6),
Sophistication::Standard => self.rng.random_range(5..10),
Sophistication::Professional => self.rng.random_range(8..15),
Sophistication::Advanced => self.rng.random_range(12..25),
Sophistication::StateLevel => self.rng.random_range(20..40),
};
let days_spread = match sophistication {
Sophistication::Basic => 3,
Sophistication::Standard => 7,
Sophistication::Professional => 14,
Sophistication::Advanced => 30,
Sophistication::StateLevel => 60,
};
let available_days = (end_date - start_date).num_days().max(1) as u32;
let actual_spread = days_spread.min(available_days);
let mut remaining = total_amount;
let scenario_id = format!("STR-{:06}", self.rng.random::<u32>());
for i in 0..num_deposits {
if remaining <= 0.0 {
break;
}
let max_deposit = threshold * 0.99;
let min_deposit = threshold * 0.80;
let deposit_amount = if remaining > max_deposit {
self.rng.random_range(min_deposit..max_deposit)
} else {
remaining.min(max_deposit)
};
remaining -= deposit_amount;
let day_offset = if actual_spread > 0 {
self.rng.random_range(0..actual_spread) as i64
} else {
0
};
let date = start_date + chrono::Duration::days(day_offset);
let timestamp = self.random_timestamp(date);
let txn = BankTransaction::new(
self.uuid_factory.next(),
account.account_id,
Decimal::from_f64_retain(deposit_amount).unwrap_or(Decimal::ZERO),
&account.currency,
Direction::Inbound,
TransactionChannel::Cash,
TransactionCategory::CashDeposit,
CounterpartyRef::atm("Branch"),
&format!("Cash deposit #{}", i + 1),
timestamp,
)
.mark_suspicious(AmlTypology::Structuring, &scenario_id)
.with_laundering_stage(LaunderingStage::Placement)
.with_scenario(&scenario_id, i as u32);
transactions.push(txn);
}
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_timestamp(&mut self, date: NaiveDate) -> DateTime<Utc> {
let hour: u32 = self.rng.random_range(9..17); 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_structuring_generation() {
let mut injector = StructuringInjector::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, 1, 31).unwrap();
let transactions =
injector.generate(&customer, &account, start, end, Sophistication::Basic);
assert!(!transactions.is_empty());
for txn in &transactions {
assert!(txn.is_suspicious);
assert_eq!(txn.suspicion_reason, Some(AmlTypology::Structuring));
let amount_f64: f64 = txn.amount.try_into().unwrap();
assert!(amount_f64 < 10_000.0);
}
}
}