#![allow(clippy::too_many_arguments)]
use chrono::{DateTime, Utc};
use datasynth_core::models::banking::{
AmlTypology, Direction, LaunderingStage, MerchantCategoryCode, TransactionCategory,
TransactionChannel,
};
use rust_decimal::Decimal;
use serde::{Deserialize, Serialize};
use uuid::Uuid;
fn derive_transaction_type(channel: TransactionChannel, category: TransactionCategory) -> String {
fn to_screaming_snake(name: &str) -> String {
let mut result = String::with_capacity(name.len() + 4);
for (i, ch) in name.chars().enumerate() {
if ch.is_uppercase() && i > 0 {
result.push('_');
}
result.push(ch.to_ascii_uppercase());
}
result
}
let channel_str = to_screaming_snake(&format!("{channel:?}"));
let category_str = to_screaming_snake(&format!("{category:?}"));
format!("{channel_str}_{category_str}")
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct BankTransaction {
pub transaction_id: Uuid,
pub account_id: Uuid,
pub timestamp_initiated: DateTime<Utc>,
pub timestamp_booked: DateTime<Utc>,
pub timestamp_settled: Option<DateTime<Utc>>,
#[serde(with = "rust_decimal::serde::str")]
pub amount: Decimal,
pub currency: String,
pub direction: Direction,
pub channel: TransactionChannel,
pub category: TransactionCategory,
pub counterparty: CounterpartyRef,
pub mcc: Option<MerchantCategoryCode>,
pub reference: String,
#[serde(with = "rust_decimal::serde::str_option")]
pub balance_before: Option<Decimal>,
#[serde(with = "rust_decimal::serde::str_option")]
pub balance_after: Option<Decimal>,
pub original_currency: Option<String>,
#[serde(with = "rust_decimal::serde::str_option")]
pub original_amount: Option<Decimal>,
#[serde(with = "rust_decimal::serde::str_option")]
pub fx_rate: Option<Decimal>,
pub location_country: Option<String>,
pub location_city: Option<String>,
pub device_id: Option<String>,
pub ip_address: Option<String>,
pub is_authorized: bool,
pub auth_code: Option<String>,
pub status: TransactionStatus,
pub parent_transaction_id: Option<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_id: Option<String>,
pub scenario_sequence: Option<u32>,
pub transaction_type: String,
}
impl BankTransaction {
pub fn new(
transaction_id: Uuid,
account_id: Uuid,
amount: Decimal,
currency: &str,
direction: Direction,
channel: TransactionChannel,
category: TransactionCategory,
counterparty: CounterpartyRef,
reference: &str,
timestamp: DateTime<Utc>,
) -> Self {
let transaction_type = derive_transaction_type(channel, category);
Self {
transaction_id,
account_id,
timestamp_initiated: timestamp,
timestamp_booked: timestamp,
timestamp_settled: None,
amount,
currency: currency.to_string(),
direction,
channel,
category,
counterparty,
mcc: None,
reference: reference.to_string(),
balance_before: None,
balance_after: None,
original_currency: None,
original_amount: None,
fx_rate: None,
location_country: None,
location_city: None,
device_id: None,
ip_address: None,
is_authorized: true,
auth_code: None,
status: TransactionStatus::Completed,
parent_transaction_id: None,
is_suspicious: false,
suspicion_reason: None,
laundering_stage: None,
case_id: None,
is_spoofed: false,
spoofing_intensity: None,
scenario_id: None,
scenario_sequence: None,
transaction_type,
}
}
pub fn mark_suspicious(mut self, reason: AmlTypology, case_id: &str) -> Self {
self.is_suspicious = true;
self.suspicion_reason = Some(reason);
self.case_id = Some(case_id.to_string());
self
}
pub fn with_laundering_stage(mut self, stage: LaunderingStage) -> Self {
self.laundering_stage = Some(stage);
self
}
pub fn mark_spoofed(mut self, intensity: f64) -> Self {
self.is_spoofed = true;
self.spoofing_intensity = Some(intensity);
self
}
pub fn with_scenario(mut self, scenario_id: &str, sequence: u32) -> Self {
self.scenario_id = Some(scenario_id.to_string());
self.scenario_sequence = Some(sequence);
self
}
pub fn with_mcc(mut self, mcc: MerchantCategoryCode) -> Self {
self.mcc = Some(mcc);
self
}
pub fn with_location(mut self, country: &str, city: Option<&str>) -> Self {
self.location_country = Some(country.to_string());
self.location_city = city.map(std::string::ToString::to_string);
self
}
pub fn with_fx_conversion(
mut self,
original_currency: &str,
original_amount: Decimal,
rate: Decimal,
) -> Self {
self.original_currency = Some(original_currency.to_string());
self.original_amount = Some(original_amount);
self.fx_rate = Some(rate);
self
}
pub fn with_balance(mut self, before: Decimal, after: Decimal) -> Self {
self.balance_before = Some(before);
self.balance_after = Some(after);
self
}
pub fn calculate_risk_score(&self) -> u8 {
let mut score = 0.0;
score += self.channel.risk_weight() * 10.0;
score += self.category.risk_weight() * 10.0;
let amount_f64: f64 = self.amount.try_into().unwrap_or(0.0);
if amount_f64 > 10_000.0 {
score += ((amount_f64 / 10_000.0).ln() * 5.0).min(20.0);
}
if let Some(mcc) = self.mcc {
score += mcc.risk_weight() * 5.0;
}
if self.original_currency.is_some() {
score += 10.0;
}
if self.is_suspicious {
score += 50.0;
}
score.min(100.0) as u8
}
pub fn is_cash(&self) -> bool {
matches!(
self.channel,
TransactionChannel::Cash | TransactionChannel::Atm
)
}
pub fn is_cross_border(&self) -> bool {
self.original_currency.is_some() || matches!(self.channel, TransactionChannel::Swift)
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct CounterpartyRef {
pub counterparty_type: CounterpartyType,
pub counterparty_id: Option<Uuid>,
pub name: String,
pub account_identifier: Option<String>,
pub bank_identifier: Option<String>,
pub country: Option<String>,
}
impl CounterpartyRef {
pub fn merchant(id: Uuid, name: &str) -> Self {
Self {
counterparty_type: CounterpartyType::Merchant,
counterparty_id: Some(id),
name: name.to_string(),
account_identifier: None,
bank_identifier: None,
country: None,
}
}
pub fn employer(id: Uuid, name: &str) -> Self {
Self {
counterparty_type: CounterpartyType::Employer,
counterparty_id: Some(id),
name: name.to_string(),
account_identifier: None,
bank_identifier: None,
country: None,
}
}
pub fn peer(name: &str, account: Option<&str>) -> Self {
Self {
counterparty_type: CounterpartyType::Peer,
counterparty_id: None,
name: name.to_string(),
account_identifier: account.map(std::string::ToString::to_string),
bank_identifier: None,
country: None,
}
}
pub fn atm(location: &str) -> Self {
Self {
counterparty_type: CounterpartyType::Atm,
counterparty_id: None,
name: format!("ATM - {location}"),
account_identifier: None,
bank_identifier: None,
country: None,
}
}
pub fn self_account(account_id: Uuid, account_name: &str) -> Self {
Self {
counterparty_type: CounterpartyType::SelfAccount,
counterparty_id: Some(account_id),
name: account_name.to_string(),
account_identifier: None,
bank_identifier: None,
country: None,
}
}
pub fn unknown(name: &str) -> Self {
Self {
counterparty_type: CounterpartyType::Unknown,
counterparty_id: None,
name: name.to_string(),
account_identifier: None,
bank_identifier: None,
country: None,
}
}
pub fn person(name: &str) -> Self {
Self {
counterparty_type: CounterpartyType::Peer,
counterparty_id: None,
name: name.to_string(),
account_identifier: None,
bank_identifier: None,
country: None,
}
}
pub fn business(name: &str) -> Self {
Self {
counterparty_type: CounterpartyType::Unknown,
counterparty_id: None,
name: name.to_string(),
account_identifier: None,
bank_identifier: None,
country: None,
}
}
pub fn international(name: &str) -> Self {
Self {
counterparty_type: CounterpartyType::FinancialInstitution,
counterparty_id: None,
name: name.to_string(),
account_identifier: None,
bank_identifier: None,
country: Some("XX".to_string()), }
}
pub fn crypto_exchange(name: &str) -> Self {
Self {
counterparty_type: CounterpartyType::CryptoExchange,
counterparty_id: None,
name: name.to_string(),
account_identifier: None,
bank_identifier: None,
country: None,
}
}
pub fn service(name: &str) -> Self {
Self {
counterparty_type: CounterpartyType::Unknown,
counterparty_id: None,
name: name.to_string(),
account_identifier: None,
bank_identifier: None,
country: None,
}
}
pub fn merchant_by_name(name: &str, _mcc: &str) -> Self {
Self {
counterparty_type: CounterpartyType::Merchant,
counterparty_id: None,
name: name.to_string(),
account_identifier: None,
bank_identifier: None,
country: None,
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum CounterpartyType {
Merchant,
Employer,
Utility,
Government,
FinancialInstitution,
Peer,
Atm,
SelfAccount,
Investment,
CryptoExchange,
Unknown,
}
impl CounterpartyType {
pub fn risk_weight(&self) -> f64 {
match self {
Self::Merchant => 1.0,
Self::Employer => 0.5,
Self::Utility | Self::Government => 0.3,
Self::FinancialInstitution => 1.2,
Self::Peer => 1.5,
Self::Atm => 1.3,
Self::SelfAccount => 0.8,
Self::Investment => 1.2,
Self::CryptoExchange => 2.0,
Self::Unknown => 1.8,
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize, Default)]
#[serde(rename_all = "snake_case")]
pub enum TransactionStatus {
Pending,
Authorized,
#[default]
Completed,
Failed,
Declined,
Reversed,
Disputed,
OnHold,
}
impl TransactionStatus {
pub fn is_final(&self) -> bool {
matches!(
self,
Self::Completed | Self::Failed | Self::Declined | Self::Reversed
)
}
}
#[cfg(test)]
#[allow(clippy::unwrap_used)]
mod tests {
use super::*;
#[test]
fn test_transaction_creation() {
let txn = BankTransaction::new(
Uuid::new_v4(),
Uuid::new_v4(),
Decimal::from(100),
"USD",
Direction::Outbound,
TransactionChannel::CardPresent,
TransactionCategory::Shopping,
CounterpartyRef::merchant(Uuid::new_v4(), "Test Store"),
"Purchase at Test Store",
Utc::now(),
);
assert!(!txn.is_suspicious);
assert!(!txn.is_cross_border());
assert!(!txn.transaction_type.is_empty());
}
#[test]
fn test_suspicious_transaction() {
let txn = BankTransaction::new(
Uuid::new_v4(),
Uuid::new_v4(),
Decimal::from(9500),
"USD",
Direction::Inbound,
TransactionChannel::Cash,
TransactionCategory::CashDeposit,
CounterpartyRef::atm("Main Branch"),
"Cash deposit",
Utc::now(),
)
.mark_suspicious(AmlTypology::Structuring, "CASE-001");
assert!(txn.is_suspicious);
assert_eq!(txn.suspicion_reason, Some(AmlTypology::Structuring));
}
#[test]
fn test_risk_score() {
let low_risk = BankTransaction::new(
Uuid::new_v4(),
Uuid::new_v4(),
Decimal::from(50),
"USD",
Direction::Outbound,
TransactionChannel::CardPresent,
TransactionCategory::Groceries,
CounterpartyRef::merchant(Uuid::new_v4(), "Grocery Store"),
"Groceries",
Utc::now(),
);
let high_risk = BankTransaction::new(
Uuid::new_v4(),
Uuid::new_v4(),
Decimal::from(50000),
"USD",
Direction::Outbound,
TransactionChannel::Wire,
TransactionCategory::InternationalTransfer,
CounterpartyRef::unknown("Unknown Recipient"),
"Wire transfer",
Utc::now(),
);
assert!(high_risk.calculate_risk_score() > low_risk.calculate_risk_score());
}
#[test]
fn test_transaction_type_derivation() {
let txn = BankTransaction::new(
Uuid::new_v4(),
Uuid::new_v4(),
Decimal::from(100),
"USD",
Direction::Outbound,
TransactionChannel::CardPresent,
TransactionCategory::Shopping,
CounterpartyRef::merchant(Uuid::new_v4(), "Test Store"),
"Purchase",
Utc::now(),
);
assert_eq!(txn.transaction_type, "CARD_PRESENT_SHOPPING");
let txn2 = BankTransaction::new(
Uuid::new_v4(),
Uuid::new_v4(),
Decimal::from(500),
"USD",
Direction::Outbound,
TransactionChannel::Wire,
TransactionCategory::InternationalTransfer,
CounterpartyRef::unknown("Recipient"),
"Wire transfer",
Utc::now(),
);
assert_eq!(txn2.transaction_type, "WIRE_INTERNATIONAL_TRANSFER");
let txn3 = BankTransaction::new(
Uuid::new_v4(),
Uuid::new_v4(),
Decimal::from(200),
"USD",
Direction::Outbound,
TransactionChannel::Atm,
TransactionCategory::AtmWithdrawal,
CounterpartyRef::atm("Branch"),
"ATM",
Utc::now(),
);
assert_eq!(txn3.transaction_type, "ATM_ATM_WITHDRAWAL");
}
#[test]
fn test_transaction_type_all_channels_non_empty() {
let channels = [
TransactionChannel::CardPresent,
TransactionChannel::CardNotPresent,
TransactionChannel::Atm,
TransactionChannel::Ach,
TransactionChannel::Wire,
TransactionChannel::InternalTransfer,
TransactionChannel::Mobile,
TransactionChannel::Online,
TransactionChannel::Branch,
TransactionChannel::Cash,
TransactionChannel::Check,
TransactionChannel::RealTimePayment,
TransactionChannel::Swift,
TransactionChannel::PeerToPeer,
];
for channel in channels {
let txn = BankTransaction::new(
Uuid::new_v4(),
Uuid::new_v4(),
Decimal::from(100),
"USD",
Direction::Outbound,
channel,
TransactionCategory::Other,
CounterpartyRef::unknown("Test"),
"Test",
Utc::now(),
);
assert!(
!txn.transaction_type.is_empty(),
"transaction_type was empty for channel {:?}",
channel
);
assert!(
txn.transaction_type
.chars()
.all(|c| c.is_ascii_uppercase() || c == '_' || c.is_ascii_digit()),
"transaction_type '{}' is not SCREAMING_SNAKE_CASE for channel {:?}",
txn.transaction_type,
channel
);
}
}
}