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