Skip to main content

datasynth_generators/anomaly/
strategies.rs

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