Skip to main content

datasynth_generators/anomaly/
strategies.rs

1//! Injection strategies for anomaly generation.
2//!
3//! Strategies determine how anomalies are applied to existing data.
4
5use chrono::Datelike;
6use rand::Rng;
7use rust_decimal::Decimal;
8
9use datasynth_core::models::{
10    AnomalyType, ControlStatus, ErrorType, FraudType, JournalEntry, ProcessIssueType,
11    RelationalAnomalyType, StatisticalAnomalyType,
12};
13use datasynth_core::uuid_factory::DeterministicUuidFactory;
14
15/// Base trait for injection strategies.
16pub trait InjectionStrategy {
17    /// Name of the strategy.
18    fn name(&self) -> &'static str;
19
20    /// Whether this strategy can be applied to the given entry.
21    fn can_apply(&self, entry: &JournalEntry) -> bool;
22
23    /// Applies the strategy to modify an entry.
24    fn apply<R: Rng>(
25        &self,
26        entry: &mut JournalEntry,
27        anomaly_type: &AnomalyType,
28        rng: &mut R,
29    ) -> InjectionResult;
30}
31
32/// Result of an injection attempt.
33#[derive(Debug, Clone)]
34pub struct InjectionResult {
35    /// Whether the injection was successful.
36    pub success: bool,
37    /// Description of what was modified.
38    pub description: String,
39    /// Monetary impact of the anomaly.
40    pub monetary_impact: Option<Decimal>,
41    /// Related entity IDs.
42    pub related_entities: Vec<String>,
43    /// Additional metadata.
44    pub metadata: Vec<(String, String)>,
45}
46
47impl InjectionResult {
48    /// Creates a successful result.
49    pub fn success(description: &str) -> Self {
50        Self {
51            success: true,
52            description: description.to_string(),
53            monetary_impact: None,
54            related_entities: Vec::new(),
55            metadata: Vec::new(),
56        }
57    }
58
59    /// Creates a failed result.
60    pub fn failure(reason: &str) -> Self {
61        Self {
62            success: false,
63            description: reason.to_string(),
64            monetary_impact: None,
65            related_entities: Vec::new(),
66            metadata: Vec::new(),
67        }
68    }
69
70    /// Adds monetary impact.
71    pub fn with_impact(mut self, impact: Decimal) -> Self {
72        self.monetary_impact = Some(impact);
73        self
74    }
75
76    /// Adds a related entity.
77    pub fn with_entity(mut self, entity: &str) -> Self {
78        self.related_entities.push(entity.to_string());
79        self
80    }
81
82    /// Adds metadata.
83    pub fn with_metadata(mut self, key: &str, value: &str) -> Self {
84        self.metadata.push((key.to_string(), value.to_string()));
85        self
86    }
87}
88
89/// Strategy for modifying amounts.
90pub struct AmountModificationStrategy {
91    /// Minimum multiplier for amount changes.
92    pub min_multiplier: f64,
93    /// Maximum multiplier for amount changes.
94    pub max_multiplier: f64,
95    /// Whether to use round numbers.
96    pub prefer_round_numbers: bool,
97    /// Whether to rebalance the entry after modification.
98    /// If true, a corresponding line will be adjusted to maintain balance.
99    /// If false, the entry will become unbalanced (for intentional fraud detection).
100    pub rebalance_entry: bool,
101}
102
103impl Default for AmountModificationStrategy {
104    fn default() -> Self {
105        Self {
106            min_multiplier: 2.0,
107            max_multiplier: 10.0,
108            prefer_round_numbers: false,
109            rebalance_entry: true, // Default to maintaining balance
110        }
111    }
112}
113
114impl InjectionStrategy for AmountModificationStrategy {
115    fn name(&self) -> &'static str {
116        "AmountModification"
117    }
118
119    fn can_apply(&self, entry: &JournalEntry) -> bool {
120        !entry.lines.is_empty()
121    }
122
123    fn apply<R: Rng>(
124        &self,
125        entry: &mut JournalEntry,
126        anomaly_type: &AnomalyType,
127        rng: &mut R,
128    ) -> InjectionResult {
129        if entry.lines.is_empty() {
130            return InjectionResult::failure("No lines to modify");
131        }
132
133        let line_idx = rng.random_range(0..entry.lines.len());
134        let is_debit = entry.lines[line_idx].debit_amount > Decimal::ZERO;
135        let original_amount = if is_debit {
136            entry.lines[line_idx].debit_amount
137        } else {
138            entry.lines[line_idx].credit_amount
139        };
140
141        let multiplier = rng.random_range(self.min_multiplier..self.max_multiplier);
142        let mut new_amount =
143            original_amount * Decimal::from_f64_retain(multiplier).unwrap_or(Decimal::ONE);
144
145        // Round to nice number if preferred
146        if self.prefer_round_numbers {
147            let 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.random_range(1..=self.max_backdate_days);
266                (-days, format!("Backdated by {} days", days))
267            }
268            AnomalyType::Error(ErrorType::FutureDatedEntry) => {
269                let days = rng.random_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.random_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.random_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.random_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.random_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.random_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.random_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.random_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.random_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.random_range(self.min_splits..=self.max_splits);
646        let target_per_split = self.split_threshold
647            - self.threshold_buffer
648            - Decimal::new(rng.random_range(1..100), 0);
649
650        // Scale down all lines to fit first split
651        let scale = target_per_split / total;
652        for line in &mut entry.lines {
653            line.debit_amount = (line.debit_amount * scale).round_dp(2);
654            line.credit_amount = (line.credit_amount * scale).round_dp(2);
655        }
656
657        InjectionResult::success(&format!(
658            "Split ${} transaction into {} parts of ~${} each (below ${} threshold)",
659            total, num_splits, target_per_split, self.split_threshold
660        ))
661        .with_impact(total)
662        .with_metadata("original_amount", &total.to_string())
663        .with_metadata("num_splits", &num_splits.to_string())
664        .with_metadata("threshold", &self.split_threshold.to_string())
665    }
666}
667
668/// Strategy for skipped approval anomalies.
669pub struct SkippedApprovalStrategy {
670    /// Threshold above which approval is required.
671    pub approval_threshold: Decimal,
672}
673
674impl Default for SkippedApprovalStrategy {
675    fn default() -> Self {
676        Self {
677            approval_threshold: Decimal::new(5000, 0),
678        }
679    }
680}
681
682impl InjectionStrategy for SkippedApprovalStrategy {
683    fn name(&self) -> &'static str {
684        "SkippedApproval"
685    }
686
687    fn can_apply(&self, entry: &JournalEntry) -> bool {
688        // Can only skip approval on entries above threshold
689        entry.total_debit() > self.approval_threshold
690    }
691
692    fn apply<R: Rng>(
693        &self,
694        entry: &mut JournalEntry,
695        _anomaly_type: &AnomalyType,
696        _rng: &mut R,
697    ) -> InjectionResult {
698        let amount = entry.total_debit();
699        if amount <= self.approval_threshold {
700            return InjectionResult::failure("Amount below approval threshold");
701        }
702
703        // Mark control status as exception (simulates skipped approval)
704        entry.header.control_status = ControlStatus::Exception;
705        entry.header.sod_violation = true;
706
707        InjectionResult::success(&format!(
708            "Skipped required approval for ${} entry (threshold: ${})",
709            amount, self.approval_threshold
710        ))
711        .with_impact(amount)
712        .with_metadata("threshold", &self.approval_threshold.to_string())
713    }
714}
715
716/// Strategy for weekend/holiday posting anomalies.
717pub struct WeekendPostingStrategy;
718
719impl Default for WeekendPostingStrategy {
720    fn default() -> Self {
721        Self
722    }
723}
724
725impl InjectionStrategy for WeekendPostingStrategy {
726    fn name(&self) -> &'static str {
727        "WeekendPosting"
728    }
729
730    fn can_apply(&self, _entry: &JournalEntry) -> bool {
731        true
732    }
733
734    fn apply<R: Rng>(
735        &self,
736        entry: &mut JournalEntry,
737        _anomaly_type: &AnomalyType,
738        rng: &mut R,
739    ) -> InjectionResult {
740        use chrono::Weekday;
741
742        let original_date = entry.header.posting_date;
743        let weekday = original_date.weekday();
744
745        // Find days until next weekend
746        let days_to_weekend = match weekday {
747            Weekday::Mon => 5,
748            Weekday::Tue => 4,
749            Weekday::Wed => 3,
750            Weekday::Thu => 2,
751            Weekday::Fri => 1,
752            Weekday::Sat => 0,
753            Weekday::Sun => 0,
754        };
755
756        // Move to Saturday or Sunday
757        let weekend_day = if rng.random_bool(0.6) {
758            days_to_weekend
759        } else {
760            days_to_weekend + 1
761        };
762        let new_date = original_date + chrono::Duration::days(weekend_day as i64);
763
764        entry.header.posting_date = new_date;
765
766        InjectionResult::success(&format!(
767            "Moved posting from {} ({:?}) to {} ({:?})",
768            original_date,
769            weekday,
770            new_date,
771            new_date.weekday()
772        ))
773        .with_metadata("original_date", &original_date.to_string())
774        .with_metadata("new_date", &new_date.to_string())
775    }
776}
777
778/// Strategy for reversed amount errors.
779pub struct ReversedAmountStrategy;
780
781impl Default for ReversedAmountStrategy {
782    fn default() -> Self {
783        Self
784    }
785}
786
787impl InjectionStrategy for ReversedAmountStrategy {
788    fn name(&self) -> &'static str {
789        "ReversedAmount"
790    }
791
792    fn can_apply(&self, entry: &JournalEntry) -> bool {
793        entry.lines.len() >= 2
794    }
795
796    fn apply<R: Rng>(
797        &self,
798        entry: &mut JournalEntry,
799        _anomaly_type: &AnomalyType,
800        rng: &mut R,
801    ) -> InjectionResult {
802        if entry.lines.len() < 2 {
803            return InjectionResult::failure("Need at least 2 lines to reverse");
804        }
805
806        // Pick a random line and swap its debit/credit
807        let line_idx = rng.random_range(0..entry.lines.len());
808        let line = &mut entry.lines[line_idx];
809
810        let original_debit = line.debit_amount;
811        let original_credit = line.credit_amount;
812
813        line.debit_amount = original_credit;
814        line.credit_amount = original_debit;
815
816        let impact = original_debit.max(original_credit);
817
818        InjectionResult::success(&format!(
819            "Reversed amounts on line {}: DR {} → CR {}, CR {} → DR {}",
820            line_idx + 1,
821            original_debit,
822            line.credit_amount,
823            original_credit,
824            line.debit_amount
825        ))
826        .with_impact(impact * Decimal::new(2, 0)) // Double impact due to both sides being wrong
827        .with_metadata("line_number", &(line_idx + 1).to_string())
828    }
829}
830
831/// Strategy for transposed digits errors.
832pub struct TransposedDigitsStrategy;
833
834impl Default for TransposedDigitsStrategy {
835    fn default() -> Self {
836        Self
837    }
838}
839
840impl InjectionStrategy for TransposedDigitsStrategy {
841    fn name(&self) -> &'static str {
842        "TransposedDigits"
843    }
844
845    fn can_apply(&self, entry: &JournalEntry) -> bool {
846        // Need at least one line with amount >= 10 (two digits to transpose)
847        entry.lines.iter().any(|l| {
848            let amount = if l.debit_amount > Decimal::ZERO {
849                l.debit_amount
850            } else {
851                l.credit_amount
852            };
853            amount >= Decimal::new(10, 0)
854        })
855    }
856
857    fn apply<R: Rng>(
858        &self,
859        entry: &mut JournalEntry,
860        _anomaly_type: &AnomalyType,
861        rng: &mut R,
862    ) -> InjectionResult {
863        // Find lines with transposable amounts
864        let valid_lines: Vec<usize> = entry
865            .lines
866            .iter()
867            .enumerate()
868            .filter(|(_, l)| {
869                let amount = if l.debit_amount > Decimal::ZERO {
870                    l.debit_amount
871                } else {
872                    l.credit_amount
873                };
874                amount >= Decimal::new(10, 0)
875            })
876            .map(|(i, _)| i)
877            .collect();
878
879        if valid_lines.is_empty() {
880            return InjectionResult::failure("No lines with transposable amounts");
881        }
882
883        let line_idx = valid_lines[rng.random_range(0..valid_lines.len())];
884        let line = &mut entry.lines[line_idx];
885
886        let is_debit = line.debit_amount > Decimal::ZERO;
887        let original_amount = if is_debit {
888            line.debit_amount
889        } else {
890            line.credit_amount
891        };
892
893        // Transpose two adjacent digits
894        let amount_str = original_amount.to_string().replace('.', "");
895        let chars: Vec<char> = amount_str.chars().collect();
896
897        if chars.len() < 2 {
898            return InjectionResult::failure("Amount too small to transpose");
899        }
900
901        // Pick a position to transpose (not the decimal point)
902        let pos = rng.random_range(0..chars.len() - 1);
903        let mut new_chars = chars.clone();
904        new_chars.swap(pos, pos + 1);
905
906        let new_str: String = new_chars.into_iter().collect();
907        let new_amount = new_str.parse::<i64>().unwrap_or(0);
908        let scale = original_amount.scale();
909        let new_decimal = Decimal::new(new_amount, scale);
910
911        let impact = (new_decimal - original_amount).abs();
912
913        if is_debit {
914            line.debit_amount = new_decimal;
915        } else {
916            line.credit_amount = new_decimal;
917        }
918
919        InjectionResult::success(&format!(
920            "Transposed digits: {} → {} (positions {} and {})",
921            original_amount,
922            new_decimal,
923            pos + 1,
924            pos + 2
925        ))
926        .with_impact(impact)
927        .with_metadata("original_amount", &original_amount.to_string())
928        .with_metadata("new_amount", &new_decimal.to_string())
929    }
930}
931
932/// Strategy for dormant account activity.
933pub struct DormantAccountStrategy {
934    /// List of dormant account codes to use.
935    pub dormant_accounts: Vec<String>,
936}
937
938impl Default for DormantAccountStrategy {
939    fn default() -> Self {
940        Self {
941            dormant_accounts: vec![
942                "199999".to_string(), // Suspense
943                "299999".to_string(), // Legacy clearing
944                "399999".to_string(), // Obsolete account
945                "999999".to_string(), // Test account
946            ],
947        }
948    }
949}
950
951impl InjectionStrategy for DormantAccountStrategy {
952    fn name(&self) -> &'static str {
953        "DormantAccountActivity"
954    }
955
956    fn can_apply(&self, entry: &JournalEntry) -> bool {
957        !entry.lines.is_empty() && !self.dormant_accounts.is_empty()
958    }
959
960    fn apply<R: Rng>(
961        &self,
962        entry: &mut JournalEntry,
963        _anomaly_type: &AnomalyType,
964        rng: &mut R,
965    ) -> InjectionResult {
966        if entry.lines.is_empty() || self.dormant_accounts.is_empty() {
967            return InjectionResult::failure("No lines or dormant accounts");
968        }
969
970        let line_idx = rng.random_range(0..entry.lines.len());
971        let line = &mut entry.lines[line_idx];
972
973        let original_account = line.gl_account.clone();
974        let dormant_account =
975            &self.dormant_accounts[rng.random_range(0..self.dormant_accounts.len())];
976
977        line.gl_account = dormant_account.clone();
978        line.account_code = dormant_account.clone();
979
980        let amount = if line.debit_amount > Decimal::ZERO {
981            line.debit_amount
982        } else {
983            line.credit_amount
984        };
985
986        InjectionResult::success(&format!(
987            "Changed account from {} to dormant account {}",
988            original_account, dormant_account
989        ))
990        .with_impact(amount)
991        .with_entity(dormant_account)
992        .with_metadata("original_account", &original_account)
993    }
994}
995
996/// Collection of all available strategies.
997#[derive(Default)]
998pub struct StrategyCollection {
999    pub amount_modification: AmountModificationStrategy,
1000    pub date_modification: DateModificationStrategy,
1001    pub duplication: DuplicationStrategy,
1002    pub approval_anomaly: ApprovalAnomalyStrategy,
1003    pub description_anomaly: DescriptionAnomalyStrategy,
1004    pub benford_violation: BenfordViolationStrategy,
1005    pub split_transaction: SplitTransactionStrategy,
1006    pub skipped_approval: SkippedApprovalStrategy,
1007    pub weekend_posting: WeekendPostingStrategy,
1008    pub reversed_amount: ReversedAmountStrategy,
1009    pub transposed_digits: TransposedDigitsStrategy,
1010    pub dormant_account: DormantAccountStrategy,
1011}
1012
1013impl StrategyCollection {
1014    /// Checks if the strategy can be applied to an entry.
1015    pub fn can_apply(&self, entry: &JournalEntry, anomaly_type: &AnomalyType) -> bool {
1016        match anomaly_type {
1017            // Amount-based strategies
1018            AnomalyType::Fraud(FraudType::RoundDollarManipulation)
1019            | AnomalyType::Statistical(StatisticalAnomalyType::UnusuallyHighAmount)
1020            | AnomalyType::Statistical(StatisticalAnomalyType::UnusuallyLowAmount) => {
1021                self.amount_modification.can_apply(entry)
1022            }
1023            // Date-based strategies
1024            AnomalyType::Error(ErrorType::BackdatedEntry)
1025            | AnomalyType::Error(ErrorType::FutureDatedEntry)
1026            | AnomalyType::Error(ErrorType::WrongPeriod)
1027            | AnomalyType::ProcessIssue(ProcessIssueType::LatePosting) => {
1028                self.date_modification.can_apply(entry)
1029            }
1030            // Approval threshold strategies
1031            AnomalyType::Fraud(FraudType::JustBelowThreshold)
1032            | AnomalyType::Fraud(FraudType::ExceededApprovalLimit) => {
1033                self.approval_anomaly.can_apply(entry)
1034            }
1035            // Description strategies
1036            AnomalyType::ProcessIssue(ProcessIssueType::VagueDescription) => {
1037                self.description_anomaly.can_apply(entry)
1038            }
1039            // Benford's Law strategies
1040            AnomalyType::Statistical(StatisticalAnomalyType::BenfordViolation) => {
1041                self.benford_violation.can_apply(entry)
1042            }
1043            // Split transaction (structuring)
1044            AnomalyType::Fraud(FraudType::SplitTransaction) => {
1045                self.split_transaction.can_apply(entry)
1046            }
1047            // Skipped approval
1048            AnomalyType::ProcessIssue(ProcessIssueType::SkippedApproval) => {
1049                self.skipped_approval.can_apply(entry)
1050            }
1051            // Weekend posting
1052            AnomalyType::ProcessIssue(ProcessIssueType::WeekendPosting)
1053            | AnomalyType::ProcessIssue(ProcessIssueType::AfterHoursPosting) => {
1054                self.weekend_posting.can_apply(entry)
1055            }
1056            // Reversed amount
1057            AnomalyType::Error(ErrorType::ReversedAmount) => self.reversed_amount.can_apply(entry),
1058            // Transposed digits
1059            AnomalyType::Error(ErrorType::TransposedDigits) => {
1060                self.transposed_digits.can_apply(entry)
1061            }
1062            // Dormant account
1063            AnomalyType::Relational(RelationalAnomalyType::DormantAccountActivity) => {
1064                self.dormant_account.can_apply(entry)
1065            }
1066            // Default fallback
1067            _ => self.amount_modification.can_apply(entry),
1068        }
1069    }
1070
1071    /// Applies the appropriate strategy for an anomaly type.
1072    pub fn apply_strategy<R: Rng>(
1073        &self,
1074        entry: &mut JournalEntry,
1075        anomaly_type: &AnomalyType,
1076        rng: &mut R,
1077    ) -> InjectionResult {
1078        match anomaly_type {
1079            // Amount-based strategies
1080            AnomalyType::Fraud(FraudType::RoundDollarManipulation)
1081            | AnomalyType::Statistical(StatisticalAnomalyType::UnusuallyHighAmount)
1082            | AnomalyType::Statistical(StatisticalAnomalyType::UnusuallyLowAmount) => {
1083                self.amount_modification.apply(entry, anomaly_type, rng)
1084            }
1085            // Date-based strategies
1086            AnomalyType::Error(ErrorType::BackdatedEntry)
1087            | AnomalyType::Error(ErrorType::FutureDatedEntry)
1088            | AnomalyType::Error(ErrorType::WrongPeriod)
1089            | AnomalyType::ProcessIssue(ProcessIssueType::LatePosting) => {
1090                self.date_modification.apply(entry, anomaly_type, rng)
1091            }
1092            // Approval threshold strategies
1093            AnomalyType::Fraud(FraudType::JustBelowThreshold)
1094            | AnomalyType::Fraud(FraudType::ExceededApprovalLimit) => {
1095                self.approval_anomaly.apply(entry, anomaly_type, rng)
1096            }
1097            // Description strategies
1098            AnomalyType::ProcessIssue(ProcessIssueType::VagueDescription) => {
1099                self.description_anomaly.apply(entry, anomaly_type, rng)
1100            }
1101            // Benford's Law strategies
1102            AnomalyType::Statistical(StatisticalAnomalyType::BenfordViolation) => {
1103                self.benford_violation.apply(entry, anomaly_type, rng)
1104            }
1105            // Split transaction (structuring)
1106            AnomalyType::Fraud(FraudType::SplitTransaction) => {
1107                self.split_transaction.apply(entry, anomaly_type, rng)
1108            }
1109            // Skipped approval
1110            AnomalyType::ProcessIssue(ProcessIssueType::SkippedApproval) => {
1111                self.skipped_approval.apply(entry, anomaly_type, rng)
1112            }
1113            // Weekend posting
1114            AnomalyType::ProcessIssue(ProcessIssueType::WeekendPosting)
1115            | AnomalyType::ProcessIssue(ProcessIssueType::AfterHoursPosting) => {
1116                self.weekend_posting.apply(entry, anomaly_type, rng)
1117            }
1118            // Reversed amount
1119            AnomalyType::Error(ErrorType::ReversedAmount) => {
1120                self.reversed_amount.apply(entry, anomaly_type, rng)
1121            }
1122            // Transposed digits
1123            AnomalyType::Error(ErrorType::TransposedDigits) => {
1124                self.transposed_digits.apply(entry, anomaly_type, rng)
1125            }
1126            // Dormant account
1127            AnomalyType::Relational(RelationalAnomalyType::DormantAccountActivity) => {
1128                self.dormant_account.apply(entry, anomaly_type, rng)
1129            }
1130            // Default fallback
1131            _ => self.amount_modification.apply(entry, anomaly_type, rng),
1132        }
1133    }
1134}
1135
1136#[cfg(test)]
1137#[allow(clippy::unwrap_used)]
1138mod tests {
1139    use super::*;
1140    use chrono::NaiveDate;
1141    use datasynth_core::models::JournalEntryLine;
1142    use rand::SeedableRng;
1143    use rand_chacha::ChaCha8Rng;
1144    use rust_decimal_macros::dec;
1145
1146    fn create_test_entry() -> JournalEntry {
1147        let mut entry = JournalEntry::new_simple(
1148            "JE001".to_string(),
1149            "1000".to_string(),
1150            NaiveDate::from_ymd_opt(2024, 6, 15).unwrap(),
1151            "Test Entry".to_string(),
1152        );
1153
1154        entry.add_line(JournalEntryLine {
1155            line_number: 1,
1156            gl_account: "5000".to_string(),
1157            debit_amount: dec!(1000),
1158            ..Default::default()
1159        });
1160
1161        entry.add_line(JournalEntryLine {
1162            line_number: 2,
1163            gl_account: "1000".to_string(),
1164            credit_amount: dec!(1000),
1165            ..Default::default()
1166        });
1167
1168        entry
1169    }
1170
1171    #[test]
1172    fn test_amount_modification() {
1173        let strategy = AmountModificationStrategy::default();
1174        let mut entry = create_test_entry();
1175        let mut rng = ChaCha8Rng::seed_from_u64(42);
1176
1177        let result = strategy.apply(
1178            &mut entry,
1179            &AnomalyType::Statistical(StatisticalAnomalyType::UnusuallyHighAmount),
1180            &mut rng,
1181        );
1182
1183        assert!(result.success);
1184        assert!(result.monetary_impact.is_some());
1185    }
1186
1187    #[test]
1188    fn test_amount_modification_rebalanced() {
1189        let strategy = AmountModificationStrategy {
1190            rebalance_entry: true,
1191            ..Default::default()
1192        };
1193        let mut entry = create_test_entry();
1194        let mut rng = ChaCha8Rng::seed_from_u64(42);
1195
1196        // Entry should start balanced
1197        assert!(entry.is_balanced());
1198
1199        let result = strategy.apply(
1200            &mut entry,
1201            &AnomalyType::Statistical(StatisticalAnomalyType::UnusuallyHighAmount),
1202            &mut rng,
1203        );
1204
1205        assert!(result.success);
1206        // Entry should remain balanced after rebalancing
1207        assert!(
1208            entry.is_balanced(),
1209            "Entry should remain balanced after amount modification with rebalancing"
1210        );
1211    }
1212
1213    #[test]
1214    fn test_amount_modification_unbalanced_fraud() {
1215        let strategy = AmountModificationStrategy {
1216            rebalance_entry: false, // Intentionally create unbalanced entry for fraud detection
1217            ..Default::default()
1218        };
1219        let mut entry = create_test_entry();
1220        let mut rng = ChaCha8Rng::seed_from_u64(42);
1221
1222        // Entry should start balanced
1223        assert!(entry.is_balanced());
1224
1225        let result = strategy.apply(
1226            &mut entry,
1227            &AnomalyType::Fraud(FraudType::RoundDollarManipulation),
1228            &mut rng,
1229        );
1230
1231        assert!(result.success);
1232        // Entry should be unbalanced when rebalance is disabled
1233        assert!(
1234            !entry.is_balanced(),
1235            "Entry should be unbalanced when rebalance_entry is false"
1236        );
1237    }
1238
1239    #[test]
1240    fn test_benford_violation_rebalanced() {
1241        let strategy = BenfordViolationStrategy {
1242            rebalance_entry: true,
1243            ..Default::default()
1244        };
1245        let mut entry = create_test_entry();
1246        let mut rng = ChaCha8Rng::seed_from_u64(42);
1247
1248        // Entry should start balanced
1249        assert!(entry.is_balanced());
1250
1251        let result = strategy.apply(
1252            &mut entry,
1253            &AnomalyType::Statistical(StatisticalAnomalyType::BenfordViolation),
1254            &mut rng,
1255        );
1256
1257        assert!(result.success);
1258        // Entry should remain balanced after rebalancing
1259        assert!(
1260            entry.is_balanced(),
1261            "Entry should remain balanced after Benford violation with rebalancing"
1262        );
1263    }
1264
1265    #[test]
1266    fn test_date_modification() {
1267        let strategy = DateModificationStrategy::default();
1268        let mut entry = create_test_entry();
1269        let original_date = entry.header.posting_date;
1270        let mut rng = ChaCha8Rng::seed_from_u64(42);
1271
1272        let result = strategy.apply(
1273            &mut entry,
1274            &AnomalyType::Error(ErrorType::BackdatedEntry),
1275            &mut rng,
1276        );
1277
1278        assert!(result.success);
1279        assert!(entry.header.posting_date < original_date);
1280    }
1281
1282    #[test]
1283    fn test_description_anomaly() {
1284        let strategy = DescriptionAnomalyStrategy::default();
1285        let mut entry = create_test_entry();
1286        let mut rng = ChaCha8Rng::seed_from_u64(42);
1287
1288        let result = strategy.apply(
1289            &mut entry,
1290            &AnomalyType::ProcessIssue(ProcessIssueType::VagueDescription),
1291            &mut rng,
1292        );
1293
1294        assert!(result.success);
1295        let desc = entry.description().unwrap_or("").to_string();
1296        assert!(strategy.vague_descriptions.contains(&desc));
1297    }
1298}