use chrono::Datelike;
use rand::Rng;
use rust_decimal::Decimal;
use datasynth_core::models::{
AnomalyType, ControlStatus, ErrorType, FraudType, JournalEntry, ProcessIssueType,
RelationalAnomalyType, StatisticalAnomalyType,
};
use datasynth_core::uuid_factory::DeterministicUuidFactory;
pub trait InjectionStrategy {
fn name(&self) -> &'static str;
fn can_apply(&self, entry: &JournalEntry) -> bool;
fn apply<R: Rng>(
&self,
entry: &mut JournalEntry,
anomaly_type: &AnomalyType,
rng: &mut R,
) -> InjectionResult;
}
#[derive(Debug, Clone)]
pub struct InjectionResult {
pub success: bool,
pub description: String,
pub monetary_impact: Option<Decimal>,
pub related_entities: Vec<String>,
pub metadata: Vec<(String, String)>,
}
impl InjectionResult {
pub fn success(description: &str) -> Self {
Self {
success: true,
description: description.to_string(),
monetary_impact: None,
related_entities: Vec::new(),
metadata: Vec::new(),
}
}
pub fn failure(reason: &str) -> Self {
Self {
success: false,
description: reason.to_string(),
monetary_impact: None,
related_entities: Vec::new(),
metadata: Vec::new(),
}
}
pub fn with_impact(mut self, impact: Decimal) -> Self {
self.monetary_impact = Some(impact);
self
}
pub fn with_entity(mut self, entity: &str) -> Self {
self.related_entities.push(entity.to_string());
self
}
pub fn with_metadata(mut self, key: &str, value: &str) -> Self {
self.metadata.push((key.to_string(), value.to_string()));
self
}
}
pub struct AmountModificationStrategy {
pub min_multiplier: f64,
pub max_multiplier: f64,
pub prefer_round_numbers: bool,
pub rebalance_entry: bool,
}
impl Default for AmountModificationStrategy {
fn default() -> Self {
Self {
min_multiplier: 2.0,
max_multiplier: 10.0,
prefer_round_numbers: false,
rebalance_entry: true, }
}
}
impl InjectionStrategy for AmountModificationStrategy {
fn name(&self) -> &'static str {
"AmountModification"
}
fn can_apply(&self, entry: &JournalEntry) -> bool {
!entry.lines.is_empty()
}
fn apply<R: Rng>(
&self,
entry: &mut JournalEntry,
anomaly_type: &AnomalyType,
rng: &mut R,
) -> InjectionResult {
if entry.lines.is_empty() {
return InjectionResult::failure("No lines to modify");
}
let line_idx = rng.random_range(0..entry.lines.len());
let is_debit = entry.lines[line_idx].debit_amount > Decimal::ZERO;
let original_amount = if is_debit {
entry.lines[line_idx].debit_amount
} else {
entry.lines[line_idx].credit_amount
};
let multiplier = rng.random_range(self.min_multiplier..self.max_multiplier);
let mut new_amount =
original_amount * Decimal::from_f64_retain(multiplier).unwrap_or(Decimal::ONE);
if self.prefer_round_numbers {
let abs_amount = new_amount.abs();
let magnitude = if abs_amount >= Decimal::ONE {
let digits = abs_amount
.to_string()
.split('.')
.next()
.map(|s| s.trim_start_matches('-').len())
.unwrap_or(1);
(digits as i32 - 1).max(0)
} else {
0
};
let round_factor = Decimal::new(10_i64.pow(magnitude as u32), 0);
new_amount = (new_amount / round_factor).round() * round_factor;
}
let impact = new_amount - original_amount;
let account_code = entry.lines[line_idx].account_code.clone();
if is_debit {
entry.lines[line_idx].debit_amount = new_amount;
} else {
entry.lines[line_idx].credit_amount = new_amount;
}
if self.rebalance_entry {
let balancing_idx = entry.lines.iter().position(|l| {
if is_debit {
l.credit_amount > Decimal::ZERO
} else {
l.debit_amount > Decimal::ZERO
}
});
if let Some(bal_idx) = balancing_idx {
if is_debit {
entry.lines[bal_idx].credit_amount += impact;
} else {
entry.lines[bal_idx].debit_amount += impact;
}
}
}
match anomaly_type {
AnomalyType::Fraud(FraudType::RoundDollarManipulation) => {
InjectionResult::success(&format!(
"Modified amount from {} to {} (round dollar){}",
original_amount,
new_amount,
if self.rebalance_entry {
" [rebalanced]"
} else {
" [UNBALANCED]"
}
))
.with_impact(impact)
.with_entity(&account_code)
}
AnomalyType::Statistical(StatisticalAnomalyType::UnusuallyHighAmount) => {
InjectionResult::success(&format!(
"Inflated amount by {:.1}x to {}{}",
multiplier,
new_amount,
if self.rebalance_entry {
" [rebalanced]"
} else {
" [UNBALANCED]"
}
))
.with_impact(impact)
.with_metadata("multiplier", &format!("{multiplier:.2}"))
}
_ => InjectionResult::success(&format!(
"Modified amount to {}{}",
new_amount,
if self.rebalance_entry {
" [rebalanced]"
} else {
" [UNBALANCED]"
}
))
.with_impact(impact),
}
}
}
pub struct DateModificationStrategy {
pub max_backdate_days: i64,
pub max_future_days: i64,
pub cross_period_boundary: bool,
}
impl Default for DateModificationStrategy {
fn default() -> Self {
Self {
max_backdate_days: 30,
max_future_days: 7,
cross_period_boundary: true,
}
}
}
impl InjectionStrategy for DateModificationStrategy {
fn name(&self) -> &'static str {
"DateModification"
}
fn can_apply(&self, _entry: &JournalEntry) -> bool {
true
}
fn apply<R: Rng>(
&self,
entry: &mut JournalEntry,
anomaly_type: &AnomalyType,
rng: &mut R,
) -> InjectionResult {
let original_date = entry.header.posting_date;
let (days_offset, description) = match anomaly_type {
AnomalyType::Error(ErrorType::BackdatedEntry) => {
let days = rng.random_range(1..=self.max_backdate_days);
(-days, format!("Backdated by {days} days"))
}
AnomalyType::Error(ErrorType::FutureDatedEntry) => {
let days = rng.random_range(1..=self.max_future_days);
(days, format!("Future-dated by {days} days"))
}
AnomalyType::Error(ErrorType::WrongPeriod) => {
let direction: i64 = if rng.random_bool(0.5) { -1 } else { 1 };
let days = direction * 32; (days, "Posted to wrong period".to_string())
}
AnomalyType::ProcessIssue(ProcessIssueType::LatePosting) => {
let days = rng.random_range(5..=15);
entry.header.document_date = entry.header.posting_date; entry.header.posting_date = original_date + chrono::Duration::days(days);
return InjectionResult::success(&format!(
"Late posting: {days} days after transaction"
))
.with_metadata("delay_days", &days.to_string());
}
_ => (0, "Date unchanged".to_string()),
};
if days_offset != 0 {
entry.header.posting_date = original_date + chrono::Duration::days(days_offset);
}
InjectionResult::success(&description)
.with_metadata("original_date", &original_date.to_string())
.with_metadata("new_date", &entry.header.posting_date.to_string())
}
}
pub struct DuplicationStrategy {
pub vary_amounts: bool,
pub amount_variance: f64,
pub change_doc_number: bool,
}
impl Default for DuplicationStrategy {
fn default() -> Self {
Self {
vary_amounts: false,
amount_variance: 0.01,
change_doc_number: true,
}
}
}
impl DuplicationStrategy {
pub fn duplicate<R: Rng>(
&self,
entry: &JournalEntry,
rng: &mut R,
uuid_factory: &DeterministicUuidFactory,
) -> JournalEntry {
let mut duplicate = entry.clone();
if self.change_doc_number {
duplicate.header.document_id = uuid_factory.next();
for line in &mut duplicate.lines {
line.document_id = duplicate.header.document_id;
}
}
if self.vary_amounts {
for line in &mut duplicate.lines {
let variance = 1.0 + rng.random_range(-self.amount_variance..self.amount_variance);
let variance_dec = Decimal::from_f64_retain(variance).unwrap_or(Decimal::ONE);
if line.debit_amount > Decimal::ZERO {
line.debit_amount = (line.debit_amount * variance_dec).round_dp(2);
}
if line.credit_amount > Decimal::ZERO {
line.credit_amount = (line.credit_amount * variance_dec).round_dp(2);
}
}
}
duplicate
}
}
pub struct ApprovalAnomalyStrategy {
pub approval_threshold: Decimal,
pub threshold_buffer: Decimal,
}
impl Default for ApprovalAnomalyStrategy {
fn default() -> Self {
Self {
approval_threshold: Decimal::new(10000, 0),
threshold_buffer: Decimal::new(100, 0),
}
}
}
impl InjectionStrategy for ApprovalAnomalyStrategy {
fn name(&self) -> &'static str {
"ApprovalAnomaly"
}
fn can_apply(&self, entry: &JournalEntry) -> bool {
entry.total_debit() > Decimal::ZERO
}
fn apply<R: Rng>(
&self,
entry: &mut JournalEntry,
anomaly_type: &AnomalyType,
rng: &mut R,
) -> InjectionResult {
match anomaly_type {
AnomalyType::Fraud(FraudType::JustBelowThreshold) => {
let target = self.approval_threshold
- self.threshold_buffer
- Decimal::new(rng.random_range(1..50), 0);
let current_total = entry.total_debit();
if current_total == Decimal::ZERO {
return InjectionResult::failure("Cannot scale zero amount");
}
let scale = target / current_total;
for line in &mut entry.lines {
line.debit_amount = (line.debit_amount * scale).round_dp(2);
line.credit_amount = (line.credit_amount * scale).round_dp(2);
}
InjectionResult::success(&format!(
"Adjusted total to {} (just below threshold {})",
entry.total_debit(),
self.approval_threshold
))
.with_metadata("threshold", &self.approval_threshold.to_string())
}
AnomalyType::Fraud(FraudType::ExceededApprovalLimit) => {
let target = self.approval_threshold * Decimal::new(15, 1);
let current_total = entry.total_debit();
if current_total == Decimal::ZERO {
return InjectionResult::failure("Cannot scale zero amount");
}
let scale = target / current_total;
for line in &mut entry.lines {
line.debit_amount = (line.debit_amount * scale).round_dp(2);
line.credit_amount = (line.credit_amount * scale).round_dp(2);
}
InjectionResult::success(&format!(
"Exceeded approval limit: {} vs limit {}",
entry.total_debit(),
self.approval_threshold
))
.with_impact(entry.total_debit() - self.approval_threshold)
}
_ => InjectionResult::failure("Unsupported anomaly type for this strategy"),
}
}
}
pub struct DescriptionAnomalyStrategy {
pub vague_descriptions: Vec<String>,
}
impl Default for DescriptionAnomalyStrategy {
fn default() -> Self {
Self {
vague_descriptions: vec![
"Misc".to_string(),
"Adjustment".to_string(),
"Correction".to_string(),
"Various".to_string(),
"Other".to_string(),
"TBD".to_string(),
"See attachment".to_string(),
"As discussed".to_string(),
"Per management".to_string(),
".".to_string(),
"xxx".to_string(),
"test".to_string(),
],
}
}
}
impl InjectionStrategy for DescriptionAnomalyStrategy {
fn name(&self) -> &'static str {
"DescriptionAnomaly"
}
fn can_apply(&self, _entry: &JournalEntry) -> bool {
true
}
fn apply<R: Rng>(
&self,
entry: &mut JournalEntry,
_anomaly_type: &AnomalyType,
rng: &mut R,
) -> InjectionResult {
let original = entry.description().unwrap_or("").to_string();
let vague = &self.vague_descriptions[rng.random_range(0..self.vague_descriptions.len())];
entry.set_description(vague.clone());
InjectionResult::success(&format!(
"Changed description from '{original}' to '{vague}'"
))
.with_metadata("original_description", &original)
}
}
pub struct BenfordViolationStrategy {
pub target_digits: Vec<u32>,
pub rebalance_entry: bool,
}
impl Default for BenfordViolationStrategy {
fn default() -> Self {
Self {
target_digits: vec![5, 6, 7, 8, 9], rebalance_entry: true, }
}
}
impl InjectionStrategy for BenfordViolationStrategy {
fn name(&self) -> &'static str {
"BenfordViolation"
}
fn can_apply(&self, entry: &JournalEntry) -> bool {
!entry.lines.is_empty()
}
fn apply<R: Rng>(
&self,
entry: &mut JournalEntry,
_anomaly_type: &AnomalyType,
rng: &mut R,
) -> InjectionResult {
if entry.lines.is_empty() {
return InjectionResult::failure("No lines to modify");
}
let line_idx = rng.random_range(0..entry.lines.len());
let is_debit = entry.lines[line_idx].debit_amount > Decimal::ZERO;
let original_amount = if is_debit {
entry.lines[line_idx].debit_amount
} else {
entry.lines[line_idx].credit_amount
};
let target_digit = self.target_digits[rng.random_range(0..self.target_digits.len())];
let original_str = original_amount.to_string();
let magnitude = original_str.replace('.', "").trim_start_matches('0').len() as i32 - 1;
let safe_magnitude = magnitude.clamp(0, 18) as u32;
let base = Decimal::new(10_i64.pow(safe_magnitude), 0);
let new_amount = base * Decimal::new(target_digit as i64, 0)
+ Decimal::new(rng.random_range(0..10_i64.pow(safe_magnitude)), 0);
let impact = new_amount - original_amount;
if is_debit {
entry.lines[line_idx].debit_amount = new_amount;
} else {
entry.lines[line_idx].credit_amount = new_amount;
}
if self.rebalance_entry {
let balancing_idx = entry.lines.iter().position(|l| {
if is_debit {
l.credit_amount > Decimal::ZERO
} else {
l.debit_amount > Decimal::ZERO
}
});
if let Some(bal_idx) = balancing_idx {
if is_debit {
entry.lines[bal_idx].credit_amount += impact;
} else {
entry.lines[bal_idx].debit_amount += impact;
}
}
}
let first_digit = target_digit;
let benford_prob = (1.0 + 1.0 / first_digit as f64).log10();
InjectionResult::success(&format!(
"Created Benford violation: first digit {} (expected probability {:.1}%){}",
first_digit,
benford_prob * 100.0,
if self.rebalance_entry {
" [rebalanced]"
} else {
" [UNBALANCED]"
}
))
.with_impact(impact)
.with_metadata("first_digit", &first_digit.to_string())
.with_metadata("benford_probability", &format!("{benford_prob:.4}"))
}
}
pub struct SplitTransactionStrategy {
pub split_threshold: Decimal,
pub min_splits: usize,
pub max_splits: usize,
pub threshold_buffer: Decimal,
}
impl Default for SplitTransactionStrategy {
fn default() -> Self {
Self {
split_threshold: Decimal::new(10000, 0),
min_splits: 2,
max_splits: 5,
threshold_buffer: Decimal::new(500, 0),
}
}
}
impl InjectionStrategy for SplitTransactionStrategy {
fn name(&self) -> &'static str {
"SplitTransaction"
}
fn can_apply(&self, entry: &JournalEntry) -> bool {
entry.total_debit() > self.split_threshold
}
fn apply<R: Rng>(
&self,
entry: &mut JournalEntry,
_anomaly_type: &AnomalyType,
rng: &mut R,
) -> InjectionResult {
let total = entry.total_debit();
if total <= self.split_threshold || total.is_zero() {
return InjectionResult::failure("Amount below split threshold");
}
let num_splits = rng.random_range(self.min_splits..=self.max_splits);
let target_per_split = self.split_threshold
- self.threshold_buffer
- Decimal::new(rng.random_range(1..100), 0);
let scale = target_per_split / total;
for line in &mut entry.lines {
line.debit_amount = (line.debit_amount * scale).round_dp(2);
line.credit_amount = (line.credit_amount * scale).round_dp(2);
}
InjectionResult::success(&format!(
"Split ${} transaction into {} parts of ~${} each (below ${} threshold)",
total, num_splits, target_per_split, self.split_threshold
))
.with_impact(total)
.with_metadata("original_amount", &total.to_string())
.with_metadata("num_splits", &num_splits.to_string())
.with_metadata("threshold", &self.split_threshold.to_string())
}
}
pub struct SkippedApprovalStrategy {
pub approval_threshold: Decimal,
}
impl Default for SkippedApprovalStrategy {
fn default() -> Self {
Self {
approval_threshold: Decimal::new(5000, 0),
}
}
}
impl InjectionStrategy for SkippedApprovalStrategy {
fn name(&self) -> &'static str {
"SkippedApproval"
}
fn can_apply(&self, entry: &JournalEntry) -> bool {
entry.total_debit() > self.approval_threshold
}
fn apply<R: Rng>(
&self,
entry: &mut JournalEntry,
_anomaly_type: &AnomalyType,
_rng: &mut R,
) -> InjectionResult {
let amount = entry.total_debit();
if amount <= self.approval_threshold {
return InjectionResult::failure("Amount below approval threshold");
}
entry.header.control_status = ControlStatus::Exception;
entry.header.sod_violation = true;
InjectionResult::success(&format!(
"Skipped required approval for ${} entry (threshold: ${})",
amount, self.approval_threshold
))
.with_impact(amount)
.with_metadata("threshold", &self.approval_threshold.to_string())
}
}
pub struct WeekendPostingStrategy;
impl Default for WeekendPostingStrategy {
fn default() -> Self {
Self
}
}
impl InjectionStrategy for WeekendPostingStrategy {
fn name(&self) -> &'static str {
"WeekendPosting"
}
fn can_apply(&self, _entry: &JournalEntry) -> bool {
true
}
fn apply<R: Rng>(
&self,
entry: &mut JournalEntry,
_anomaly_type: &AnomalyType,
rng: &mut R,
) -> InjectionResult {
use chrono::Weekday;
let original_date = entry.header.posting_date;
let weekday = original_date.weekday();
let days_to_weekend = match weekday {
Weekday::Mon => 5,
Weekday::Tue => 4,
Weekday::Wed => 3,
Weekday::Thu => 2,
Weekday::Fri => 1,
Weekday::Sat => 0,
Weekday::Sun => 0,
};
let weekend_day = if rng.random_bool(0.6) {
days_to_weekend
} else {
days_to_weekend + 1
};
let new_date = original_date + chrono::Duration::days(weekend_day as i64);
entry.header.posting_date = new_date;
InjectionResult::success(&format!(
"Moved posting from {} ({:?}) to {} ({:?})",
original_date,
weekday,
new_date,
new_date.weekday()
))
.with_metadata("original_date", &original_date.to_string())
.with_metadata("new_date", &new_date.to_string())
}
}
pub struct ReversedAmountStrategy;
impl Default for ReversedAmountStrategy {
fn default() -> Self {
Self
}
}
impl InjectionStrategy for ReversedAmountStrategy {
fn name(&self) -> &'static str {
"ReversedAmount"
}
fn can_apply(&self, entry: &JournalEntry) -> bool {
entry.lines.len() >= 2
}
fn apply<R: Rng>(
&self,
entry: &mut JournalEntry,
_anomaly_type: &AnomalyType,
rng: &mut R,
) -> InjectionResult {
if entry.lines.len() < 2 {
return InjectionResult::failure("Need at least 2 lines to reverse");
}
let line_idx = rng.random_range(0..entry.lines.len());
let line = &mut entry.lines[line_idx];
let original_debit = line.debit_amount;
let original_credit = line.credit_amount;
line.debit_amount = original_credit;
line.credit_amount = original_debit;
let impact = original_debit.max(original_credit);
InjectionResult::success(&format!(
"Reversed amounts on line {}: DR {} → CR {}, CR {} → DR {}",
line_idx + 1,
original_debit,
line.credit_amount,
original_credit,
line.debit_amount
))
.with_impact(impact * Decimal::new(2, 0)) .with_metadata("line_number", &(line_idx + 1).to_string())
}
}
pub struct TransposedDigitsStrategy;
impl Default for TransposedDigitsStrategy {
fn default() -> Self {
Self
}
}
impl InjectionStrategy for TransposedDigitsStrategy {
fn name(&self) -> &'static str {
"TransposedDigits"
}
fn can_apply(&self, entry: &JournalEntry) -> bool {
entry.lines.iter().any(|l| {
let amount = if l.debit_amount > Decimal::ZERO {
l.debit_amount
} else {
l.credit_amount
};
amount >= Decimal::new(10, 0)
})
}
fn apply<R: Rng>(
&self,
entry: &mut JournalEntry,
_anomaly_type: &AnomalyType,
rng: &mut R,
) -> InjectionResult {
let valid_lines: Vec<usize> = entry
.lines
.iter()
.enumerate()
.filter(|(_, l)| {
let amount = if l.debit_amount > Decimal::ZERO {
l.debit_amount
} else {
l.credit_amount
};
amount >= Decimal::new(10, 0)
})
.map(|(i, _)| i)
.collect();
if valid_lines.is_empty() {
return InjectionResult::failure("No lines with transposable amounts");
}
let line_idx = valid_lines[rng.random_range(0..valid_lines.len())];
let line = &mut entry.lines[line_idx];
let is_debit = line.debit_amount > Decimal::ZERO;
let original_amount = if is_debit {
line.debit_amount
} else {
line.credit_amount
};
let amount_str = original_amount.to_string().replace('.', "");
let chars: Vec<char> = amount_str.chars().collect();
if chars.len() < 2 {
return InjectionResult::failure("Amount too small to transpose");
}
let pos = rng.random_range(0..chars.len() - 1);
let mut new_chars = chars.clone();
new_chars.swap(pos, pos + 1);
let new_str: String = new_chars.into_iter().collect();
let new_amount = new_str.parse::<i64>().unwrap_or(0);
let scale = original_amount.scale();
let new_decimal = Decimal::new(new_amount, scale);
let impact = (new_decimal - original_amount).abs();
if is_debit {
line.debit_amount = new_decimal;
} else {
line.credit_amount = new_decimal;
}
InjectionResult::success(&format!(
"Transposed digits: {} → {} (positions {} and {})",
original_amount,
new_decimal,
pos + 1,
pos + 2
))
.with_impact(impact)
.with_metadata("original_amount", &original_amount.to_string())
.with_metadata("new_amount", &new_decimal.to_string())
}
}
pub struct DormantAccountStrategy {
pub dormant_accounts: Vec<String>,
}
impl Default for DormantAccountStrategy {
fn default() -> Self {
Self {
dormant_accounts: vec![
"199999".to_string(), "299999".to_string(), "399999".to_string(), "999999".to_string(), ],
}
}
}
impl InjectionStrategy for DormantAccountStrategy {
fn name(&self) -> &'static str {
"DormantAccountActivity"
}
fn can_apply(&self, entry: &JournalEntry) -> bool {
!entry.lines.is_empty() && !self.dormant_accounts.is_empty()
}
fn apply<R: Rng>(
&self,
entry: &mut JournalEntry,
_anomaly_type: &AnomalyType,
rng: &mut R,
) -> InjectionResult {
if entry.lines.is_empty() || self.dormant_accounts.is_empty() {
return InjectionResult::failure("No lines or dormant accounts");
}
let line_idx = rng.random_range(0..entry.lines.len());
let line = &mut entry.lines[line_idx];
let original_account = line.gl_account.clone();
let dormant_account =
&self.dormant_accounts[rng.random_range(0..self.dormant_accounts.len())];
line.gl_account = dormant_account.clone();
line.account_code = dormant_account.clone();
let amount = if line.debit_amount > Decimal::ZERO {
line.debit_amount
} else {
line.credit_amount
};
InjectionResult::success(&format!(
"Changed account from {original_account} to dormant account {dormant_account}"
))
.with_impact(amount)
.with_entity(dormant_account)
.with_metadata("original_account", &original_account)
}
}
#[derive(Default)]
pub struct StrategyCollection {
pub amount_modification: AmountModificationStrategy,
pub date_modification: DateModificationStrategy,
pub duplication: DuplicationStrategy,
pub approval_anomaly: ApprovalAnomalyStrategy,
pub description_anomaly: DescriptionAnomalyStrategy,
pub benford_violation: BenfordViolationStrategy,
pub split_transaction: SplitTransactionStrategy,
pub skipped_approval: SkippedApprovalStrategy,
pub weekend_posting: WeekendPostingStrategy,
pub reversed_amount: ReversedAmountStrategy,
pub transposed_digits: TransposedDigitsStrategy,
pub dormant_account: DormantAccountStrategy,
}
impl StrategyCollection {
pub fn can_apply(&self, entry: &JournalEntry, anomaly_type: &AnomalyType) -> bool {
match anomaly_type {
AnomalyType::Fraud(FraudType::RoundDollarManipulation)
| AnomalyType::Statistical(StatisticalAnomalyType::UnusuallyHighAmount)
| AnomalyType::Statistical(StatisticalAnomalyType::UnusuallyLowAmount) => {
self.amount_modification.can_apply(entry)
}
AnomalyType::Error(ErrorType::BackdatedEntry)
| AnomalyType::Error(ErrorType::FutureDatedEntry)
| AnomalyType::Error(ErrorType::WrongPeriod)
| AnomalyType::ProcessIssue(ProcessIssueType::LatePosting) => {
self.date_modification.can_apply(entry)
}
AnomalyType::Fraud(FraudType::JustBelowThreshold)
| AnomalyType::Fraud(FraudType::ExceededApprovalLimit) => {
self.approval_anomaly.can_apply(entry)
}
AnomalyType::ProcessIssue(ProcessIssueType::VagueDescription) => {
self.description_anomaly.can_apply(entry)
}
AnomalyType::Statistical(StatisticalAnomalyType::BenfordViolation) => {
self.benford_violation.can_apply(entry)
}
AnomalyType::Fraud(FraudType::SplitTransaction) => {
self.split_transaction.can_apply(entry)
}
AnomalyType::ProcessIssue(ProcessIssueType::SkippedApproval) => {
self.skipped_approval.can_apply(entry)
}
AnomalyType::ProcessIssue(ProcessIssueType::WeekendPosting)
| AnomalyType::ProcessIssue(ProcessIssueType::AfterHoursPosting) => {
self.weekend_posting.can_apply(entry)
}
AnomalyType::Error(ErrorType::ReversedAmount) => self.reversed_amount.can_apply(entry),
AnomalyType::Error(ErrorType::TransposedDigits) => {
self.transposed_digits.can_apply(entry)
}
AnomalyType::Relational(RelationalAnomalyType::DormantAccountActivity) => {
self.dormant_account.can_apply(entry)
}
_ => self.amount_modification.can_apply(entry),
}
}
pub fn apply_strategy<R: Rng>(
&self,
entry: &mut JournalEntry,
anomaly_type: &AnomalyType,
rng: &mut R,
) -> InjectionResult {
match anomaly_type {
AnomalyType::Fraud(FraudType::RoundDollarManipulation)
| AnomalyType::Statistical(StatisticalAnomalyType::UnusuallyHighAmount)
| AnomalyType::Statistical(StatisticalAnomalyType::UnusuallyLowAmount) => {
self.amount_modification.apply(entry, anomaly_type, rng)
}
AnomalyType::Error(ErrorType::BackdatedEntry)
| AnomalyType::Error(ErrorType::FutureDatedEntry)
| AnomalyType::Error(ErrorType::WrongPeriod)
| AnomalyType::ProcessIssue(ProcessIssueType::LatePosting) => {
self.date_modification.apply(entry, anomaly_type, rng)
}
AnomalyType::Fraud(FraudType::JustBelowThreshold)
| AnomalyType::Fraud(FraudType::ExceededApprovalLimit) => {
self.approval_anomaly.apply(entry, anomaly_type, rng)
}
AnomalyType::ProcessIssue(ProcessIssueType::VagueDescription) => {
self.description_anomaly.apply(entry, anomaly_type, rng)
}
AnomalyType::Statistical(StatisticalAnomalyType::BenfordViolation) => {
self.benford_violation.apply(entry, anomaly_type, rng)
}
AnomalyType::Fraud(FraudType::SplitTransaction) => {
self.split_transaction.apply(entry, anomaly_type, rng)
}
AnomalyType::ProcessIssue(ProcessIssueType::SkippedApproval) => {
self.skipped_approval.apply(entry, anomaly_type, rng)
}
AnomalyType::ProcessIssue(ProcessIssueType::WeekendPosting)
| AnomalyType::ProcessIssue(ProcessIssueType::AfterHoursPosting) => {
self.weekend_posting.apply(entry, anomaly_type, rng)
}
AnomalyType::Error(ErrorType::ReversedAmount) => {
self.reversed_amount.apply(entry, anomaly_type, rng)
}
AnomalyType::Error(ErrorType::TransposedDigits) => {
self.transposed_digits.apply(entry, anomaly_type, rng)
}
AnomalyType::Relational(RelationalAnomalyType::DormantAccountActivity) => {
self.dormant_account.apply(entry, anomaly_type, rng)
}
_ => self.amount_modification.apply(entry, anomaly_type, rng),
}
}
}
#[cfg(test)]
#[allow(clippy::unwrap_used)]
mod tests {
use super::*;
use chrono::NaiveDate;
use datasynth_core::models::JournalEntryLine;
use rand::SeedableRng;
use rand_chacha::ChaCha8Rng;
use rust_decimal_macros::dec;
fn create_test_entry() -> JournalEntry {
let mut entry = JournalEntry::new_simple(
"JE001".to_string(),
"1000".to_string(),
NaiveDate::from_ymd_opt(2024, 6, 15).unwrap(),
"Test Entry".to_string(),
);
entry.add_line(JournalEntryLine {
line_number: 1,
gl_account: "5000".to_string(),
debit_amount: dec!(1000),
..Default::default()
});
entry.add_line(JournalEntryLine {
line_number: 2,
gl_account: "1000".to_string(),
credit_amount: dec!(1000),
..Default::default()
});
entry
}
#[test]
fn test_amount_modification() {
let strategy = AmountModificationStrategy::default();
let mut entry = create_test_entry();
let mut rng = ChaCha8Rng::seed_from_u64(42);
let result = strategy.apply(
&mut entry,
&AnomalyType::Statistical(StatisticalAnomalyType::UnusuallyHighAmount),
&mut rng,
);
assert!(result.success);
assert!(result.monetary_impact.is_some());
}
#[test]
fn test_amount_modification_rebalanced() {
let strategy = AmountModificationStrategy {
rebalance_entry: true,
..Default::default()
};
let mut entry = create_test_entry();
let mut rng = ChaCha8Rng::seed_from_u64(42);
assert!(entry.is_balanced());
let result = strategy.apply(
&mut entry,
&AnomalyType::Statistical(StatisticalAnomalyType::UnusuallyHighAmount),
&mut rng,
);
assert!(result.success);
assert!(
entry.is_balanced(),
"Entry should remain balanced after amount modification with rebalancing"
);
}
#[test]
fn test_amount_modification_unbalanced_fraud() {
let strategy = AmountModificationStrategy {
rebalance_entry: false, ..Default::default()
};
let mut entry = create_test_entry();
let mut rng = ChaCha8Rng::seed_from_u64(42);
assert!(entry.is_balanced());
let result = strategy.apply(
&mut entry,
&AnomalyType::Fraud(FraudType::RoundDollarManipulation),
&mut rng,
);
assert!(result.success);
assert!(
!entry.is_balanced(),
"Entry should be unbalanced when rebalance_entry is false"
);
}
#[test]
fn test_benford_violation_rebalanced() {
let strategy = BenfordViolationStrategy {
rebalance_entry: true,
..Default::default()
};
let mut entry = create_test_entry();
let mut rng = ChaCha8Rng::seed_from_u64(42);
assert!(entry.is_balanced());
let result = strategy.apply(
&mut entry,
&AnomalyType::Statistical(StatisticalAnomalyType::BenfordViolation),
&mut rng,
);
assert!(result.success);
assert!(
entry.is_balanced(),
"Entry should remain balanced after Benford violation with rebalancing"
);
}
#[test]
fn test_date_modification() {
let strategy = DateModificationStrategy::default();
let mut entry = create_test_entry();
let original_date = entry.header.posting_date;
let mut rng = ChaCha8Rng::seed_from_u64(42);
let result = strategy.apply(
&mut entry,
&AnomalyType::Error(ErrorType::BackdatedEntry),
&mut rng,
);
assert!(result.success);
assert!(entry.header.posting_date < original_date);
}
#[test]
fn test_description_anomaly() {
let strategy = DescriptionAnomalyStrategy::default();
let mut entry = create_test_entry();
let mut rng = ChaCha8Rng::seed_from_u64(42);
let result = strategy.apply(
&mut entry,
&AnomalyType::ProcessIssue(ProcessIssueType::VagueDescription),
&mut rng,
);
assert!(result.success);
let desc = entry.description().unwrap_or("").to_string();
assert!(strategy.vague_descriptions.contains(&desc));
}
}