use chrono::Timelike;
use datasynth_core::models::banking::TransactionCategory;
use rand::prelude::*;
use rand_chacha::ChaCha8Rng;
use rust_decimal::Decimal;
use crate::config::SpoofingConfig;
use crate::models::{BankTransaction, BankingCustomer};
use crate::seed_offsets::SPOOFING_ENGINE_SEED_OFFSET;
pub struct SpoofingEngine {
config: SpoofingConfig,
rng: ChaCha8Rng,
}
impl SpoofingEngine {
pub fn new(config: SpoofingConfig, seed: u64) -> Self {
Self {
config,
rng: ChaCha8Rng::seed_from_u64(seed.wrapping_add(SPOOFING_ENGINE_SEED_OFFSET)),
}
}
pub fn apply(&mut self, txn: &mut BankTransaction, customer: &BankingCustomer) {
if !self.config.enabled || txn.spoofing_intensity.is_none() {
return;
}
let intensity = txn.spoofing_intensity.unwrap_or(0.0);
if self.config.spoof_timing && self.rng.random::<f64>() < intensity {
self.spoof_timing(txn, customer);
}
if self.config.spoof_amounts && self.rng.random::<f64>() < intensity {
self.spoof_amount(txn, customer);
}
if self.config.spoof_merchants && self.rng.random::<f64>() < intensity {
self.spoof_merchant(txn, customer);
}
if self.config.add_delays && self.rng.random::<f64>() < intensity {
self.add_timing_jitter(txn);
}
}
fn spoof_timing(&mut self, txn: &mut BankTransaction, _customer: &BankingCustomer) {
let current_hour = txn.timestamp_initiated.hour();
if !(7..=22).contains(¤t_hour) {
let new_hour = self.rng.random_range(9..18);
let new_minute = self.rng.random_range(0..60);
let new_second = self.rng.random_range(0..60);
if let Some(new_time) = txn
.timestamp_initiated
.date_naive()
.and_hms_opt(new_hour, new_minute, new_second)
{
txn.timestamp_initiated =
chrono::DateTime::<chrono::Utc>::from_naive_utc_and_offset(
new_time,
chrono::Utc,
);
}
}
}
fn spoof_amount(&mut self, txn: &mut BankTransaction, _customer: &BankingCustomer) {
let amount_f64: f64 = txn.amount.try_into().unwrap_or(0.0);
let cents = self.rng.random_range(1..99) as f64 / 100.0;
let new_amount = amount_f64 + cents;
let new_amount = self.avoid_thresholds(new_amount);
txn.amount = Decimal::from_f64_retain(new_amount).unwrap_or(txn.amount);
}
fn avoid_thresholds(&mut self, amount: f64) -> f64 {
let thresholds = [10_000.0, 5_000.0, 3_000.0, 1_000.0];
for threshold in thresholds {
let lower_bound = threshold * 0.95;
let upper_bound = threshold * 1.05;
if amount > lower_bound && amount < upper_bound {
if self.rng.random::<bool>() {
return threshold * 0.85 + self.rng.random_range(0.0..100.0);
} else {
return threshold * 1.15 + self.rng.random_range(0.0..100.0);
}
}
}
amount
}
fn spoof_merchant(&mut self, txn: &mut BankTransaction, customer: &BankingCustomer) {
let persona_categories = self.get_persona_categories(customer);
if !persona_categories.is_empty() {
let idx = self.rng.random_range(0..persona_categories.len());
txn.category = persona_categories[idx];
}
}
fn get_persona_categories(&self, customer: &BankingCustomer) -> Vec<TransactionCategory> {
use crate::models::PersonaVariant;
use datasynth_core::models::banking::RetailPersona;
match &customer.persona {
Some(PersonaVariant::Retail(persona)) => match persona {
RetailPersona::Student => vec![
TransactionCategory::Shopping,
TransactionCategory::Dining,
TransactionCategory::Entertainment,
TransactionCategory::Subscription,
],
RetailPersona::EarlyCareer => vec![
TransactionCategory::Shopping,
TransactionCategory::Dining,
TransactionCategory::Subscription,
TransactionCategory::Transportation,
],
RetailPersona::MidCareer => vec![
TransactionCategory::Groceries,
TransactionCategory::Shopping,
TransactionCategory::Utilities,
TransactionCategory::Insurance,
],
RetailPersona::Retiree => vec![
TransactionCategory::Healthcare,
TransactionCategory::Groceries,
TransactionCategory::Utilities,
],
RetailPersona::HighNetWorth => vec![
TransactionCategory::Investment,
TransactionCategory::Entertainment,
TransactionCategory::Shopping,
],
RetailPersona::GigWorker => vec![
TransactionCategory::Shopping,
TransactionCategory::Transportation,
TransactionCategory::Dining,
],
_ => vec![
TransactionCategory::Shopping,
TransactionCategory::Groceries,
],
},
Some(PersonaVariant::Business(_)) => vec![
TransactionCategory::TransferOut,
TransactionCategory::Utilities,
TransactionCategory::Other,
],
Some(PersonaVariant::Trust(_)) => vec![
TransactionCategory::Investment,
TransactionCategory::Other,
TransactionCategory::Charity,
],
None => vec![TransactionCategory::Shopping],
}
}
fn add_timing_jitter(&mut self, txn: &mut BankTransaction) {
let jitter_minutes = self.rng.random_range(-30..30);
txn.timestamp_initiated += chrono::Duration::minutes(jitter_minutes as i64);
}
pub fn calculate_effectiveness(
&self,
txn: &BankTransaction,
customer: &BankingCustomer,
) -> f64 {
let mut score = 0.0;
let mut factors = 0;
let hour = txn.timestamp_initiated.hour();
if (9..=17).contains(&hour) {
score += 1.0;
}
factors += 1;
let amount: f64 = txn.amount.try_into().unwrap_or(0.0);
let has_cents = (amount * 100.0) % 100.0 != 0.0;
if has_cents {
score += 0.5;
}
if !(9_000.0..=11_000.0).contains(&amount) {
score += 0.5;
}
factors += 1;
let expected = self.get_persona_categories(customer);
if expected.contains(&txn.category) {
score += 1.0;
}
factors += 1;
score / factors as f64
}
}
#[derive(Debug, Clone, Default)]
pub struct SpoofingStats {
pub transactions_spoofed: usize,
pub timing_adjustments: usize,
pub amount_adjustments: usize,
pub merchant_changes: usize,
pub avg_effectiveness: f64,
}
#[cfg(test)]
#[allow(clippy::unwrap_used)]
mod tests {
use super::*;
use chrono::NaiveDate;
use uuid::Uuid;
#[test]
fn test_spoofing_engine() {
let config = SpoofingConfig {
enabled: true,
intensity: 0.5,
spoof_timing: true,
spoof_amounts: true,
spoof_merchants: true,
spoof_geography: false,
add_delays: true,
};
let mut engine = SpoofingEngine::new(config, 12345);
let customer = BankingCustomer::new_retail(
Uuid::new_v4(),
"Test",
"User",
"US",
NaiveDate::from_ymd_opt(2024, 1, 1).unwrap(),
);
let account_id = Uuid::new_v4();
let mut txn = BankTransaction::new(
Uuid::new_v4(),
account_id,
Decimal::from(9999),
"USD",
datasynth_core::models::banking::Direction::Outbound,
datasynth_core::models::banking::TransactionChannel::Wire,
TransactionCategory::TransferOut,
crate::models::CounterpartyRef::person("Test"),
"Test transaction",
chrono::Utc::now(),
);
txn.spoofing_intensity = Some(1.0);
engine.apply(&mut txn, &customer);
let amount: f64 = txn.amount.try_into().unwrap();
assert!(amount != 9999.0); }
#[test]
fn test_threshold_avoidance() {
let config = SpoofingConfig::default();
let mut engine = SpoofingEngine::new(config, 12345);
let amount = 9_950.0;
let adjusted = engine.avoid_thresholds(amount);
assert!(!(9_500.0..=10_500.0).contains(&adjusted));
}
}