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        // Compute the order of magnitude of the integer part of the
554        // original amount.  Using the full Decimal string and stripping
555        // the decimal point would conflate the integer part with the
556        // scale (a `530079.625888901324346344432` opening balance has
557        // 28 digits in its full string but only 6 in its integer part),
558        // and previously caused `safe_magnitude` to clamp to 18 — the
559        // resulting `base = 10^18` then produced 5–9 quintillion
560        // amounts on routine inputs.  See issue #185.
561        //
562        // f64 has plenty of precision for log10 of amounts that fit in
563        // a Decimal scaled-i128, and falls back to 0 for anything
564        // pathological.
565        let abs_amount: f64 = original_amount.abs().try_into().unwrap_or(0.0);
566        let magnitude = if abs_amount >= 1.0 {
567            abs_amount.log10().floor() as i32
568        } else {
569            0
570        };
571        // Cap at 12 (≈ \$1 trillion).  Anti-Benford fraud amounts on
572        // realistic enterprise data should not be quintillion-scale,
573        // and `i64::pow` overflows above 18 anyway.
574        let safe_magnitude = magnitude.clamp(0, 12) as u32;
575
576        let base = Decimal::new(10_i64.pow(safe_magnitude), 0);
577        let new_amount = base * Decimal::new(target_digit as i64, 0)
578            + Decimal::new(rng.random_range(0..10_i64.pow(safe_magnitude)), 0);
579
580        let impact = new_amount - original_amount;
581
582        // Apply the modification
583        if is_debit {
584            entry.lines[line_idx].debit_amount = new_amount;
585        } else {
586            entry.lines[line_idx].credit_amount = new_amount;
587        }
588
589        // Rebalance the entry if configured to do so
590        if self.rebalance_entry {
591            // Find a line on the opposite side to adjust
592            let balancing_idx = entry.lines.iter().position(|l| {
593                if is_debit {
594                    l.credit_amount > Decimal::ZERO
595                } else {
596                    l.debit_amount > Decimal::ZERO
597                }
598            });
599
600            if let Some(bal_idx) = balancing_idx {
601                // Adjust the balancing line by the same impact
602                if is_debit {
603                    entry.lines[bal_idx].credit_amount += impact;
604                } else {
605                    entry.lines[bal_idx].debit_amount += impact;
606                }
607            }
608        }
609
610        let first_digit = target_digit;
611        let benford_prob = (1.0 + 1.0 / first_digit as f64).log10();
612
613        InjectionResult::success(&format!(
614            "Created Benford violation: first digit {} (expected probability {:.1}%){}",
615            first_digit,
616            benford_prob * 100.0,
617            if self.rebalance_entry {
618                " [rebalanced]"
619            } else {
620                " [UNBALANCED]"
621            }
622        ))
623        .with_impact(impact)
624        .with_metadata("first_digit", &first_digit.to_string())
625        .with_metadata("benford_probability", &format!("{benford_prob:.4}"))
626    }
627}
628
629/// Strategy for split transactions (structuring to avoid thresholds).
630pub struct SplitTransactionStrategy {
631    /// Threshold above which transactions are split.
632    pub split_threshold: Decimal,
633    /// Number of splits to create.
634    pub min_splits: usize,
635    pub max_splits: usize,
636    /// Buffer below threshold.
637    pub threshold_buffer: Decimal,
638}
639
640impl Default for SplitTransactionStrategy {
641    fn default() -> Self {
642        Self {
643            split_threshold: Decimal::new(10000, 0),
644            min_splits: 2,
645            max_splits: 5,
646            threshold_buffer: Decimal::new(500, 0),
647        }
648    }
649}
650
651impl InjectionStrategy for SplitTransactionStrategy {
652    fn name(&self) -> &'static str {
653        "SplitTransaction"
654    }
655
656    fn can_apply(&self, entry: &JournalEntry) -> bool {
657        // Can only split entries above threshold
658        entry.total_debit() > self.split_threshold
659    }
660
661    fn apply<R: Rng>(
662        &self,
663        entry: &mut JournalEntry,
664        _anomaly_type: &AnomalyType,
665        rng: &mut R,
666    ) -> InjectionResult {
667        let total = entry.total_debit();
668        if total <= self.split_threshold || total.is_zero() {
669            return InjectionResult::failure("Amount below split threshold");
670        }
671
672        let num_splits = rng.random_range(self.min_splits..=self.max_splits);
673        let target_per_split = self.split_threshold
674            - self.threshold_buffer
675            - Decimal::new(rng.random_range(1..100), 0);
676
677        // Scale down all lines to fit first split
678        let scale = target_per_split / total;
679        for line in &mut entry.lines {
680            line.debit_amount = (line.debit_amount * scale).round_dp(2);
681            line.credit_amount = (line.credit_amount * scale).round_dp(2);
682        }
683
684        InjectionResult::success(&format!(
685            "Split ${} transaction into {} parts of ~${} each (below ${} threshold)",
686            total, num_splits, target_per_split, self.split_threshold
687        ))
688        .with_impact(total)
689        .with_metadata("original_amount", &total.to_string())
690        .with_metadata("num_splits", &num_splits.to_string())
691        .with_metadata("threshold", &self.split_threshold.to_string())
692    }
693}
694
695/// Strategy for skipped approval anomalies.
696pub struct SkippedApprovalStrategy {
697    /// Threshold above which approval is required.
698    pub approval_threshold: Decimal,
699}
700
701impl Default for SkippedApprovalStrategy {
702    fn default() -> Self {
703        Self {
704            approval_threshold: Decimal::new(5000, 0),
705        }
706    }
707}
708
709impl InjectionStrategy for SkippedApprovalStrategy {
710    fn name(&self) -> &'static str {
711        "SkippedApproval"
712    }
713
714    fn can_apply(&self, entry: &JournalEntry) -> bool {
715        // Can only skip approval on entries above threshold
716        entry.total_debit() > self.approval_threshold
717    }
718
719    fn apply<R: Rng>(
720        &self,
721        entry: &mut JournalEntry,
722        _anomaly_type: &AnomalyType,
723        _rng: &mut R,
724    ) -> InjectionResult {
725        let amount = entry.total_debit();
726        if amount <= self.approval_threshold {
727            return InjectionResult::failure("Amount below approval threshold");
728        }
729
730        // Mark control status as exception (simulates skipped approval)
731        entry.header.control_status = ControlStatus::Exception;
732        entry.header.sod_violation = true;
733
734        InjectionResult::success(&format!(
735            "Skipped required approval for ${} entry (threshold: ${})",
736            amount, self.approval_threshold
737        ))
738        .with_impact(amount)
739        .with_metadata("threshold", &self.approval_threshold.to_string())
740    }
741}
742
743/// Strategy for weekend/holiday posting anomalies.
744pub struct WeekendPostingStrategy;
745
746impl Default for WeekendPostingStrategy {
747    fn default() -> Self {
748        Self
749    }
750}
751
752impl InjectionStrategy for WeekendPostingStrategy {
753    fn name(&self) -> &'static str {
754        "WeekendPosting"
755    }
756
757    fn can_apply(&self, _entry: &JournalEntry) -> bool {
758        true
759    }
760
761    fn apply<R: Rng>(
762        &self,
763        entry: &mut JournalEntry,
764        _anomaly_type: &AnomalyType,
765        rng: &mut R,
766    ) -> InjectionResult {
767        use chrono::Weekday;
768
769        let original_date = entry.header.posting_date;
770        let weekday = original_date.weekday();
771
772        // Find days until next weekend
773        let days_to_weekend = match weekday {
774            Weekday::Mon => 5,
775            Weekday::Tue => 4,
776            Weekday::Wed => 3,
777            Weekday::Thu => 2,
778            Weekday::Fri => 1,
779            Weekday::Sat => 0,
780            Weekday::Sun => 0,
781        };
782
783        // Move to Saturday or Sunday
784        let weekend_day = if rng.random_bool(0.6) {
785            days_to_weekend
786        } else {
787            days_to_weekend + 1
788        };
789        let new_date = original_date + chrono::Duration::days(weekend_day as i64);
790
791        entry.header.posting_date = new_date;
792
793        InjectionResult::success(&format!(
794            "Moved posting from {} ({:?}) to {} ({:?})",
795            original_date,
796            weekday,
797            new_date,
798            new_date.weekday()
799        ))
800        .with_metadata("original_date", &original_date.to_string())
801        .with_metadata("new_date", &new_date.to_string())
802    }
803}
804
805/// Strategy for reversed amount errors.
806pub struct ReversedAmountStrategy;
807
808impl Default for ReversedAmountStrategy {
809    fn default() -> Self {
810        Self
811    }
812}
813
814impl InjectionStrategy for ReversedAmountStrategy {
815    fn name(&self) -> &'static str {
816        "ReversedAmount"
817    }
818
819    fn can_apply(&self, entry: &JournalEntry) -> bool {
820        entry.lines.len() >= 2
821    }
822
823    fn apply<R: Rng>(
824        &self,
825        entry: &mut JournalEntry,
826        _anomaly_type: &AnomalyType,
827        rng: &mut R,
828    ) -> InjectionResult {
829        if entry.lines.len() < 2 {
830            return InjectionResult::failure("Need at least 2 lines to reverse");
831        }
832
833        // Pick a random line and swap its debit/credit
834        let line_idx = rng.random_range(0..entry.lines.len());
835        let line = &mut entry.lines[line_idx];
836
837        let original_debit = line.debit_amount;
838        let original_credit = line.credit_amount;
839
840        line.debit_amount = original_credit;
841        line.credit_amount = original_debit;
842
843        let impact = original_debit.max(original_credit);
844
845        InjectionResult::success(&format!(
846            "Reversed amounts on line {}: DR {} → CR {}, CR {} → DR {}",
847            line_idx + 1,
848            original_debit,
849            line.credit_amount,
850            original_credit,
851            line.debit_amount
852        ))
853        .with_impact(impact * Decimal::new(2, 0)) // Double impact due to both sides being wrong
854        .with_metadata("line_number", &(line_idx + 1).to_string())
855    }
856}
857
858/// Strategy for transposed digits errors.
859pub struct TransposedDigitsStrategy;
860
861impl Default for TransposedDigitsStrategy {
862    fn default() -> Self {
863        Self
864    }
865}
866
867impl InjectionStrategy for TransposedDigitsStrategy {
868    fn name(&self) -> &'static str {
869        "TransposedDigits"
870    }
871
872    fn can_apply(&self, entry: &JournalEntry) -> bool {
873        // Need at least one line with amount >= 10 (two digits to transpose)
874        entry.lines.iter().any(|l| {
875            let amount = if l.debit_amount > Decimal::ZERO {
876                l.debit_amount
877            } else {
878                l.credit_amount
879            };
880            amount >= Decimal::new(10, 0)
881        })
882    }
883
884    fn apply<R: Rng>(
885        &self,
886        entry: &mut JournalEntry,
887        _anomaly_type: &AnomalyType,
888        rng: &mut R,
889    ) -> InjectionResult {
890        // Find lines with transposable amounts
891        let valid_lines: Vec<usize> = entry
892            .lines
893            .iter()
894            .enumerate()
895            .filter(|(_, l)| {
896                let amount = if l.debit_amount > Decimal::ZERO {
897                    l.debit_amount
898                } else {
899                    l.credit_amount
900                };
901                amount >= Decimal::new(10, 0)
902            })
903            .map(|(i, _)| i)
904            .collect();
905
906        if valid_lines.is_empty() {
907            return InjectionResult::failure("No lines with transposable amounts");
908        }
909
910        let line_idx = valid_lines[rng.random_range(0..valid_lines.len())];
911        let line = &mut entry.lines[line_idx];
912
913        let is_debit = line.debit_amount > Decimal::ZERO;
914        let original_amount = if is_debit {
915            line.debit_amount
916        } else {
917            line.credit_amount
918        };
919
920        // Transpose two adjacent digits
921        let amount_str = original_amount.to_string().replace('.', "");
922        let chars: Vec<char> = amount_str.chars().collect();
923
924        if chars.len() < 2 {
925            return InjectionResult::failure("Amount too small to transpose");
926        }
927
928        // Pick a position to transpose (not the decimal point)
929        let pos = rng.random_range(0..chars.len() - 1);
930        let mut new_chars = chars.clone();
931        new_chars.swap(pos, pos + 1);
932
933        let new_str: String = new_chars.into_iter().collect();
934        let new_amount = new_str.parse::<i64>().unwrap_or(0);
935        let scale = original_amount.scale();
936        let new_decimal = Decimal::new(new_amount, scale);
937
938        let impact = (new_decimal - original_amount).abs();
939
940        if is_debit {
941            line.debit_amount = new_decimal;
942        } else {
943            line.credit_amount = new_decimal;
944        }
945
946        InjectionResult::success(&format!(
947            "Transposed digits: {} → {} (positions {} and {})",
948            original_amount,
949            new_decimal,
950            pos + 1,
951            pos + 2
952        ))
953        .with_impact(impact)
954        .with_metadata("original_amount", &original_amount.to_string())
955        .with_metadata("new_amount", &new_decimal.to_string())
956    }
957}
958
959/// Strategy for dormant account activity.
960pub struct DormantAccountStrategy {
961    /// List of dormant account codes to use.
962    pub dormant_accounts: Vec<String>,
963}
964
965impl Default for DormantAccountStrategy {
966    fn default() -> Self {
967        // Sourced from `datasynth_core::accounts::dormant_accounts` so the
968        // anomaly target list stays in sync with the COA's blocked-account
969        // seeds — every account this strategy can post to is also seeded
970        // (with `is_blocked = true`) by `seed_canonical_accounts`, which
971        // preserves the v5.5.1 COA-coverage invariant even when anomaly
972        // injection is enabled.
973        Self {
974            dormant_accounts: vec![
975                dormant_accounts::LEGACY_SUSPENSE.to_string(),
976                dormant_accounts::LEGACY_CLEARING.to_string(),
977                dormant_accounts::OBSOLETE.to_string(),
978                dormant_accounts::TEST_ACCOUNT.to_string(),
979            ],
980        }
981    }
982}
983
984impl InjectionStrategy for DormantAccountStrategy {
985    fn name(&self) -> &'static str {
986        "DormantAccountActivity"
987    }
988
989    fn can_apply(&self, entry: &JournalEntry) -> bool {
990        !entry.lines.is_empty() && !self.dormant_accounts.is_empty()
991    }
992
993    fn apply<R: Rng>(
994        &self,
995        entry: &mut JournalEntry,
996        _anomaly_type: &AnomalyType,
997        rng: &mut R,
998    ) -> InjectionResult {
999        if entry.lines.is_empty() || self.dormant_accounts.is_empty() {
1000            return InjectionResult::failure("No lines or dormant accounts");
1001        }
1002
1003        let line_idx = rng.random_range(0..entry.lines.len());
1004        let line = &mut entry.lines[line_idx];
1005
1006        let original_account = line.gl_account.clone();
1007        let dormant_account =
1008            &self.dormant_accounts[rng.random_range(0..self.dormant_accounts.len())];
1009
1010        line.gl_account = dormant_account.clone();
1011        line.account_code = dormant_account.clone();
1012
1013        let amount = if line.debit_amount > Decimal::ZERO {
1014            line.debit_amount
1015        } else {
1016            line.credit_amount
1017        };
1018
1019        InjectionResult::success(&format!(
1020            "Changed account from {original_account} to dormant account {dormant_account}"
1021        ))
1022        .with_impact(amount)
1023        .with_entity(dormant_account)
1024        .with_metadata("original_account", &original_account)
1025    }
1026}
1027
1028/// Collection of all available strategies.
1029#[derive(Default)]
1030pub struct StrategyCollection {
1031    pub amount_modification: AmountModificationStrategy,
1032    pub date_modification: DateModificationStrategy,
1033    pub duplication: DuplicationStrategy,
1034    pub approval_anomaly: ApprovalAnomalyStrategy,
1035    pub description_anomaly: DescriptionAnomalyStrategy,
1036    pub benford_violation: BenfordViolationStrategy,
1037    pub split_transaction: SplitTransactionStrategy,
1038    pub skipped_approval: SkippedApprovalStrategy,
1039    pub weekend_posting: WeekendPostingStrategy,
1040    pub reversed_amount: ReversedAmountStrategy,
1041    pub transposed_digits: TransposedDigitsStrategy,
1042    pub dormant_account: DormantAccountStrategy,
1043}
1044
1045impl StrategyCollection {
1046    /// Checks if the strategy can be applied to an entry.
1047    pub fn can_apply(&self, entry: &JournalEntry, anomaly_type: &AnomalyType) -> bool {
1048        match anomaly_type {
1049            // Amount-based strategies
1050            AnomalyType::Fraud(FraudType::RoundDollarManipulation)
1051            | AnomalyType::Statistical(StatisticalAnomalyType::UnusuallyHighAmount)
1052            | AnomalyType::Statistical(StatisticalAnomalyType::UnusuallyLowAmount) => {
1053                self.amount_modification.can_apply(entry)
1054            }
1055            // Date-based strategies
1056            AnomalyType::Error(ErrorType::BackdatedEntry)
1057            | AnomalyType::Error(ErrorType::FutureDatedEntry)
1058            | AnomalyType::Error(ErrorType::WrongPeriod)
1059            | AnomalyType::ProcessIssue(ProcessIssueType::LatePosting) => {
1060                self.date_modification.can_apply(entry)
1061            }
1062            // Approval threshold strategies
1063            AnomalyType::Fraud(FraudType::JustBelowThreshold)
1064            | AnomalyType::Fraud(FraudType::ExceededApprovalLimit) => {
1065                self.approval_anomaly.can_apply(entry)
1066            }
1067            // Description strategies
1068            AnomalyType::ProcessIssue(ProcessIssueType::VagueDescription) => {
1069                self.description_anomaly.can_apply(entry)
1070            }
1071            // Benford's Law strategies
1072            AnomalyType::Statistical(StatisticalAnomalyType::BenfordViolation) => {
1073                self.benford_violation.can_apply(entry)
1074            }
1075            // Split transaction (structuring)
1076            AnomalyType::Fraud(FraudType::SplitTransaction) => {
1077                self.split_transaction.can_apply(entry)
1078            }
1079            // Skipped approval
1080            AnomalyType::ProcessIssue(ProcessIssueType::SkippedApproval) => {
1081                self.skipped_approval.can_apply(entry)
1082            }
1083            // Weekend posting
1084            AnomalyType::ProcessIssue(ProcessIssueType::WeekendPosting)
1085            | AnomalyType::ProcessIssue(ProcessIssueType::AfterHoursPosting) => {
1086                self.weekend_posting.can_apply(entry)
1087            }
1088            // Reversed amount
1089            AnomalyType::Error(ErrorType::ReversedAmount) => self.reversed_amount.can_apply(entry),
1090            // Transposed digits
1091            AnomalyType::Error(ErrorType::TransposedDigits) => {
1092                self.transposed_digits.can_apply(entry)
1093            }
1094            // Dormant account
1095            AnomalyType::Relational(RelationalAnomalyType::DormantAccountActivity) => {
1096                self.dormant_account.can_apply(entry)
1097            }
1098            // Default fallback
1099            _ => self.amount_modification.can_apply(entry),
1100        }
1101    }
1102
1103    /// Applies the appropriate strategy for an anomaly type.
1104    pub fn apply_strategy<R: Rng>(
1105        &self,
1106        entry: &mut JournalEntry,
1107        anomaly_type: &AnomalyType,
1108        rng: &mut R,
1109    ) -> InjectionResult {
1110        match anomaly_type {
1111            // Amount-based strategies
1112            AnomalyType::Fraud(FraudType::RoundDollarManipulation)
1113            | AnomalyType::Statistical(StatisticalAnomalyType::UnusuallyHighAmount)
1114            | AnomalyType::Statistical(StatisticalAnomalyType::UnusuallyLowAmount) => {
1115                self.amount_modification.apply(entry, anomaly_type, rng)
1116            }
1117            // Date-based strategies
1118            AnomalyType::Error(ErrorType::BackdatedEntry)
1119            | AnomalyType::Error(ErrorType::FutureDatedEntry)
1120            | AnomalyType::Error(ErrorType::WrongPeriod)
1121            | AnomalyType::ProcessIssue(ProcessIssueType::LatePosting) => {
1122                self.date_modification.apply(entry, anomaly_type, rng)
1123            }
1124            // Approval threshold strategies
1125            AnomalyType::Fraud(FraudType::JustBelowThreshold)
1126            | AnomalyType::Fraud(FraudType::ExceededApprovalLimit) => {
1127                self.approval_anomaly.apply(entry, anomaly_type, rng)
1128            }
1129            // Description strategies
1130            AnomalyType::ProcessIssue(ProcessIssueType::VagueDescription) => {
1131                self.description_anomaly.apply(entry, anomaly_type, rng)
1132            }
1133            // Benford's Law strategies
1134            AnomalyType::Statistical(StatisticalAnomalyType::BenfordViolation) => {
1135                self.benford_violation.apply(entry, anomaly_type, rng)
1136            }
1137            // Split transaction (structuring)
1138            AnomalyType::Fraud(FraudType::SplitTransaction) => {
1139                self.split_transaction.apply(entry, anomaly_type, rng)
1140            }
1141            // Skipped approval
1142            AnomalyType::ProcessIssue(ProcessIssueType::SkippedApproval) => {
1143                self.skipped_approval.apply(entry, anomaly_type, rng)
1144            }
1145            // Weekend posting
1146            AnomalyType::ProcessIssue(ProcessIssueType::WeekendPosting)
1147            | AnomalyType::ProcessIssue(ProcessIssueType::AfterHoursPosting) => {
1148                self.weekend_posting.apply(entry, anomaly_type, rng)
1149            }
1150            // Reversed amount
1151            AnomalyType::Error(ErrorType::ReversedAmount) => {
1152                self.reversed_amount.apply(entry, anomaly_type, rng)
1153            }
1154            // Transposed digits
1155            AnomalyType::Error(ErrorType::TransposedDigits) => {
1156                self.transposed_digits.apply(entry, anomaly_type, rng)
1157            }
1158            // Dormant account
1159            AnomalyType::Relational(RelationalAnomalyType::DormantAccountActivity) => {
1160                self.dormant_account.apply(entry, anomaly_type, rng)
1161            }
1162            // Default fallback
1163            _ => self.amount_modification.apply(entry, anomaly_type, rng),
1164        }
1165    }
1166}
1167
1168#[cfg(test)]
1169#[allow(clippy::unwrap_used)]
1170mod tests {
1171    use super::*;
1172    use chrono::NaiveDate;
1173    use datasynth_core::models::JournalEntryLine;
1174    use rand::SeedableRng;
1175    use rand_chacha::ChaCha8Rng;
1176    use rust_decimal_macros::dec;
1177
1178    fn create_test_entry() -> JournalEntry {
1179        let mut entry = JournalEntry::new_simple(
1180            "JE001".to_string(),
1181            "1000".to_string(),
1182            NaiveDate::from_ymd_opt(2024, 6, 15).unwrap(),
1183            "Test Entry".to_string(),
1184        );
1185
1186        entry.add_line(JournalEntryLine {
1187            line_number: 1,
1188            gl_account: "5000".to_string(),
1189            debit_amount: dec!(1000),
1190            ..Default::default()
1191        });
1192
1193        entry.add_line(JournalEntryLine {
1194            line_number: 2,
1195            gl_account: "1000".to_string(),
1196            credit_amount: dec!(1000),
1197            ..Default::default()
1198        });
1199
1200        entry
1201    }
1202
1203    #[test]
1204    fn test_amount_modification() {
1205        let strategy = AmountModificationStrategy::default();
1206        let mut entry = create_test_entry();
1207        let mut rng = ChaCha8Rng::seed_from_u64(42);
1208
1209        let result = strategy.apply(
1210            &mut entry,
1211            &AnomalyType::Statistical(StatisticalAnomalyType::UnusuallyHighAmount),
1212            &mut rng,
1213        );
1214
1215        assert!(result.success);
1216        assert!(result.monetary_impact.is_some());
1217    }
1218
1219    #[test]
1220    fn test_amount_modification_rebalanced() {
1221        let strategy = AmountModificationStrategy {
1222            rebalance_entry: true,
1223            ..Default::default()
1224        };
1225        let mut entry = create_test_entry();
1226        let mut rng = ChaCha8Rng::seed_from_u64(42);
1227
1228        // Entry should start balanced
1229        assert!(entry.is_balanced());
1230
1231        let result = strategy.apply(
1232            &mut entry,
1233            &AnomalyType::Statistical(StatisticalAnomalyType::UnusuallyHighAmount),
1234            &mut rng,
1235        );
1236
1237        assert!(result.success);
1238        // Entry should remain balanced after rebalancing
1239        assert!(
1240            entry.is_balanced(),
1241            "Entry should remain balanced after amount modification with rebalancing"
1242        );
1243    }
1244
1245    #[test]
1246    fn test_amount_modification_unbalanced_fraud() {
1247        let strategy = AmountModificationStrategy {
1248            rebalance_entry: false, // Intentionally create unbalanced entry for fraud detection
1249            ..Default::default()
1250        };
1251        let mut entry = create_test_entry();
1252        let mut rng = ChaCha8Rng::seed_from_u64(42);
1253
1254        // Entry should start balanced
1255        assert!(entry.is_balanced());
1256
1257        let result = strategy.apply(
1258            &mut entry,
1259            &AnomalyType::Fraud(FraudType::RoundDollarManipulation),
1260            &mut rng,
1261        );
1262
1263        assert!(result.success);
1264        // Entry should be unbalanced when rebalance is disabled
1265        assert!(
1266            !entry.is_balanced(),
1267            "Entry should be unbalanced when rebalance_entry is false"
1268        );
1269    }
1270
1271    #[test]
1272    fn test_benford_violation_rebalanced() {
1273        let strategy = BenfordViolationStrategy {
1274            rebalance_entry: true,
1275            ..Default::default()
1276        };
1277        let mut entry = create_test_entry();
1278        let mut rng = ChaCha8Rng::seed_from_u64(42);
1279
1280        // Entry should start balanced
1281        assert!(entry.is_balanced());
1282
1283        let result = strategy.apply(
1284            &mut entry,
1285            &AnomalyType::Statistical(StatisticalAnomalyType::BenfordViolation),
1286            &mut rng,
1287        );
1288
1289        assert!(result.success);
1290        // Entry should remain balanced after rebalancing
1291        assert!(
1292            entry.is_balanced(),
1293            "Entry should remain balanced after Benford violation with rebalancing"
1294        );
1295    }
1296
1297    #[test]
1298    fn test_date_modification() {
1299        let strategy = DateModificationStrategy::default();
1300        let mut entry = create_test_entry();
1301        let original_date = entry.header.posting_date;
1302        let mut rng = ChaCha8Rng::seed_from_u64(42);
1303
1304        let result = strategy.apply(
1305            &mut entry,
1306            &AnomalyType::Error(ErrorType::BackdatedEntry),
1307            &mut rng,
1308        );
1309
1310        assert!(result.success);
1311        assert!(entry.header.posting_date < original_date);
1312    }
1313
1314    #[test]
1315    fn test_description_anomaly() {
1316        let strategy = DescriptionAnomalyStrategy::default();
1317        let mut entry = create_test_entry();
1318        let mut rng = ChaCha8Rng::seed_from_u64(42);
1319
1320        let result = strategy.apply(
1321            &mut entry,
1322            &AnomalyType::ProcessIssue(ProcessIssueType::VagueDescription),
1323            &mut rng,
1324        );
1325
1326        assert!(result.success);
1327        let desc = entry.description().unwrap_or("").to_string();
1328        assert!(strategy.vague_descriptions.contains(&desc));
1329    }
1330}