use chrono::NaiveDate;
use rand::{Rng, RngExt};
use rust_decimal::Decimal;
use rust_decimal_macros::dec;
use serde::{Deserialize, Serialize};
use uuid::Uuid;
use datasynth_core::uuid_factory::{DeterministicUuidFactory, GeneratorType};
use datasynth_core::models::{
AnomalyDetectionDifficulty, ConcealmentTechnique, SchemeDetectionStatus, SchemeType,
};
use super::scheme::{
FraudScheme, SchemeAction, SchemeActionType, SchemeContext, SchemeStage, SchemeStatus,
SchemeTransactionRef,
};
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct GradualEmbezzlementScheme {
pub scheme_id: Uuid,
pub perpetrator_id: String,
pub start_date: Option<NaiveDate>,
current_stage_index: usize,
stages: Vec<SchemeStage>,
transactions: Vec<SchemeTransactionRef>,
total_impact: Decimal,
status: SchemeStatus,
detection_status: SchemeDetectionStatus,
detection_probability: f64,
preferred_accounts: Vec<String>,
stage_transaction_count: u32,
days_since_last_transaction: u32,
}
impl GradualEmbezzlementScheme {
pub fn new(perpetrator_id: impl Into<String>) -> Self {
let stages = vec![
SchemeStage::new(
1,
"testing",
2,
(dec!(100), dec!(500)),
(2, 4),
AnomalyDetectionDifficulty::Hard,
)
.with_description("Initial testing phase with small amounts")
.with_technique(ConcealmentTechnique::TimingExploitation),
SchemeStage::new(
2,
"escalation",
6,
(dec!(500), dec!(2000)),
(4, 8),
AnomalyDetectionDifficulty::Moderate,
)
.with_description("Gradual increase in amounts as confidence grows")
.with_technique(ConcealmentTechnique::AccountMisclassification),
SchemeStage::new(
3,
"acceleration",
3,
(dec!(2000), dec!(10000)),
(6, 12),
AnomalyDetectionDifficulty::Easy,
)
.with_description("Accelerated theft with larger amounts")
.with_technique(ConcealmentTechnique::DocumentManipulation)
.with_technique(ConcealmentTechnique::ApprovalCircumvention),
SchemeStage::new(
4,
"desperation",
1,
(dec!(10000), dec!(50000)),
(2, 5),
AnomalyDetectionDifficulty::Trivial,
)
.with_description("Final desperate phase before exit or detection")
.with_technique(ConcealmentTechnique::FalseDocumentation),
];
let uuid_factory = DeterministicUuidFactory::new(0, GeneratorType::Anomaly);
Self {
scheme_id: uuid_factory.next(),
perpetrator_id: perpetrator_id.into(),
start_date: None,
current_stage_index: 0,
stages,
transactions: Vec::new(),
total_impact: Decimal::ZERO,
status: SchemeStatus::NotStarted,
detection_status: SchemeDetectionStatus::Undetected,
detection_probability: 0.0,
preferred_accounts: Vec::new(),
stage_transaction_count: 0,
days_since_last_transaction: 0,
}
}
pub fn with_accounts(mut self, accounts: Vec<String>) -> Self {
self.preferred_accounts = accounts;
self
}
pub fn start(&mut self, date: NaiveDate) {
self.start_date = Some(date);
self.status = SchemeStatus::Active;
}
fn stage_end_date(&self) -> Option<NaiveDate> {
self.start_date.map(|start| {
let months_elapsed: u32 = self.stages[..self.current_stage_index]
.iter()
.map(|s| s.duration_months)
.sum();
let stage_months = self.stages[self.current_stage_index].duration_months;
start + chrono::Months::new(months_elapsed + stage_months)
})
}
fn should_advance_stage(&self, current_date: NaiveDate) -> bool {
if let Some(end_date) = self.stage_end_date() {
current_date >= end_date && self.current_stage_index < self.stages.len() - 1
} else {
false
}
}
fn advance_stage(&mut self) {
if self.current_stage_index < self.stages.len() - 1 {
self.current_stage_index += 1;
self.stage_transaction_count = 0;
}
}
fn update_detection_probability(&mut self) {
let stage = &self.stages[self.current_stage_index];
let base_prob = 1.0 - stage.detection_difficulty.expected_detection_rate();
let impact_factor = if self.total_impact > dec!(100000) {
0.3
} else if self.total_impact > dec!(50000) {
0.2
} else if self.total_impact > dec!(10000) {
0.1
} else {
0.0
};
let count_factor = (self.transactions.len() as f64 * 0.02).min(0.2);
let stage_factor = (self.current_stage_index as f64 * 0.1).min(0.3);
self.detection_probability =
(base_prob + impact_factor + count_factor + stage_factor).min(0.95);
}
fn select_account<R: Rng + ?Sized>(
&self,
context: &SchemeContext,
rng: &mut R,
) -> Option<String> {
if !self.preferred_accounts.is_empty() && rng.random::<f64>() < 0.8 {
let idx = rng.random_range(0..self.preferred_accounts.len());
return Some(self.preferred_accounts[idx].clone());
}
if !context.available_accounts.is_empty() {
let idx = rng.random_range(0..context.available_accounts.len());
return Some(context.available_accounts[idx].clone());
}
None
}
}
impl FraudScheme for GradualEmbezzlementScheme {
fn scheme_type(&self) -> SchemeType {
SchemeType::GradualEmbezzlement
}
fn scheme_id(&self) -> Uuid {
self.scheme_id
}
fn current_stage(&self) -> &SchemeStage {
&self.stages[self.current_stage_index]
}
fn current_stage_number(&self) -> u32 {
self.stages[self.current_stage_index].stage_number
}
fn stages(&self) -> &[SchemeStage] {
&self.stages
}
fn status(&self) -> SchemeStatus {
self.status
}
fn detection_status(&self) -> SchemeDetectionStatus {
self.detection_status
}
fn advance(&mut self, context: &SchemeContext, rng: &mut dyn rand::Rng) -> Vec<SchemeAction> {
let mut actions = Vec::new();
if self.status == SchemeStatus::NotStarted {
self.start(context.current_date);
}
if self.should_terminate(context) {
self.status = SchemeStatus::Terminated;
return actions;
}
if rng.random::<f64>() < self.detection_probability * context.detection_activity {
self.detection_status = SchemeDetectionStatus::PartiallyDetected;
self.status = SchemeStatus::Detected;
return actions;
}
if self.should_advance_stage(context.current_date) {
self.advance_stage();
}
if context.audit_in_progress && rng.random::<f64>() < 0.8 {
self.status = SchemeStatus::Paused;
return actions;
}
self.status = SchemeStatus::Active;
let stage = &self.stages[self.current_stage_index];
let target_count = stage.random_transaction_count(rng);
let should_transact = self.stage_transaction_count < target_count
&& self.days_since_last_transaction >= 3 && rng.random::<f64>() < 0.3;
if should_transact {
let amount = stage.random_amount(rng);
let account = self.select_account(context, rng);
let mut action = SchemeAction::new(
self.scheme_id,
stage.stage_number,
SchemeActionType::CreateFraudulentEntry,
context.current_date,
)
.with_amount(amount)
.with_user(&self.perpetrator_id)
.with_difficulty(stage.detection_difficulty)
.with_description(format!(
"Embezzlement stage {} - {}",
stage.stage_number, stage.name
));
if let Some(acct) = account {
action = action.with_account(acct);
}
for technique in &stage.concealment_techniques {
action = action.with_technique(*technique);
}
self.stage_transaction_count += 1;
self.days_since_last_transaction = 0;
actions.push(action);
} else {
self.days_since_last_transaction += 1;
}
if self.current_stage_index == self.stages.len() - 1 {
if let Some(end_date) = self.stage_end_date() {
if context.current_date >= end_date {
self.status = SchemeStatus::Completed;
}
}
}
self.update_detection_probability();
actions
}
fn detection_probability(&self) -> f64 {
self.detection_probability
}
fn total_impact(&self) -> Decimal {
self.total_impact
}
fn should_terminate(&self, context: &SchemeContext) -> bool {
if context.detection_activity > 0.8 {
return true;
}
if self.detection_probability > 0.9 {
return true;
}
if self.detection_status != SchemeDetectionStatus::Undetected {
return true;
}
false
}
fn perpetrator_id(&self) -> &str {
&self.perpetrator_id
}
fn start_date(&self) -> Option<NaiveDate> {
self.start_date
}
fn transaction_refs(&self) -> &[SchemeTransactionRef] {
&self.transactions
}
fn record_transaction(&mut self, transaction: SchemeTransactionRef) {
self.total_impact += transaction.amount;
self.transactions.push(transaction);
}
}
#[cfg(test)]
#[allow(clippy::unwrap_used)]
mod tests {
use super::*;
use rand::SeedableRng;
use rand_chacha::ChaCha8Rng;
#[test]
fn test_embezzlement_scheme_creation() {
let scheme = GradualEmbezzlementScheme::new("USER001")
.with_accounts(vec!["5000".to_string(), "6000".to_string()]);
assert_eq!(scheme.perpetrator_id, "USER001");
assert_eq!(scheme.stages.len(), 4);
assert_eq!(scheme.status, SchemeStatus::NotStarted);
assert_eq!(scheme.preferred_accounts.len(), 2);
}
#[test]
fn test_embezzlement_scheme_stages() {
let scheme = GradualEmbezzlementScheme::new("USER001");
assert_eq!(scheme.stages[0].name, "testing");
assert_eq!(scheme.stages[0].duration_months, 2);
assert_eq!(
scheme.stages[0].detection_difficulty,
AnomalyDetectionDifficulty::Hard
);
assert_eq!(scheme.stages[3].name, "desperation");
assert_eq!(
scheme.stages[3].detection_difficulty,
AnomalyDetectionDifficulty::Trivial
);
}
#[test]
fn test_embezzlement_scheme_advance() {
let mut scheme =
GradualEmbezzlementScheme::new("USER001").with_accounts(vec!["5000".to_string()]);
let mut rng = ChaCha8Rng::seed_from_u64(42);
let context = SchemeContext::new(NaiveDate::from_ymd_opt(2024, 1, 15).unwrap(), "1000")
.with_accounts(vec!["5000".to_string(), "6000".to_string()])
.with_users(vec!["USER001".to_string()]);
let mut total_actions = 0;
for day in 0..30 {
let date = NaiveDate::from_ymd_opt(2024, 1, 15).unwrap() + chrono::Duration::days(day);
let mut ctx = context.clone();
ctx.current_date = date;
let actions = scheme.advance(&ctx, &mut rng);
total_actions += actions.len();
}
assert!(total_actions > 0);
assert_eq!(scheme.status, SchemeStatus::Active);
}
#[test]
fn test_embezzlement_scheme_pauses_during_audit() {
let mut scheme = GradualEmbezzlementScheme::new("USER001");
let mut rng = ChaCha8Rng::seed_from_u64(42);
let context = SchemeContext::new(NaiveDate::from_ymd_opt(2024, 1, 15).unwrap(), "1000")
.with_audit(true);
scheme.start(NaiveDate::from_ymd_opt(2024, 1, 1).unwrap());
let mut pause_count = 0;
for _ in 0..10 {
scheme.advance(&context, &mut rng);
if scheme.status == SchemeStatus::Paused {
pause_count += 1;
}
}
assert!(pause_count > 0);
}
#[test]
fn test_embezzlement_scheme_terminates_on_high_detection() {
let mut scheme = GradualEmbezzlementScheme::new("USER001");
scheme.start(NaiveDate::from_ymd_opt(2024, 1, 1).unwrap());
let context = SchemeContext::new(NaiveDate::from_ymd_opt(2024, 1, 15).unwrap(), "1000")
.with_detection_activity(0.9);
assert!(scheme.should_terminate(&context));
}
}