use chrono::{Datelike, Timelike};
use datasynth_core::models::banking::{AmlTypology, LaunderingStage};
use serde::{Deserialize, Serialize};
use uuid::Uuid;
use crate::models::BankTransaction;
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct TransactionLabel {
pub transaction_id: Uuid,
pub is_suspicious: bool,
pub suspicion_reason: Option<AmlTypology>,
pub laundering_stage: Option<LaunderingStage>,
pub case_id: Option<String>,
pub is_spoofed: bool,
pub spoofing_intensity: Option<f64>,
pub scenario_sequence: Option<u32>,
pub confidence: f64,
pub features: TransactionLabelFeatures,
}
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct TransactionLabelFeatures {
pub is_structuring: bool,
pub below_threshold: bool,
pub is_cash: bool,
pub is_international: bool,
pub is_rapid_succession: bool,
pub new_counterparty: bool,
pub round_amount: bool,
pub unusual_timing: bool,
}
impl TransactionLabel {
pub fn from_transaction(txn: &BankTransaction) -> Self {
let amount_f64: f64 = txn.amount.try_into().unwrap_or(0.0);
Self {
transaction_id: txn.transaction_id,
is_suspicious: txn.is_suspicious,
suspicion_reason: txn.suspicion_reason,
laundering_stage: txn.laundering_stage,
case_id: txn.case_id.clone(),
is_spoofed: txn.is_spoofed,
spoofing_intensity: txn.spoofing_intensity,
scenario_sequence: txn.scenario_sequence,
confidence: 1.0, features: TransactionLabelFeatures {
is_structuring: txn.suspicion_reason == Some(AmlTypology::Structuring),
below_threshold: amount_f64 < 10_000.0 && amount_f64 > 8_000.0,
is_cash: matches!(
txn.channel,
datasynth_core::models::banking::TransactionChannel::Cash
),
is_international: matches!(
txn.category,
datasynth_core::models::banking::TransactionCategory::InternationalTransfer
),
is_rapid_succession: false, new_counterparty: false, round_amount: Self::is_round_amount(amount_f64),
unusual_timing: Self::is_unusual_timing(txn),
},
}
}
fn is_round_amount(amount: f64) -> bool {
let cents = (amount * 100.0) % 100.0;
cents.abs() < 0.01 && amount >= 100.0 && (amount % 100.0).abs() < 0.01
}
fn is_unusual_timing(txn: &BankTransaction) -> bool {
let weekday = txn.timestamp_initiated.weekday();
let hour = txn.timestamp_initiated.hour();
matches!(weekday, chrono::Weekday::Sat | chrono::Weekday::Sun) || !(6..=22).contains(&hour)
}
}
pub struct TransactionLabelExtractor;
impl TransactionLabelExtractor {
pub fn extract(transactions: &[BankTransaction]) -> Vec<TransactionLabel> {
transactions
.iter()
.map(TransactionLabel::from_transaction)
.collect()
}
pub fn extract_with_features(transactions: &[BankTransaction]) -> Vec<TransactionLabel> {
let mut labels: Vec<_> = transactions
.iter()
.map(TransactionLabel::from_transaction)
.collect();
Self::compute_rapid_succession(&mut labels, transactions);
Self::compute_new_counterparty(&mut labels, transactions);
labels
}
fn compute_rapid_succession(labels: &mut [TransactionLabel], transactions: &[BankTransaction]) {
use std::collections::HashMap;
let mut by_account: HashMap<Uuid, Vec<usize>> = HashMap::new();
for (i, txn) in transactions.iter().enumerate() {
by_account.entry(txn.account_id).or_default().push(i);
}
for indices in by_account.values() {
for window in indices.windows(2) {
let t1 = &transactions[window[0]];
let t2 = &transactions[window[1]];
let duration = (t2.timestamp_initiated - t1.timestamp_initiated)
.num_minutes()
.abs();
if duration < 30 {
labels[window[0]].features.is_rapid_succession = true;
labels[window[1]].features.is_rapid_succession = true;
}
}
}
}
fn compute_new_counterparty(labels: &mut [TransactionLabel], transactions: &[BankTransaction]) {
use std::collections::{HashMap, HashSet};
let mut seen: HashMap<Uuid, HashSet<String>> = HashMap::new();
let mut sorted_indices: Vec<usize> = (0..transactions.len()).collect();
sorted_indices.sort_by_key(|&i| transactions[i].timestamp_initiated);
for idx in sorted_indices {
let txn = &transactions[idx];
let counterparty_key = txn.counterparty.name.clone();
let account_seen = seen.entry(txn.account_id).or_default();
if !account_seen.contains(&counterparty_key) {
labels[idx].features.new_counterparty = true;
account_seen.insert(counterparty_key);
}
}
}
pub fn summarize(labels: &[TransactionLabel]) -> LabelSummary {
let total = labels.len();
let suspicious = labels.iter().filter(|l| l.is_suspicious).count();
let spoofed = labels.iter().filter(|l| l.is_spoofed).count();
let mut by_typology = std::collections::HashMap::new();
let mut by_stage = std::collections::HashMap::new();
for label in labels {
if let Some(reason) = &label.suspicion_reason {
*by_typology.entry(*reason).or_insert(0) += 1;
}
if let Some(stage) = &label.laundering_stage {
*by_stage.entry(*stage).or_insert(0) += 1;
}
}
LabelSummary {
total_transactions: total,
suspicious_count: suspicious,
suspicious_rate: suspicious as f64 / total as f64,
spoofed_count: spoofed,
spoofed_rate: spoofed as f64 / total as f64,
by_typology,
by_stage,
}
}
}
#[derive(Debug, Clone)]
pub struct LabelSummary {
pub total_transactions: usize,
pub suspicious_count: usize,
pub suspicious_rate: f64,
pub spoofed_count: usize,
pub spoofed_rate: f64,
pub by_typology: std::collections::HashMap<AmlTypology, usize>,
pub by_stage: std::collections::HashMap<LaunderingStage, usize>,
}
#[cfg(test)]
#[allow(clippy::unwrap_used)]
mod tests {
use super::*;
#[test]
fn test_label_extraction() {
let account_id = Uuid::new_v4();
let txn = BankTransaction::new(
Uuid::new_v4(),
account_id,
rust_decimal::Decimal::from(9500),
"USD",
datasynth_core::models::banking::Direction::Inbound,
datasynth_core::models::banking::TransactionChannel::Cash,
datasynth_core::models::banking::TransactionCategory::CashDeposit,
crate::models::CounterpartyRef::atm("ATM"),
"Test deposit",
chrono::Utc::now(),
)
.mark_suspicious(AmlTypology::Structuring, "TEST-001")
.with_laundering_stage(LaunderingStage::Placement);
let label = TransactionLabel::from_transaction(&txn);
assert!(label.is_suspicious);
assert_eq!(label.suspicion_reason, Some(AmlTypology::Structuring));
assert_eq!(label.laundering_stage, Some(LaunderingStage::Placement));
assert!(label.features.below_threshold);
assert!(label.features.is_cash);
}
}