Skip to main content

datasynth_generators/anomaly/
strategies.rs

1//! Injection strategies for anomaly generation.
2//!
3//! Strategies determine how anomalies are applied to existing data.
4
5use chrono::Datelike;
6use rand::Rng;
7use rust_decimal::Decimal;
8
9use datasynth_core::models::{
10    AnomalyType, ControlStatus, ErrorType, FraudType, JournalEntry, ProcessIssueType,
11    RelationalAnomalyType, StatisticalAnomalyType,
12};
13
14/// Base trait for injection strategies.
15pub trait InjectionStrategy {
16    /// Name of the strategy.
17    fn name(&self) -> &'static str;
18
19    /// Whether this strategy can be applied to the given entry.
20    fn can_apply(&self, entry: &JournalEntry) -> bool;
21
22    /// Applies the strategy to modify an entry.
23    fn apply<R: Rng>(
24        &self,
25        entry: &mut JournalEntry,
26        anomaly_type: &AnomalyType,
27        rng: &mut R,
28    ) -> InjectionResult;
29}
30
31/// Result of an injection attempt.
32#[derive(Debug, Clone)]
33pub struct InjectionResult {
34    /// Whether the injection was successful.
35    pub success: bool,
36    /// Description of what was modified.
37    pub description: String,
38    /// Monetary impact of the anomaly.
39    pub monetary_impact: Option<Decimal>,
40    /// Related entity IDs.
41    pub related_entities: Vec<String>,
42    /// Additional metadata.
43    pub metadata: Vec<(String, String)>,
44}
45
46impl InjectionResult {
47    /// Creates a successful result.
48    pub fn success(description: &str) -> Self {
49        Self {
50            success: true,
51            description: description.to_string(),
52            monetary_impact: None,
53            related_entities: Vec::new(),
54            metadata: Vec::new(),
55        }
56    }
57
58    /// Creates a failed result.
59    pub fn failure(reason: &str) -> Self {
60        Self {
61            success: false,
62            description: reason.to_string(),
63            monetary_impact: None,
64            related_entities: Vec::new(),
65            metadata: Vec::new(),
66        }
67    }
68
69    /// Adds monetary impact.
70    pub fn with_impact(mut self, impact: Decimal) -> Self {
71        self.monetary_impact = Some(impact);
72        self
73    }
74
75    /// Adds a related entity.
76    pub fn with_entity(mut self, entity: &str) -> Self {
77        self.related_entities.push(entity.to_string());
78        self
79    }
80
81    /// Adds metadata.
82    pub fn with_metadata(mut self, key: &str, value: &str) -> Self {
83        self.metadata.push((key.to_string(), value.to_string()));
84        self
85    }
86}
87
88/// Strategy for modifying amounts.
89pub struct AmountModificationStrategy {
90    /// Minimum multiplier for amount changes.
91    pub min_multiplier: f64,
92    /// Maximum multiplier for amount changes.
93    pub max_multiplier: f64,
94    /// Whether to use round numbers.
95    pub prefer_round_numbers: bool,
96    /// Whether to rebalance the entry after modification.
97    /// If true, a corresponding line will be adjusted to maintain balance.
98    /// If false, the entry will become unbalanced (for intentional fraud detection).
99    pub rebalance_entry: bool,
100}
101
102impl Default for AmountModificationStrategy {
103    fn default() -> Self {
104        Self {
105            min_multiplier: 2.0,
106            max_multiplier: 10.0,
107            prefer_round_numbers: false,
108            rebalance_entry: true, // Default to maintaining balance
109        }
110    }
111}
112
113impl InjectionStrategy for AmountModificationStrategy {
114    fn name(&self) -> &'static str {
115        "AmountModification"
116    }
117
118    fn can_apply(&self, entry: &JournalEntry) -> bool {
119        !entry.lines.is_empty()
120    }
121
122    fn apply<R: Rng>(
123        &self,
124        entry: &mut JournalEntry,
125        anomaly_type: &AnomalyType,
126        rng: &mut R,
127    ) -> InjectionResult {
128        if entry.lines.is_empty() {
129            return InjectionResult::failure("No lines to modify");
130        }
131
132        let line_idx = rng.gen_range(0..entry.lines.len());
133        let is_debit = entry.lines[line_idx].debit_amount > Decimal::ZERO;
134        let original_amount = if is_debit {
135            entry.lines[line_idx].debit_amount
136        } else {
137            entry.lines[line_idx].credit_amount
138        };
139
140        let multiplier = rng.gen_range(self.min_multiplier..self.max_multiplier);
141        let mut new_amount =
142            original_amount * Decimal::from_f64_retain(multiplier).unwrap_or(Decimal::ONE);
143
144        // Round to nice number if preferred
145        if self.prefer_round_numbers {
146            let magnitude = new_amount.to_string().len() as i32 - 2;
147            let round_factor = Decimal::new(10_i64.pow(magnitude.max(0) as u32), 0);
148            new_amount = (new_amount / round_factor).round() * round_factor;
149        }
150
151        let impact = new_amount - original_amount;
152        let account_code = entry.lines[line_idx].account_code.clone();
153
154        // Apply the modification
155        if is_debit {
156            entry.lines[line_idx].debit_amount = new_amount;
157        } else {
158            entry.lines[line_idx].credit_amount = new_amount;
159        }
160
161        // Rebalance the entry if configured to do so
162        if self.rebalance_entry {
163            // Find a line on the opposite side to adjust
164            let balancing_idx = entry.lines.iter().position(|l| {
165                if is_debit {
166                    l.credit_amount > Decimal::ZERO
167                } else {
168                    l.debit_amount > Decimal::ZERO
169                }
170            });
171
172            if let Some(bal_idx) = balancing_idx {
173                // Adjust the balancing line by the same impact
174                if is_debit {
175                    entry.lines[bal_idx].credit_amount += impact;
176                } else {
177                    entry.lines[bal_idx].debit_amount += impact;
178                }
179            }
180        }
181
182        match anomaly_type {
183            AnomalyType::Fraud(FraudType::RoundDollarManipulation) => {
184                InjectionResult::success(&format!(
185                    "Modified amount from {} to {} (round dollar){}",
186                    original_amount,
187                    new_amount,
188                    if self.rebalance_entry {
189                        " [rebalanced]"
190                    } else {
191                        " [UNBALANCED]"
192                    }
193                ))
194                .with_impact(impact)
195                .with_entity(&account_code)
196            }
197            AnomalyType::Statistical(StatisticalAnomalyType::UnusuallyHighAmount) => {
198                InjectionResult::success(&format!(
199                    "Inflated amount by {:.1}x to {}{}",
200                    multiplier,
201                    new_amount,
202                    if self.rebalance_entry {
203                        " [rebalanced]"
204                    } else {
205                        " [UNBALANCED]"
206                    }
207                ))
208                .with_impact(impact)
209                .with_metadata("multiplier", &format!("{:.2}", multiplier))
210            }
211            _ => InjectionResult::success(&format!(
212                "Modified amount to {}{}",
213                new_amount,
214                if self.rebalance_entry {
215                    " [rebalanced]"
216                } else {
217                    " [UNBALANCED]"
218                }
219            ))
220            .with_impact(impact),
221        }
222    }
223}
224
225/// Strategy for modifying dates.
226pub struct DateModificationStrategy {
227    /// Maximum days to backdate.
228    pub max_backdate_days: i64,
229    /// Maximum days to future-date.
230    pub max_future_days: i64,
231    /// Whether to cross period boundaries.
232    pub cross_period_boundary: bool,
233}
234
235impl Default for DateModificationStrategy {
236    fn default() -> Self {
237        Self {
238            max_backdate_days: 30,
239            max_future_days: 7,
240            cross_period_boundary: true,
241        }
242    }
243}
244
245impl InjectionStrategy for DateModificationStrategy {
246    fn name(&self) -> &'static str {
247        "DateModification"
248    }
249
250    fn can_apply(&self, _entry: &JournalEntry) -> bool {
251        true
252    }
253
254    fn apply<R: Rng>(
255        &self,
256        entry: &mut JournalEntry,
257        anomaly_type: &AnomalyType,
258        rng: &mut R,
259    ) -> InjectionResult {
260        let original_date = entry.header.posting_date;
261
262        let (days_offset, description) = match anomaly_type {
263            AnomalyType::Error(ErrorType::BackdatedEntry) => {
264                let days = rng.gen_range(1..=self.max_backdate_days);
265                (-days, format!("Backdated by {} days", days))
266            }
267            AnomalyType::Error(ErrorType::FutureDatedEntry) => {
268                let days = rng.gen_range(1..=self.max_future_days);
269                (days, format!("Future-dated by {} days", days))
270            }
271            AnomalyType::Error(ErrorType::WrongPeriod) => {
272                // Move to previous or next month
273                let direction: i64 = if rng.gen_bool(0.5) { -1 } else { 1 };
274                let days = direction * 32; // Ensure crossing month boundary
275                (days, "Posted to wrong period".to_string())
276            }
277            AnomalyType::ProcessIssue(ProcessIssueType::LatePosting) => {
278                let days = rng.gen_range(5..=15);
279                entry.header.document_date = entry.header.posting_date; // Document date stays same
280                entry.header.posting_date = original_date + chrono::Duration::days(days);
281                return InjectionResult::success(&format!(
282                    "Late posting: {} days after transaction",
283                    days
284                ))
285                .with_metadata("delay_days", &days.to_string());
286            }
287            _ => (0, "Date unchanged".to_string()),
288        };
289
290        if days_offset != 0 {
291            entry.header.posting_date = original_date + chrono::Duration::days(days_offset);
292        }
293
294        InjectionResult::success(&description)
295            .with_metadata("original_date", &original_date.to_string())
296            .with_metadata("new_date", &entry.header.posting_date.to_string())
297    }
298}
299
300/// Strategy for document duplication.
301pub struct DuplicationStrategy {
302    /// Whether to modify amounts slightly.
303    pub vary_amounts: bool,
304    /// Amount variance factor.
305    pub amount_variance: f64,
306    /// Whether to change document numbers.
307    pub change_doc_number: bool,
308}
309
310impl Default for DuplicationStrategy {
311    fn default() -> Self {
312        Self {
313            vary_amounts: false,
314            amount_variance: 0.01,
315            change_doc_number: true,
316        }
317    }
318}
319
320impl DuplicationStrategy {
321    /// Creates a duplicate of the entry.
322    pub fn duplicate<R: Rng>(&self, entry: &JournalEntry, rng: &mut R) -> JournalEntry {
323        let mut duplicate = entry.clone();
324
325        if self.change_doc_number {
326            // Generate a new UUID for the duplicate
327            duplicate.header.document_id = uuid::Uuid::new_v4();
328            // Update line items to reference the new document ID
329            for line in &mut duplicate.lines {
330                line.document_id = duplicate.header.document_id;
331            }
332        }
333
334        if self.vary_amounts {
335            for line in &mut duplicate.lines {
336                let variance = 1.0 + rng.gen_range(-self.amount_variance..self.amount_variance);
337                let variance_dec = Decimal::from_f64_retain(variance).unwrap_or(Decimal::ONE);
338
339                if line.debit_amount > Decimal::ZERO {
340                    line.debit_amount = (line.debit_amount * variance_dec).round_dp(2);
341                }
342                if line.credit_amount > Decimal::ZERO {
343                    line.credit_amount = (line.credit_amount * variance_dec).round_dp(2);
344                }
345            }
346        }
347
348        duplicate
349    }
350}
351
352/// Strategy for approval-related anomalies.
353pub struct ApprovalAnomalyStrategy {
354    /// Approval threshold to target.
355    pub approval_threshold: Decimal,
356    /// Buffer below threshold.
357    pub threshold_buffer: Decimal,
358}
359
360impl Default for ApprovalAnomalyStrategy {
361    fn default() -> Self {
362        Self {
363            approval_threshold: Decimal::new(10000, 0),
364            threshold_buffer: Decimal::new(100, 0),
365        }
366    }
367}
368
369impl InjectionStrategy for ApprovalAnomalyStrategy {
370    fn name(&self) -> &'static str {
371        "ApprovalAnomaly"
372    }
373
374    fn can_apply(&self, entry: &JournalEntry) -> bool {
375        entry.total_debit() > Decimal::ZERO
376    }
377
378    fn apply<R: Rng>(
379        &self,
380        entry: &mut JournalEntry,
381        anomaly_type: &AnomalyType,
382        rng: &mut R,
383    ) -> InjectionResult {
384        match anomaly_type {
385            AnomalyType::Fraud(FraudType::JustBelowThreshold) => {
386                // Set total to just below threshold
387                let target = self.approval_threshold
388                    - self.threshold_buffer
389                    - Decimal::new(rng.gen_range(1..50), 0);
390
391                let current_total = entry.total_debit();
392                if current_total == Decimal::ZERO {
393                    return InjectionResult::failure("Cannot scale zero amount");
394                }
395
396                let scale = target / current_total;
397                for line in &mut entry.lines {
398                    line.debit_amount = (line.debit_amount * scale).round_dp(2);
399                    line.credit_amount = (line.credit_amount * scale).round_dp(2);
400                }
401
402                InjectionResult::success(&format!(
403                    "Adjusted total to {} (just below threshold {})",
404                    entry.total_debit(),
405                    self.approval_threshold
406                ))
407                .with_metadata("threshold", &self.approval_threshold.to_string())
408            }
409            AnomalyType::Fraud(FraudType::ExceededApprovalLimit) => {
410                // Set total to exceed threshold
411                let target = self.approval_threshold * Decimal::new(15, 1); // 1.5x threshold
412
413                let current_total = entry.total_debit();
414                if current_total == Decimal::ZERO {
415                    return InjectionResult::failure("Cannot scale zero amount");
416                }
417
418                let scale = target / current_total;
419                for line in &mut entry.lines {
420                    line.debit_amount = (line.debit_amount * scale).round_dp(2);
421                    line.credit_amount = (line.credit_amount * scale).round_dp(2);
422                }
423
424                InjectionResult::success(&format!(
425                    "Exceeded approval limit: {} vs limit {}",
426                    entry.total_debit(),
427                    self.approval_threshold
428                ))
429                .with_impact(entry.total_debit() - self.approval_threshold)
430            }
431            _ => InjectionResult::failure("Unsupported anomaly type for this strategy"),
432        }
433    }
434}
435
436/// Strategy for description/text anomalies.
437pub struct DescriptionAnomalyStrategy {
438    /// Vague descriptions to use.
439    pub vague_descriptions: Vec<String>,
440}
441
442impl Default for DescriptionAnomalyStrategy {
443    fn default() -> Self {
444        Self {
445            vague_descriptions: vec![
446                "Misc".to_string(),
447                "Adjustment".to_string(),
448                "Correction".to_string(),
449                "Various".to_string(),
450                "Other".to_string(),
451                "TBD".to_string(),
452                "See attachment".to_string(),
453                "As discussed".to_string(),
454                "Per management".to_string(),
455                ".".to_string(),
456                "xxx".to_string(),
457                "test".to_string(),
458            ],
459        }
460    }
461}
462
463impl InjectionStrategy for DescriptionAnomalyStrategy {
464    fn name(&self) -> &'static str {
465        "DescriptionAnomaly"
466    }
467
468    fn can_apply(&self, _entry: &JournalEntry) -> bool {
469        true
470    }
471
472    fn apply<R: Rng>(
473        &self,
474        entry: &mut JournalEntry,
475        _anomaly_type: &AnomalyType,
476        rng: &mut R,
477    ) -> InjectionResult {
478        let original = entry.description().unwrap_or("").to_string();
479        let vague = &self.vague_descriptions[rng.gen_range(0..self.vague_descriptions.len())];
480        entry.set_description(vague.clone());
481
482        InjectionResult::success(&format!(
483            "Changed description from '{}' to '{}'",
484            original, vague
485        ))
486        .with_metadata("original_description", &original)
487    }
488}
489
490/// Strategy for Benford's Law violations.
491pub struct BenfordViolationStrategy {
492    /// Target first digits (rarely occurring).
493    pub target_digits: Vec<u32>,
494    /// Whether to rebalance the entry after modification.
495    pub rebalance_entry: bool,
496}
497
498impl Default for BenfordViolationStrategy {
499    fn default() -> Self {
500        Self {
501            target_digits: vec![5, 6, 7, 8, 9], // Less common first digits
502            rebalance_entry: true,              // Default to maintaining balance
503        }
504    }
505}
506
507impl InjectionStrategy for BenfordViolationStrategy {
508    fn name(&self) -> &'static str {
509        "BenfordViolation"
510    }
511
512    fn can_apply(&self, entry: &JournalEntry) -> bool {
513        !entry.lines.is_empty()
514    }
515
516    fn apply<R: Rng>(
517        &self,
518        entry: &mut JournalEntry,
519        _anomaly_type: &AnomalyType,
520        rng: &mut R,
521    ) -> InjectionResult {
522        if entry.lines.is_empty() {
523            return InjectionResult::failure("No lines to modify");
524        }
525
526        let line_idx = rng.gen_range(0..entry.lines.len());
527        let is_debit = entry.lines[line_idx].debit_amount > Decimal::ZERO;
528        let original_amount = if is_debit {
529            entry.lines[line_idx].debit_amount
530        } else {
531            entry.lines[line_idx].credit_amount
532        };
533
534        // Get target first digit
535        let target_digit = self.target_digits[rng.gen_range(0..self.target_digits.len())];
536
537        // Calculate new amount with target first digit
538        let original_str = original_amount.to_string();
539        let magnitude = original_str.replace('.', "").trim_start_matches('0').len() as i32 - 1;
540        // Limit magnitude to prevent overflow (10^18 is max safe for i64)
541        let safe_magnitude = magnitude.clamp(0, 18) as u32;
542
543        let base = Decimal::new(10_i64.pow(safe_magnitude), 0);
544        let new_amount = base * Decimal::new(target_digit as i64, 0)
545            + Decimal::new(rng.gen_range(0..10_i64.pow(safe_magnitude)), 0);
546
547        let impact = new_amount - original_amount;
548
549        // Apply the modification
550        if is_debit {
551            entry.lines[line_idx].debit_amount = new_amount;
552        } else {
553            entry.lines[line_idx].credit_amount = new_amount;
554        }
555
556        // Rebalance the entry if configured to do so
557        if self.rebalance_entry {
558            // Find a line on the opposite side to adjust
559            let balancing_idx = entry.lines.iter().position(|l| {
560                if is_debit {
561                    l.credit_amount > Decimal::ZERO
562                } else {
563                    l.debit_amount > Decimal::ZERO
564                }
565            });
566
567            if let Some(bal_idx) = balancing_idx {
568                // Adjust the balancing line by the same impact
569                if is_debit {
570                    entry.lines[bal_idx].credit_amount += impact;
571                } else {
572                    entry.lines[bal_idx].debit_amount += impact;
573                }
574            }
575        }
576
577        let first_digit = target_digit;
578        let benford_prob = (1.0 + 1.0 / first_digit as f64).log10();
579
580        InjectionResult::success(&format!(
581            "Created Benford violation: first digit {} (expected probability {:.1}%){}",
582            first_digit,
583            benford_prob * 100.0,
584            if self.rebalance_entry {
585                " [rebalanced]"
586            } else {
587                " [UNBALANCED]"
588            }
589        ))
590        .with_impact(impact)
591        .with_metadata("first_digit", &first_digit.to_string())
592        .with_metadata("benford_probability", &format!("{:.4}", benford_prob))
593    }
594}
595
596/// Strategy for split transactions (structuring to avoid thresholds).
597pub struct SplitTransactionStrategy {
598    /// Threshold above which transactions are split.
599    pub split_threshold: Decimal,
600    /// Number of splits to create.
601    pub min_splits: usize,
602    pub max_splits: usize,
603    /// Buffer below threshold.
604    pub threshold_buffer: Decimal,
605}
606
607impl Default for SplitTransactionStrategy {
608    fn default() -> Self {
609        Self {
610            split_threshold: Decimal::new(10000, 0),
611            min_splits: 2,
612            max_splits: 5,
613            threshold_buffer: Decimal::new(500, 0),
614        }
615    }
616}
617
618impl InjectionStrategy for SplitTransactionStrategy {
619    fn name(&self) -> &'static str {
620        "SplitTransaction"
621    }
622
623    fn can_apply(&self, entry: &JournalEntry) -> bool {
624        // Can only split entries above threshold
625        entry.total_debit() > self.split_threshold
626    }
627
628    fn apply<R: Rng>(
629        &self,
630        entry: &mut JournalEntry,
631        _anomaly_type: &AnomalyType,
632        rng: &mut R,
633    ) -> InjectionResult {
634        let total = entry.total_debit();
635        if total <= self.split_threshold {
636            return InjectionResult::failure("Amount below split threshold");
637        }
638
639        let num_splits = rng.gen_range(self.min_splits..=self.max_splits);
640        let target_per_split =
641            self.split_threshold - self.threshold_buffer - Decimal::new(rng.gen_range(1..100), 0);
642
643        // Scale down all lines to fit first split
644        let scale = target_per_split / total;
645        for line in &mut entry.lines {
646            line.debit_amount = (line.debit_amount * scale).round_dp(2);
647            line.credit_amount = (line.credit_amount * scale).round_dp(2);
648        }
649
650        InjectionResult::success(&format!(
651            "Split ${} transaction into {} parts of ~${} each (below ${} threshold)",
652            total, num_splits, target_per_split, self.split_threshold
653        ))
654        .with_impact(total)
655        .with_metadata("original_amount", &total.to_string())
656        .with_metadata("num_splits", &num_splits.to_string())
657        .with_metadata("threshold", &self.split_threshold.to_string())
658    }
659}
660
661/// Strategy for skipped approval anomalies.
662pub struct SkippedApprovalStrategy {
663    /// Threshold above which approval is required.
664    pub approval_threshold: Decimal,
665}
666
667impl Default for SkippedApprovalStrategy {
668    fn default() -> Self {
669        Self {
670            approval_threshold: Decimal::new(5000, 0),
671        }
672    }
673}
674
675impl InjectionStrategy for SkippedApprovalStrategy {
676    fn name(&self) -> &'static str {
677        "SkippedApproval"
678    }
679
680    fn can_apply(&self, entry: &JournalEntry) -> bool {
681        // Can only skip approval on entries above threshold
682        entry.total_debit() > self.approval_threshold
683    }
684
685    fn apply<R: Rng>(
686        &self,
687        entry: &mut JournalEntry,
688        _anomaly_type: &AnomalyType,
689        _rng: &mut R,
690    ) -> InjectionResult {
691        let amount = entry.total_debit();
692        if amount <= self.approval_threshold {
693            return InjectionResult::failure("Amount below approval threshold");
694        }
695
696        // Mark control status as exception (simulates skipped approval)
697        entry.header.control_status = ControlStatus::Exception;
698        entry.header.sod_violation = true;
699
700        InjectionResult::success(&format!(
701            "Skipped required approval for ${} entry (threshold: ${})",
702            amount, self.approval_threshold
703        ))
704        .with_impact(amount)
705        .with_metadata("threshold", &self.approval_threshold.to_string())
706    }
707}
708
709/// Strategy for weekend/holiday posting anomalies.
710pub struct WeekendPostingStrategy;
711
712impl Default for WeekendPostingStrategy {
713    fn default() -> Self {
714        Self
715    }
716}
717
718impl InjectionStrategy for WeekendPostingStrategy {
719    fn name(&self) -> &'static str {
720        "WeekendPosting"
721    }
722
723    fn can_apply(&self, _entry: &JournalEntry) -> bool {
724        true
725    }
726
727    fn apply<R: Rng>(
728        &self,
729        entry: &mut JournalEntry,
730        _anomaly_type: &AnomalyType,
731        rng: &mut R,
732    ) -> InjectionResult {
733        use chrono::Weekday;
734
735        let original_date = entry.header.posting_date;
736        let weekday = original_date.weekday();
737
738        // Find days until next weekend
739        let days_to_weekend = match weekday {
740            Weekday::Mon => 5,
741            Weekday::Tue => 4,
742            Weekday::Wed => 3,
743            Weekday::Thu => 2,
744            Weekday::Fri => 1,
745            Weekday::Sat => 0,
746            Weekday::Sun => 0,
747        };
748
749        // Move to Saturday or Sunday
750        let weekend_day = if rng.gen_bool(0.6) {
751            days_to_weekend
752        } else {
753            days_to_weekend + 1
754        };
755        let new_date = original_date + chrono::Duration::days(weekend_day as i64);
756
757        entry.header.posting_date = new_date;
758
759        InjectionResult::success(&format!(
760            "Moved posting from {} ({:?}) to {} ({:?})",
761            original_date,
762            weekday,
763            new_date,
764            new_date.weekday()
765        ))
766        .with_metadata("original_date", &original_date.to_string())
767        .with_metadata("new_date", &new_date.to_string())
768    }
769}
770
771/// Strategy for reversed amount errors.
772pub struct ReversedAmountStrategy;
773
774impl Default for ReversedAmountStrategy {
775    fn default() -> Self {
776        Self
777    }
778}
779
780impl InjectionStrategy for ReversedAmountStrategy {
781    fn name(&self) -> &'static str {
782        "ReversedAmount"
783    }
784
785    fn can_apply(&self, entry: &JournalEntry) -> bool {
786        entry.lines.len() >= 2
787    }
788
789    fn apply<R: Rng>(
790        &self,
791        entry: &mut JournalEntry,
792        _anomaly_type: &AnomalyType,
793        rng: &mut R,
794    ) -> InjectionResult {
795        if entry.lines.len() < 2 {
796            return InjectionResult::failure("Need at least 2 lines to reverse");
797        }
798
799        // Pick a random line and swap its debit/credit
800        let line_idx = rng.gen_range(0..entry.lines.len());
801        let line = &mut entry.lines[line_idx];
802
803        let original_debit = line.debit_amount;
804        let original_credit = line.credit_amount;
805
806        line.debit_amount = original_credit;
807        line.credit_amount = original_debit;
808
809        let impact = original_debit.max(original_credit);
810
811        InjectionResult::success(&format!(
812            "Reversed amounts on line {}: DR {} → CR {}, CR {} → DR {}",
813            line_idx + 1,
814            original_debit,
815            line.credit_amount,
816            original_credit,
817            line.debit_amount
818        ))
819        .with_impact(impact * Decimal::new(2, 0)) // Double impact due to both sides being wrong
820        .with_metadata("line_number", &(line_idx + 1).to_string())
821    }
822}
823
824/// Strategy for transposed digits errors.
825pub struct TransposedDigitsStrategy;
826
827impl Default for TransposedDigitsStrategy {
828    fn default() -> Self {
829        Self
830    }
831}
832
833impl InjectionStrategy for TransposedDigitsStrategy {
834    fn name(&self) -> &'static str {
835        "TransposedDigits"
836    }
837
838    fn can_apply(&self, entry: &JournalEntry) -> bool {
839        // Need at least one line with amount >= 10 (two digits to transpose)
840        entry.lines.iter().any(|l| {
841            let amount = if l.debit_amount > Decimal::ZERO {
842                l.debit_amount
843            } else {
844                l.credit_amount
845            };
846            amount >= Decimal::new(10, 0)
847        })
848    }
849
850    fn apply<R: Rng>(
851        &self,
852        entry: &mut JournalEntry,
853        _anomaly_type: &AnomalyType,
854        rng: &mut R,
855    ) -> InjectionResult {
856        // Find lines with transposable amounts
857        let valid_lines: Vec<usize> = entry
858            .lines
859            .iter()
860            .enumerate()
861            .filter(|(_, l)| {
862                let amount = if l.debit_amount > Decimal::ZERO {
863                    l.debit_amount
864                } else {
865                    l.credit_amount
866                };
867                amount >= Decimal::new(10, 0)
868            })
869            .map(|(i, _)| i)
870            .collect();
871
872        if valid_lines.is_empty() {
873            return InjectionResult::failure("No lines with transposable amounts");
874        }
875
876        let line_idx = valid_lines[rng.gen_range(0..valid_lines.len())];
877        let line = &mut entry.lines[line_idx];
878
879        let is_debit = line.debit_amount > Decimal::ZERO;
880        let original_amount = if is_debit {
881            line.debit_amount
882        } else {
883            line.credit_amount
884        };
885
886        // Transpose two adjacent digits
887        let amount_str = original_amount.to_string().replace('.', "");
888        let chars: Vec<char> = amount_str.chars().collect();
889
890        if chars.len() < 2 {
891            return InjectionResult::failure("Amount too small to transpose");
892        }
893
894        // Pick a position to transpose (not the decimal point)
895        let pos = rng.gen_range(0..chars.len() - 1);
896        let mut new_chars = chars.clone();
897        new_chars.swap(pos, pos + 1);
898
899        let new_str: String = new_chars.into_iter().collect();
900        let new_amount = new_str.parse::<i64>().unwrap_or(0);
901        let scale = original_amount.scale();
902        let new_decimal = Decimal::new(new_amount, scale);
903
904        let impact = (new_decimal - original_amount).abs();
905
906        if is_debit {
907            line.debit_amount = new_decimal;
908        } else {
909            line.credit_amount = new_decimal;
910        }
911
912        InjectionResult::success(&format!(
913            "Transposed digits: {} → {} (positions {} and {})",
914            original_amount,
915            new_decimal,
916            pos + 1,
917            pos + 2
918        ))
919        .with_impact(impact)
920        .with_metadata("original_amount", &original_amount.to_string())
921        .with_metadata("new_amount", &new_decimal.to_string())
922    }
923}
924
925/// Strategy for dormant account activity.
926pub struct DormantAccountStrategy {
927    /// List of dormant account codes to use.
928    pub dormant_accounts: Vec<String>,
929}
930
931impl Default for DormantAccountStrategy {
932    fn default() -> Self {
933        Self {
934            dormant_accounts: vec![
935                "199999".to_string(), // Suspense
936                "299999".to_string(), // Legacy clearing
937                "399999".to_string(), // Obsolete account
938                "999999".to_string(), // Test account
939            ],
940        }
941    }
942}
943
944impl InjectionStrategy for DormantAccountStrategy {
945    fn name(&self) -> &'static str {
946        "DormantAccountActivity"
947    }
948
949    fn can_apply(&self, entry: &JournalEntry) -> bool {
950        !entry.lines.is_empty() && !self.dormant_accounts.is_empty()
951    }
952
953    fn apply<R: Rng>(
954        &self,
955        entry: &mut JournalEntry,
956        _anomaly_type: &AnomalyType,
957        rng: &mut R,
958    ) -> InjectionResult {
959        if entry.lines.is_empty() || self.dormant_accounts.is_empty() {
960            return InjectionResult::failure("No lines or dormant accounts");
961        }
962
963        let line_idx = rng.gen_range(0..entry.lines.len());
964        let line = &mut entry.lines[line_idx];
965
966        let original_account = line.gl_account.clone();
967        let dormant_account = &self.dormant_accounts[rng.gen_range(0..self.dormant_accounts.len())];
968
969        line.gl_account = dormant_account.clone();
970        line.account_code = dormant_account.clone();
971
972        let amount = if line.debit_amount > Decimal::ZERO {
973            line.debit_amount
974        } else {
975            line.credit_amount
976        };
977
978        InjectionResult::success(&format!(
979            "Changed account from {} to dormant account {}",
980            original_account, dormant_account
981        ))
982        .with_impact(amount)
983        .with_entity(dormant_account)
984        .with_metadata("original_account", &original_account)
985    }
986}
987
988/// Collection of all available strategies.
989#[derive(Default)]
990pub struct StrategyCollection {
991    pub amount_modification: AmountModificationStrategy,
992    pub date_modification: DateModificationStrategy,
993    pub duplication: DuplicationStrategy,
994    pub approval_anomaly: ApprovalAnomalyStrategy,
995    pub description_anomaly: DescriptionAnomalyStrategy,
996    pub benford_violation: BenfordViolationStrategy,
997    pub split_transaction: SplitTransactionStrategy,
998    pub skipped_approval: SkippedApprovalStrategy,
999    pub weekend_posting: WeekendPostingStrategy,
1000    pub reversed_amount: ReversedAmountStrategy,
1001    pub transposed_digits: TransposedDigitsStrategy,
1002    pub dormant_account: DormantAccountStrategy,
1003}
1004
1005impl StrategyCollection {
1006    /// Checks if the strategy can be applied to an entry.
1007    pub fn can_apply(&self, entry: &JournalEntry, anomaly_type: &AnomalyType) -> bool {
1008        match anomaly_type {
1009            // Amount-based strategies
1010            AnomalyType::Fraud(FraudType::RoundDollarManipulation)
1011            | AnomalyType::Statistical(StatisticalAnomalyType::UnusuallyHighAmount)
1012            | AnomalyType::Statistical(StatisticalAnomalyType::UnusuallyLowAmount) => {
1013                self.amount_modification.can_apply(entry)
1014            }
1015            // Date-based strategies
1016            AnomalyType::Error(ErrorType::BackdatedEntry)
1017            | AnomalyType::Error(ErrorType::FutureDatedEntry)
1018            | AnomalyType::Error(ErrorType::WrongPeriod)
1019            | AnomalyType::ProcessIssue(ProcessIssueType::LatePosting) => {
1020                self.date_modification.can_apply(entry)
1021            }
1022            // Approval threshold strategies
1023            AnomalyType::Fraud(FraudType::JustBelowThreshold)
1024            | AnomalyType::Fraud(FraudType::ExceededApprovalLimit) => {
1025                self.approval_anomaly.can_apply(entry)
1026            }
1027            // Description strategies
1028            AnomalyType::ProcessIssue(ProcessIssueType::VagueDescription) => {
1029                self.description_anomaly.can_apply(entry)
1030            }
1031            // Benford's Law strategies
1032            AnomalyType::Statistical(StatisticalAnomalyType::BenfordViolation) => {
1033                self.benford_violation.can_apply(entry)
1034            }
1035            // Split transaction (structuring)
1036            AnomalyType::Fraud(FraudType::SplitTransaction) => {
1037                self.split_transaction.can_apply(entry)
1038            }
1039            // Skipped approval
1040            AnomalyType::ProcessIssue(ProcessIssueType::SkippedApproval) => {
1041                self.skipped_approval.can_apply(entry)
1042            }
1043            // Weekend posting
1044            AnomalyType::ProcessIssue(ProcessIssueType::WeekendPosting)
1045            | AnomalyType::ProcessIssue(ProcessIssueType::AfterHoursPosting) => {
1046                self.weekend_posting.can_apply(entry)
1047            }
1048            // Reversed amount
1049            AnomalyType::Error(ErrorType::ReversedAmount) => self.reversed_amount.can_apply(entry),
1050            // Transposed digits
1051            AnomalyType::Error(ErrorType::TransposedDigits) => {
1052                self.transposed_digits.can_apply(entry)
1053            }
1054            // Dormant account
1055            AnomalyType::Relational(RelationalAnomalyType::DormantAccountActivity) => {
1056                self.dormant_account.can_apply(entry)
1057            }
1058            // Default fallback
1059            _ => self.amount_modification.can_apply(entry),
1060        }
1061    }
1062
1063    /// Applies the appropriate strategy for an anomaly type.
1064    pub fn apply_strategy<R: Rng>(
1065        &self,
1066        entry: &mut JournalEntry,
1067        anomaly_type: &AnomalyType,
1068        rng: &mut R,
1069    ) -> InjectionResult {
1070        match anomaly_type {
1071            // Amount-based strategies
1072            AnomalyType::Fraud(FraudType::RoundDollarManipulation)
1073            | AnomalyType::Statistical(StatisticalAnomalyType::UnusuallyHighAmount)
1074            | AnomalyType::Statistical(StatisticalAnomalyType::UnusuallyLowAmount) => {
1075                self.amount_modification.apply(entry, anomaly_type, rng)
1076            }
1077            // Date-based strategies
1078            AnomalyType::Error(ErrorType::BackdatedEntry)
1079            | AnomalyType::Error(ErrorType::FutureDatedEntry)
1080            | AnomalyType::Error(ErrorType::WrongPeriod)
1081            | AnomalyType::ProcessIssue(ProcessIssueType::LatePosting) => {
1082                self.date_modification.apply(entry, anomaly_type, rng)
1083            }
1084            // Approval threshold strategies
1085            AnomalyType::Fraud(FraudType::JustBelowThreshold)
1086            | AnomalyType::Fraud(FraudType::ExceededApprovalLimit) => {
1087                self.approval_anomaly.apply(entry, anomaly_type, rng)
1088            }
1089            // Description strategies
1090            AnomalyType::ProcessIssue(ProcessIssueType::VagueDescription) => {
1091                self.description_anomaly.apply(entry, anomaly_type, rng)
1092            }
1093            // Benford's Law strategies
1094            AnomalyType::Statistical(StatisticalAnomalyType::BenfordViolation) => {
1095                self.benford_violation.apply(entry, anomaly_type, rng)
1096            }
1097            // Split transaction (structuring)
1098            AnomalyType::Fraud(FraudType::SplitTransaction) => {
1099                self.split_transaction.apply(entry, anomaly_type, rng)
1100            }
1101            // Skipped approval
1102            AnomalyType::ProcessIssue(ProcessIssueType::SkippedApproval) => {
1103                self.skipped_approval.apply(entry, anomaly_type, rng)
1104            }
1105            // Weekend posting
1106            AnomalyType::ProcessIssue(ProcessIssueType::WeekendPosting)
1107            | AnomalyType::ProcessIssue(ProcessIssueType::AfterHoursPosting) => {
1108                self.weekend_posting.apply(entry, anomaly_type, rng)
1109            }
1110            // Reversed amount
1111            AnomalyType::Error(ErrorType::ReversedAmount) => {
1112                self.reversed_amount.apply(entry, anomaly_type, rng)
1113            }
1114            // Transposed digits
1115            AnomalyType::Error(ErrorType::TransposedDigits) => {
1116                self.transposed_digits.apply(entry, anomaly_type, rng)
1117            }
1118            // Dormant account
1119            AnomalyType::Relational(RelationalAnomalyType::DormantAccountActivity) => {
1120                self.dormant_account.apply(entry, anomaly_type, rng)
1121            }
1122            // Default fallback
1123            _ => self.amount_modification.apply(entry, anomaly_type, rng),
1124        }
1125    }
1126}
1127
1128#[cfg(test)]
1129mod tests {
1130    use super::*;
1131    use chrono::NaiveDate;
1132    use datasynth_core::models::JournalEntryLine;
1133    use rand::SeedableRng;
1134    use rand_chacha::ChaCha8Rng;
1135    use rust_decimal_macros::dec;
1136
1137    fn create_test_entry() -> JournalEntry {
1138        let mut entry = JournalEntry::new_simple(
1139            "JE001".to_string(),
1140            "1000".to_string(),
1141            NaiveDate::from_ymd_opt(2024, 6, 15).unwrap(),
1142            "Test Entry".to_string(),
1143        );
1144
1145        entry.add_line(JournalEntryLine {
1146            line_number: 1,
1147            gl_account: "5000".to_string(),
1148            debit_amount: dec!(1000),
1149            ..Default::default()
1150        });
1151
1152        entry.add_line(JournalEntryLine {
1153            line_number: 2,
1154            gl_account: "1000".to_string(),
1155            credit_amount: dec!(1000),
1156            ..Default::default()
1157        });
1158
1159        entry
1160    }
1161
1162    #[test]
1163    fn test_amount_modification() {
1164        let strategy = AmountModificationStrategy::default();
1165        let mut entry = create_test_entry();
1166        let mut rng = ChaCha8Rng::seed_from_u64(42);
1167
1168        let result = strategy.apply(
1169            &mut entry,
1170            &AnomalyType::Statistical(StatisticalAnomalyType::UnusuallyHighAmount),
1171            &mut rng,
1172        );
1173
1174        assert!(result.success);
1175        assert!(result.monetary_impact.is_some());
1176    }
1177
1178    #[test]
1179    fn test_amount_modification_rebalanced() {
1180        let strategy = AmountModificationStrategy {
1181            rebalance_entry: true,
1182            ..Default::default()
1183        };
1184        let mut entry = create_test_entry();
1185        let mut rng = ChaCha8Rng::seed_from_u64(42);
1186
1187        // Entry should start balanced
1188        assert!(entry.is_balanced());
1189
1190        let result = strategy.apply(
1191            &mut entry,
1192            &AnomalyType::Statistical(StatisticalAnomalyType::UnusuallyHighAmount),
1193            &mut rng,
1194        );
1195
1196        assert!(result.success);
1197        // Entry should remain balanced after rebalancing
1198        assert!(
1199            entry.is_balanced(),
1200            "Entry should remain balanced after amount modification with rebalancing"
1201        );
1202    }
1203
1204    #[test]
1205    fn test_amount_modification_unbalanced_fraud() {
1206        let strategy = AmountModificationStrategy {
1207            rebalance_entry: false, // Intentionally create unbalanced entry for fraud detection
1208            ..Default::default()
1209        };
1210        let mut entry = create_test_entry();
1211        let mut rng = ChaCha8Rng::seed_from_u64(42);
1212
1213        // Entry should start balanced
1214        assert!(entry.is_balanced());
1215
1216        let result = strategy.apply(
1217            &mut entry,
1218            &AnomalyType::Fraud(FraudType::RoundDollarManipulation),
1219            &mut rng,
1220        );
1221
1222        assert!(result.success);
1223        // Entry should be unbalanced when rebalance is disabled
1224        assert!(
1225            !entry.is_balanced(),
1226            "Entry should be unbalanced when rebalance_entry is false"
1227        );
1228    }
1229
1230    #[test]
1231    fn test_benford_violation_rebalanced() {
1232        let strategy = BenfordViolationStrategy {
1233            rebalance_entry: true,
1234            ..Default::default()
1235        };
1236        let mut entry = create_test_entry();
1237        let mut rng = ChaCha8Rng::seed_from_u64(42);
1238
1239        // Entry should start balanced
1240        assert!(entry.is_balanced());
1241
1242        let result = strategy.apply(
1243            &mut entry,
1244            &AnomalyType::Statistical(StatisticalAnomalyType::BenfordViolation),
1245            &mut rng,
1246        );
1247
1248        assert!(result.success);
1249        // Entry should remain balanced after rebalancing
1250        assert!(
1251            entry.is_balanced(),
1252            "Entry should remain balanced after Benford violation with rebalancing"
1253        );
1254    }
1255
1256    #[test]
1257    fn test_date_modification() {
1258        let strategy = DateModificationStrategy::default();
1259        let mut entry = create_test_entry();
1260        let original_date = entry.header.posting_date;
1261        let mut rng = ChaCha8Rng::seed_from_u64(42);
1262
1263        let result = strategy.apply(
1264            &mut entry,
1265            &AnomalyType::Error(ErrorType::BackdatedEntry),
1266            &mut rng,
1267        );
1268
1269        assert!(result.success);
1270        assert!(entry.header.posting_date < original_date);
1271    }
1272
1273    #[test]
1274    fn test_description_anomaly() {
1275        let strategy = DescriptionAnomalyStrategy::default();
1276        let mut entry = create_test_entry();
1277        let mut rng = ChaCha8Rng::seed_from_u64(42);
1278
1279        let result = strategy.apply(
1280            &mut entry,
1281            &AnomalyType::ProcessIssue(ProcessIssueType::VagueDescription),
1282            &mut rng,
1283        );
1284
1285        assert!(result.success);
1286        let desc = entry.description().unwrap_or("").to_string();
1287        assert!(strategy.vague_descriptions.contains(&desc));
1288    }
1289}