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