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