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