use chrono::NaiveDate;
use rand::Rng;
use rust_decimal::Decimal;
use serde::{Deserialize, Serialize};
use uuid::Uuid;
use datasynth_core::models::{
AnomalyDetectionDifficulty, ConcealmentTechnique, SchemeDetectionStatus, SchemeType,
};
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct SchemeStage {
pub stage_number: u32,
pub name: String,
pub description: String,
pub duration_months: u32,
pub amount_min: Decimal,
pub amount_max: Decimal,
pub transaction_count_min: u32,
pub transaction_count_max: u32,
pub detection_difficulty: AnomalyDetectionDifficulty,
pub concealment_techniques: Vec<ConcealmentTechnique>,
}
impl SchemeStage {
pub fn new(
stage_number: u32,
name: impl Into<String>,
duration_months: u32,
amount_range: (Decimal, Decimal),
transaction_range: (u32, u32),
difficulty: AnomalyDetectionDifficulty,
) -> Self {
Self {
stage_number,
name: name.into(),
description: String::new(),
duration_months,
amount_min: amount_range.0,
amount_max: amount_range.1,
transaction_count_min: transaction_range.0,
transaction_count_max: transaction_range.1,
detection_difficulty: difficulty,
concealment_techniques: Vec::new(),
}
}
pub fn with_description(mut self, description: impl Into<String>) -> Self {
self.description = description.into();
self
}
pub fn with_technique(mut self, technique: ConcealmentTechnique) -> Self {
self.concealment_techniques.push(technique);
self
}
pub fn random_amount<R: Rng + ?Sized>(&self, rng: &mut R) -> Decimal {
if self.amount_min == self.amount_max {
return self.amount_min;
}
let min_f64: f64 = self.amount_min.try_into().unwrap_or(0.0);
let max_f64: f64 = self.amount_max.try_into().unwrap_or(min_f64 + 1000.0);
let value = rng.random_range(min_f64..=max_f64);
Decimal::from_f64_retain(value).unwrap_or(self.amount_min)
}
pub fn random_transaction_count<R: Rng + ?Sized>(&self, rng: &mut R) -> u32 {
if self.transaction_count_min == self.transaction_count_max {
return self.transaction_count_min;
}
rng.random_range(self.transaction_count_min..=self.transaction_count_max)
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
pub enum SchemeStatus {
#[default]
NotStarted,
Active,
Paused,
Terminated,
Detected,
Completed,
}
#[derive(Debug, Clone)]
pub struct SchemeContext {
pub current_date: NaiveDate,
pub audit_in_progress: bool,
pub detection_activity: f64,
pub available_accounts: Vec<String>,
pub available_counterparties: Vec<String>,
pub available_users: Vec<String>,
pub company_code: String,
}
impl SchemeContext {
pub fn new(current_date: NaiveDate, company_code: impl Into<String>) -> Self {
Self {
current_date,
audit_in_progress: false,
detection_activity: 0.0,
available_accounts: Vec::new(),
available_counterparties: Vec::new(),
available_users: Vec::new(),
company_code: company_code.into(),
}
}
pub fn with_audit(mut self, in_progress: bool) -> Self {
self.audit_in_progress = in_progress;
self
}
pub fn with_detection_activity(mut self, level: f64) -> Self {
self.detection_activity = level.clamp(0.0, 1.0);
self
}
pub fn with_accounts(mut self, accounts: Vec<String>) -> Self {
self.available_accounts = accounts;
self
}
pub fn with_counterparties(mut self, counterparties: Vec<String>) -> Self {
self.available_counterparties = counterparties;
self
}
pub fn with_users(mut self, users: Vec<String>) -> Self {
self.available_users = users;
self
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct SchemeAction {
pub action_id: Uuid,
pub scheme_id: Uuid,
pub stage: u32,
pub action_type: SchemeActionType,
pub target_date: NaiveDate,
pub amount: Option<Decimal>,
pub target_account: Option<String>,
pub counterparty: Option<String>,
pub user_id: Option<String>,
pub description: String,
pub detection_difficulty: AnomalyDetectionDifficulty,
pub concealment_techniques: Vec<ConcealmentTechnique>,
pub executed: bool,
}
impl SchemeAction {
pub fn new(
scheme_id: Uuid,
stage: u32,
action_type: SchemeActionType,
target_date: NaiveDate,
) -> Self {
let action_id = {
let scheme_bytes = scheme_id.as_bytes();
let stage_bytes = stage.to_le_bytes();
let mut hash: u64 = 0xcbf29ce484222325;
for &b in scheme_bytes.iter().chain(stage_bytes.iter()) {
hash ^= b as u64;
hash = hash.wrapping_mul(0x100000001b3);
}
let bytes = hash.to_le_bytes();
let mut uuid_bytes = [0u8; 16];
uuid_bytes[..8].copy_from_slice(&bytes);
uuid_bytes[8..16].copy_from_slice(&bytes);
uuid_bytes[6] = (uuid_bytes[6] & 0x0f) | 0x40;
uuid_bytes[8] = (uuid_bytes[8] & 0x3f) | 0x80;
Uuid::from_bytes(uuid_bytes)
};
Self {
action_id,
scheme_id,
stage,
action_type,
target_date,
amount: None,
target_account: None,
counterparty: None,
user_id: None,
description: String::new(),
detection_difficulty: AnomalyDetectionDifficulty::Moderate,
concealment_techniques: Vec::new(),
executed: false,
}
}
pub fn with_amount(mut self, amount: Decimal) -> Self {
self.amount = Some(amount);
self
}
pub fn with_account(mut self, account: impl Into<String>) -> Self {
self.target_account = Some(account.into());
self
}
pub fn with_counterparty(mut self, counterparty: impl Into<String>) -> Self {
self.counterparty = Some(counterparty.into());
self
}
pub fn with_user(mut self, user: impl Into<String>) -> Self {
self.user_id = Some(user.into());
self
}
pub fn with_description(mut self, description: impl Into<String>) -> Self {
self.description = description.into();
self
}
pub fn with_difficulty(mut self, difficulty: AnomalyDetectionDifficulty) -> Self {
self.detection_difficulty = difficulty;
self
}
pub fn with_technique(mut self, technique: ConcealmentTechnique) -> Self {
self.concealment_techniques.push(technique);
self
}
pub fn mark_executed(&mut self) {
self.executed = true;
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
pub enum SchemeActionType {
CreateFraudulentEntry,
CreateFraudulentPayment,
CreateFictitiousVendor,
InflateInvoice,
MakeKickbackPayment,
ManipulateRevenue,
DeferExpense,
ReleaseReserves,
ChannelStuff,
Conceal,
CoverUp,
}
pub trait FraudScheme: Send + Sync {
fn scheme_type(&self) -> SchemeType;
fn scheme_id(&self) -> Uuid;
fn current_stage(&self) -> &SchemeStage;
fn current_stage_number(&self) -> u32;
fn stages(&self) -> &[SchemeStage];
fn status(&self) -> SchemeStatus;
fn detection_status(&self) -> SchemeDetectionStatus;
fn advance(
&mut self,
context: &SchemeContext,
rng: &mut dyn rand::RngCore,
) -> Vec<SchemeAction>;
fn detection_probability(&self) -> f64;
fn total_impact(&self) -> Decimal;
fn should_terminate(&self, context: &SchemeContext) -> bool;
fn perpetrator_id(&self) -> &str;
fn start_date(&self) -> Option<NaiveDate>;
fn transaction_refs(&self) -> &[crate::anomaly::schemes::scheme::SchemeTransactionRef];
fn record_transaction(&mut self, transaction: SchemeTransactionRef);
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct SchemeTransactionRef {
pub document_id: String,
pub date: NaiveDate,
pub amount: Decimal,
pub stage: u32,
pub anomaly_id: Option<String>,
pub action_id: Option<Uuid>,
}
impl SchemeTransactionRef {
pub fn new(
document_id: impl Into<String>,
date: NaiveDate,
amount: Decimal,
stage: u32,
) -> Self {
Self {
document_id: document_id.into(),
date,
amount,
stage,
anomaly_id: None,
action_id: None,
}
}
pub fn with_anomaly(mut self, anomaly_id: impl Into<String>) -> Self {
self.anomaly_id = Some(anomaly_id.into());
self
}
pub fn with_action(mut self, action_id: Uuid) -> Self {
self.action_id = Some(action_id);
self
}
}
#[cfg(test)]
#[allow(clippy::unwrap_used)]
mod tests {
use super::*;
use rust_decimal_macros::dec;
#[test]
fn test_scheme_stage() {
let stage = SchemeStage::new(
1,
"testing",
2,
(dec!(100), dec!(500)),
(2, 5),
AnomalyDetectionDifficulty::Hard,
)
.with_description("Initial testing phase")
.with_technique(ConcealmentTechnique::TransactionSplitting);
assert_eq!(stage.stage_number, 1);
assert_eq!(stage.name, "testing");
assert_eq!(stage.duration_months, 2);
assert_eq!(stage.detection_difficulty, AnomalyDetectionDifficulty::Hard);
assert_eq!(stage.concealment_techniques.len(), 1);
}
#[test]
fn test_scheme_stage_random_amount() {
use rand::SeedableRng;
use rand_chacha::ChaCha8Rng;
let stage = SchemeStage::new(
1,
"test",
2,
(dec!(100), dec!(500)),
(2, 5),
AnomalyDetectionDifficulty::Moderate,
);
let mut rng = ChaCha8Rng::seed_from_u64(42);
let amount = stage.random_amount(&mut rng);
assert!(amount >= dec!(100));
assert!(amount <= dec!(500));
}
#[test]
fn test_scheme_context() {
let context = SchemeContext::new(NaiveDate::from_ymd_opt(2024, 6, 15).unwrap(), "1000")
.with_audit(true)
.with_detection_activity(0.3)
.with_accounts(vec!["5000".to_string(), "6000".to_string()])
.with_users(vec!["USER001".to_string()]);
assert!(context.audit_in_progress);
assert!((context.detection_activity - 0.3).abs() < 0.01);
assert_eq!(context.available_accounts.len(), 2);
}
#[test]
fn test_scheme_action() {
let action = SchemeAction::new(
Uuid::new_v4(),
1,
SchemeActionType::CreateFraudulentEntry,
NaiveDate::from_ymd_opt(2024, 6, 15).unwrap(),
)
.with_amount(dec!(5000))
.with_account("5000")
.with_user("USER001")
.with_description("Test fraudulent entry")
.with_technique(ConcealmentTechnique::DocumentManipulation);
assert_eq!(action.amount, Some(dec!(5000)));
assert_eq!(action.target_account, Some("5000".to_string()));
assert!(!action.executed);
}
#[test]
fn test_scheme_transaction_ref() {
let tx_ref = SchemeTransactionRef::new(
"JE001",
NaiveDate::from_ymd_opt(2024, 6, 15).unwrap(),
dec!(1000),
1,
)
.with_anomaly("ANO001");
assert_eq!(tx_ref.document_id, "JE001");
assert_eq!(tx_ref.anomaly_id, Some("ANO001".to_string()));
}
}