1use 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
15pub trait InjectionStrategy {
17 fn name(&self) -> &'static str;
19
20 fn can_apply(&self, entry: &JournalEntry) -> bool;
22
23 fn apply<R: Rng>(
25 &self,
26 entry: &mut JournalEntry,
27 anomaly_type: &AnomalyType,
28 rng: &mut R,
29 ) -> InjectionResult;
30}
31
32#[derive(Debug, Clone)]
34pub struct InjectionResult {
35 pub success: bool,
37 pub description: String,
39 pub monetary_impact: Option<Decimal>,
41 pub related_entities: Vec<String>,
43 pub metadata: Vec<(String, String)>,
45}
46
47impl InjectionResult {
48 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 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 pub fn with_impact(mut self, impact: Decimal) -> Self {
72 self.monetary_impact = Some(impact);
73 self
74 }
75
76 pub fn with_entity(mut self, entity: &str) -> Self {
78 self.related_entities.push(entity.to_string());
79 self
80 }
81
82 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
89pub struct AmountModificationStrategy {
91 pub min_multiplier: f64,
93 pub max_multiplier: f64,
95 pub prefer_round_numbers: bool,
97 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, }
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 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 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 if self.rebalance_entry {
164 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 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
226pub struct DateModificationStrategy {
228 pub max_backdate_days: i64,
230 pub max_future_days: i64,
232 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 let direction: i64 = if rng.gen_bool(0.5) { -1 } else { 1 };
275 let days = direction * 32; (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; 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
301pub struct DuplicationStrategy {
303 pub vary_amounts: bool,
305 pub amount_variance: f64,
307 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 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 duplicate.header.document_id = uuid_factory.next();
334 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
358pub struct ApprovalAnomalyStrategy {
360 pub approval_threshold: Decimal,
362 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 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 let target = self.approval_threshold * Decimal::new(15, 1); 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
442pub struct DescriptionAnomalyStrategy {
444 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
496pub struct BenfordViolationStrategy {
498 pub target_digits: Vec<u32>,
500 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], rebalance_entry: true, }
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 let target_digit = self.target_digits[rng.gen_range(0..self.target_digits.len())];
542
543 let original_str = original_amount.to_string();
545 let magnitude = original_str.replace('.', "").trim_start_matches('0').len() as i32 - 1;
546 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 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 if self.rebalance_entry {
564 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 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
602pub struct SplitTransactionStrategy {
604 pub split_threshold: Decimal,
606 pub min_splits: usize,
608 pub max_splits: usize,
609 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 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 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
667pub struct SkippedApprovalStrategy {
669 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 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 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
715pub 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 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 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
777pub 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 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)) .with_metadata("line_number", &(line_idx + 1).to_string())
827 }
828}
829
830pub 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 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 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 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 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
931pub struct DormantAccountStrategy {
933 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(), "299999".to_string(), "399999".to_string(), "999999".to_string(), ],
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#[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 pub fn can_apply(&self, entry: &JournalEntry, anomaly_type: &AnomalyType) -> bool {
1014 match anomaly_type {
1015 AnomalyType::Fraud(FraudType::RoundDollarManipulation)
1017 | AnomalyType::Statistical(StatisticalAnomalyType::UnusuallyHighAmount)
1018 | AnomalyType::Statistical(StatisticalAnomalyType::UnusuallyLowAmount) => {
1019 self.amount_modification.can_apply(entry)
1020 }
1021 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 AnomalyType::Fraud(FraudType::JustBelowThreshold)
1030 | AnomalyType::Fraud(FraudType::ExceededApprovalLimit) => {
1031 self.approval_anomaly.can_apply(entry)
1032 }
1033 AnomalyType::ProcessIssue(ProcessIssueType::VagueDescription) => {
1035 self.description_anomaly.can_apply(entry)
1036 }
1037 AnomalyType::Statistical(StatisticalAnomalyType::BenfordViolation) => {
1039 self.benford_violation.can_apply(entry)
1040 }
1041 AnomalyType::Fraud(FraudType::SplitTransaction) => {
1043 self.split_transaction.can_apply(entry)
1044 }
1045 AnomalyType::ProcessIssue(ProcessIssueType::SkippedApproval) => {
1047 self.skipped_approval.can_apply(entry)
1048 }
1049 AnomalyType::ProcessIssue(ProcessIssueType::WeekendPosting)
1051 | AnomalyType::ProcessIssue(ProcessIssueType::AfterHoursPosting) => {
1052 self.weekend_posting.can_apply(entry)
1053 }
1054 AnomalyType::Error(ErrorType::ReversedAmount) => self.reversed_amount.can_apply(entry),
1056 AnomalyType::Error(ErrorType::TransposedDigits) => {
1058 self.transposed_digits.can_apply(entry)
1059 }
1060 AnomalyType::Relational(RelationalAnomalyType::DormantAccountActivity) => {
1062 self.dormant_account.can_apply(entry)
1063 }
1064 _ => self.amount_modification.can_apply(entry),
1066 }
1067 }
1068
1069 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 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 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 AnomalyType::Fraud(FraudType::JustBelowThreshold)
1092 | AnomalyType::Fraud(FraudType::ExceededApprovalLimit) => {
1093 self.approval_anomaly.apply(entry, anomaly_type, rng)
1094 }
1095 AnomalyType::ProcessIssue(ProcessIssueType::VagueDescription) => {
1097 self.description_anomaly.apply(entry, anomaly_type, rng)
1098 }
1099 AnomalyType::Statistical(StatisticalAnomalyType::BenfordViolation) => {
1101 self.benford_violation.apply(entry, anomaly_type, rng)
1102 }
1103 AnomalyType::Fraud(FraudType::SplitTransaction) => {
1105 self.split_transaction.apply(entry, anomaly_type, rng)
1106 }
1107 AnomalyType::ProcessIssue(ProcessIssueType::SkippedApproval) => {
1109 self.skipped_approval.apply(entry, anomaly_type, rng)
1110 }
1111 AnomalyType::ProcessIssue(ProcessIssueType::WeekendPosting)
1113 | AnomalyType::ProcessIssue(ProcessIssueType::AfterHoursPosting) => {
1114 self.weekend_posting.apply(entry, anomaly_type, rng)
1115 }
1116 AnomalyType::Error(ErrorType::ReversedAmount) => {
1118 self.reversed_amount.apply(entry, anomaly_type, rng)
1119 }
1120 AnomalyType::Error(ErrorType::TransposedDigits) => {
1122 self.transposed_digits.apply(entry, anomaly_type, rng)
1123 }
1124 AnomalyType::Relational(RelationalAnomalyType::DormantAccountActivity) => {
1126 self.dormant_account.apply(entry, anomaly_type, rng)
1127 }
1128 _ => 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 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 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, ..Default::default()
1216 };
1217 let mut entry = create_test_entry();
1218 let mut rng = ChaCha8Rng::seed_from_u64(42);
1219
1220 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 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 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 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}