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 original_str = original_amount.to_string();
555 let magnitude = original_str.replace('.', "").trim_start_matches('0').len() as i32 - 1;
556 let safe_magnitude = magnitude.clamp(0, 18) as u32;
558
559 let base = Decimal::new(10_i64.pow(safe_magnitude), 0);
560 let new_amount = base * Decimal::new(target_digit as i64, 0)
561 + Decimal::new(rng.random_range(0..10_i64.pow(safe_magnitude)), 0);
562
563 let impact = new_amount - original_amount;
564
565 if is_debit {
567 entry.lines[line_idx].debit_amount = new_amount;
568 } else {
569 entry.lines[line_idx].credit_amount = new_amount;
570 }
571
572 if self.rebalance_entry {
574 let balancing_idx = entry.lines.iter().position(|l| {
576 if is_debit {
577 l.credit_amount > Decimal::ZERO
578 } else {
579 l.debit_amount > Decimal::ZERO
580 }
581 });
582
583 if let Some(bal_idx) = balancing_idx {
584 if is_debit {
586 entry.lines[bal_idx].credit_amount += impact;
587 } else {
588 entry.lines[bal_idx].debit_amount += impact;
589 }
590 }
591 }
592
593 let first_digit = target_digit;
594 let benford_prob = (1.0 + 1.0 / first_digit as f64).log10();
595
596 InjectionResult::success(&format!(
597 "Created Benford violation: first digit {} (expected probability {:.1}%){}",
598 first_digit,
599 benford_prob * 100.0,
600 if self.rebalance_entry {
601 " [rebalanced]"
602 } else {
603 " [UNBALANCED]"
604 }
605 ))
606 .with_impact(impact)
607 .with_metadata("first_digit", &first_digit.to_string())
608 .with_metadata("benford_probability", &format!("{benford_prob:.4}"))
609 }
610}
611
612pub struct SplitTransactionStrategy {
614 pub split_threshold: Decimal,
616 pub min_splits: usize,
618 pub max_splits: usize,
619 pub threshold_buffer: Decimal,
621}
622
623impl Default for SplitTransactionStrategy {
624 fn default() -> Self {
625 Self {
626 split_threshold: Decimal::new(10000, 0),
627 min_splits: 2,
628 max_splits: 5,
629 threshold_buffer: Decimal::new(500, 0),
630 }
631 }
632}
633
634impl InjectionStrategy for SplitTransactionStrategy {
635 fn name(&self) -> &'static str {
636 "SplitTransaction"
637 }
638
639 fn can_apply(&self, entry: &JournalEntry) -> bool {
640 entry.total_debit() > self.split_threshold
642 }
643
644 fn apply<R: Rng>(
645 &self,
646 entry: &mut JournalEntry,
647 _anomaly_type: &AnomalyType,
648 rng: &mut R,
649 ) -> InjectionResult {
650 let total = entry.total_debit();
651 if total <= self.split_threshold || total.is_zero() {
652 return InjectionResult::failure("Amount below split threshold");
653 }
654
655 let num_splits = rng.random_range(self.min_splits..=self.max_splits);
656 let target_per_split = self.split_threshold
657 - self.threshold_buffer
658 - Decimal::new(rng.random_range(1..100), 0);
659
660 let scale = target_per_split / total;
662 for line in &mut entry.lines {
663 line.debit_amount = (line.debit_amount * scale).round_dp(2);
664 line.credit_amount = (line.credit_amount * scale).round_dp(2);
665 }
666
667 InjectionResult::success(&format!(
668 "Split ${} transaction into {} parts of ~${} each (below ${} threshold)",
669 total, num_splits, target_per_split, self.split_threshold
670 ))
671 .with_impact(total)
672 .with_metadata("original_amount", &total.to_string())
673 .with_metadata("num_splits", &num_splits.to_string())
674 .with_metadata("threshold", &self.split_threshold.to_string())
675 }
676}
677
678pub struct SkippedApprovalStrategy {
680 pub approval_threshold: Decimal,
682}
683
684impl Default for SkippedApprovalStrategy {
685 fn default() -> Self {
686 Self {
687 approval_threshold: Decimal::new(5000, 0),
688 }
689 }
690}
691
692impl InjectionStrategy for SkippedApprovalStrategy {
693 fn name(&self) -> &'static str {
694 "SkippedApproval"
695 }
696
697 fn can_apply(&self, entry: &JournalEntry) -> bool {
698 entry.total_debit() > self.approval_threshold
700 }
701
702 fn apply<R: Rng>(
703 &self,
704 entry: &mut JournalEntry,
705 _anomaly_type: &AnomalyType,
706 _rng: &mut R,
707 ) -> InjectionResult {
708 let amount = entry.total_debit();
709 if amount <= self.approval_threshold {
710 return InjectionResult::failure("Amount below approval threshold");
711 }
712
713 entry.header.control_status = ControlStatus::Exception;
715 entry.header.sod_violation = true;
716
717 InjectionResult::success(&format!(
718 "Skipped required approval for ${} entry (threshold: ${})",
719 amount, self.approval_threshold
720 ))
721 .with_impact(amount)
722 .with_metadata("threshold", &self.approval_threshold.to_string())
723 }
724}
725
726pub struct WeekendPostingStrategy;
728
729impl Default for WeekendPostingStrategy {
730 fn default() -> Self {
731 Self
732 }
733}
734
735impl InjectionStrategy for WeekendPostingStrategy {
736 fn name(&self) -> &'static str {
737 "WeekendPosting"
738 }
739
740 fn can_apply(&self, _entry: &JournalEntry) -> bool {
741 true
742 }
743
744 fn apply<R: Rng>(
745 &self,
746 entry: &mut JournalEntry,
747 _anomaly_type: &AnomalyType,
748 rng: &mut R,
749 ) -> InjectionResult {
750 use chrono::Weekday;
751
752 let original_date = entry.header.posting_date;
753 let weekday = original_date.weekday();
754
755 let days_to_weekend = match weekday {
757 Weekday::Mon => 5,
758 Weekday::Tue => 4,
759 Weekday::Wed => 3,
760 Weekday::Thu => 2,
761 Weekday::Fri => 1,
762 Weekday::Sat => 0,
763 Weekday::Sun => 0,
764 };
765
766 let weekend_day = if rng.random_bool(0.6) {
768 days_to_weekend
769 } else {
770 days_to_weekend + 1
771 };
772 let new_date = original_date + chrono::Duration::days(weekend_day as i64);
773
774 entry.header.posting_date = new_date;
775
776 InjectionResult::success(&format!(
777 "Moved posting from {} ({:?}) to {} ({:?})",
778 original_date,
779 weekday,
780 new_date,
781 new_date.weekday()
782 ))
783 .with_metadata("original_date", &original_date.to_string())
784 .with_metadata("new_date", &new_date.to_string())
785 }
786}
787
788pub struct ReversedAmountStrategy;
790
791impl Default for ReversedAmountStrategy {
792 fn default() -> Self {
793 Self
794 }
795}
796
797impl InjectionStrategy for ReversedAmountStrategy {
798 fn name(&self) -> &'static str {
799 "ReversedAmount"
800 }
801
802 fn can_apply(&self, entry: &JournalEntry) -> bool {
803 entry.lines.len() >= 2
804 }
805
806 fn apply<R: Rng>(
807 &self,
808 entry: &mut JournalEntry,
809 _anomaly_type: &AnomalyType,
810 rng: &mut R,
811 ) -> InjectionResult {
812 if entry.lines.len() < 2 {
813 return InjectionResult::failure("Need at least 2 lines to reverse");
814 }
815
816 let line_idx = rng.random_range(0..entry.lines.len());
818 let line = &mut entry.lines[line_idx];
819
820 let original_debit = line.debit_amount;
821 let original_credit = line.credit_amount;
822
823 line.debit_amount = original_credit;
824 line.credit_amount = original_debit;
825
826 let impact = original_debit.max(original_credit);
827
828 InjectionResult::success(&format!(
829 "Reversed amounts on line {}: DR {} → CR {}, CR {} → DR {}",
830 line_idx + 1,
831 original_debit,
832 line.credit_amount,
833 original_credit,
834 line.debit_amount
835 ))
836 .with_impact(impact * Decimal::new(2, 0)) .with_metadata("line_number", &(line_idx + 1).to_string())
838 }
839}
840
841pub struct TransposedDigitsStrategy;
843
844impl Default for TransposedDigitsStrategy {
845 fn default() -> Self {
846 Self
847 }
848}
849
850impl InjectionStrategy for TransposedDigitsStrategy {
851 fn name(&self) -> &'static str {
852 "TransposedDigits"
853 }
854
855 fn can_apply(&self, entry: &JournalEntry) -> bool {
856 entry.lines.iter().any(|l| {
858 let amount = if l.debit_amount > Decimal::ZERO {
859 l.debit_amount
860 } else {
861 l.credit_amount
862 };
863 amount >= Decimal::new(10, 0)
864 })
865 }
866
867 fn apply<R: Rng>(
868 &self,
869 entry: &mut JournalEntry,
870 _anomaly_type: &AnomalyType,
871 rng: &mut R,
872 ) -> InjectionResult {
873 let valid_lines: Vec<usize> = entry
875 .lines
876 .iter()
877 .enumerate()
878 .filter(|(_, l)| {
879 let amount = if l.debit_amount > Decimal::ZERO {
880 l.debit_amount
881 } else {
882 l.credit_amount
883 };
884 amount >= Decimal::new(10, 0)
885 })
886 .map(|(i, _)| i)
887 .collect();
888
889 if valid_lines.is_empty() {
890 return InjectionResult::failure("No lines with transposable amounts");
891 }
892
893 let line_idx = valid_lines[rng.random_range(0..valid_lines.len())];
894 let line = &mut entry.lines[line_idx];
895
896 let is_debit = line.debit_amount > Decimal::ZERO;
897 let original_amount = if is_debit {
898 line.debit_amount
899 } else {
900 line.credit_amount
901 };
902
903 let amount_str = original_amount.to_string().replace('.', "");
905 let chars: Vec<char> = amount_str.chars().collect();
906
907 if chars.len() < 2 {
908 return InjectionResult::failure("Amount too small to transpose");
909 }
910
911 let pos = rng.random_range(0..chars.len() - 1);
913 let mut new_chars = chars.clone();
914 new_chars.swap(pos, pos + 1);
915
916 let new_str: String = new_chars.into_iter().collect();
917 let new_amount = new_str.parse::<i64>().unwrap_or(0);
918 let scale = original_amount.scale();
919 let new_decimal = Decimal::new(new_amount, scale);
920
921 let impact = (new_decimal - original_amount).abs();
922
923 if is_debit {
924 line.debit_amount = new_decimal;
925 } else {
926 line.credit_amount = new_decimal;
927 }
928
929 InjectionResult::success(&format!(
930 "Transposed digits: {} → {} (positions {} and {})",
931 original_amount,
932 new_decimal,
933 pos + 1,
934 pos + 2
935 ))
936 .with_impact(impact)
937 .with_metadata("original_amount", &original_amount.to_string())
938 .with_metadata("new_amount", &new_decimal.to_string())
939 }
940}
941
942pub struct DormantAccountStrategy {
944 pub dormant_accounts: Vec<String>,
946}
947
948impl Default for DormantAccountStrategy {
949 fn default() -> Self {
950 Self {
957 dormant_accounts: vec![
958 dormant_accounts::LEGACY_SUSPENSE.to_string(),
959 dormant_accounts::LEGACY_CLEARING.to_string(),
960 dormant_accounts::OBSOLETE.to_string(),
961 dormant_accounts::TEST_ACCOUNT.to_string(),
962 ],
963 }
964 }
965}
966
967impl InjectionStrategy for DormantAccountStrategy {
968 fn name(&self) -> &'static str {
969 "DormantAccountActivity"
970 }
971
972 fn can_apply(&self, entry: &JournalEntry) -> bool {
973 !entry.lines.is_empty() && !self.dormant_accounts.is_empty()
974 }
975
976 fn apply<R: Rng>(
977 &self,
978 entry: &mut JournalEntry,
979 _anomaly_type: &AnomalyType,
980 rng: &mut R,
981 ) -> InjectionResult {
982 if entry.lines.is_empty() || self.dormant_accounts.is_empty() {
983 return InjectionResult::failure("No lines or dormant accounts");
984 }
985
986 let line_idx = rng.random_range(0..entry.lines.len());
987 let line = &mut entry.lines[line_idx];
988
989 let original_account = line.gl_account.clone();
990 let dormant_account =
991 &self.dormant_accounts[rng.random_range(0..self.dormant_accounts.len())];
992
993 line.gl_account = dormant_account.clone();
994 line.account_code = dormant_account.clone();
995
996 let amount = if line.debit_amount > Decimal::ZERO {
997 line.debit_amount
998 } else {
999 line.credit_amount
1000 };
1001
1002 InjectionResult::success(&format!(
1003 "Changed account from {original_account} to dormant account {dormant_account}"
1004 ))
1005 .with_impact(amount)
1006 .with_entity(dormant_account)
1007 .with_metadata("original_account", &original_account)
1008 }
1009}
1010
1011#[derive(Default)]
1013pub struct StrategyCollection {
1014 pub amount_modification: AmountModificationStrategy,
1015 pub date_modification: DateModificationStrategy,
1016 pub duplication: DuplicationStrategy,
1017 pub approval_anomaly: ApprovalAnomalyStrategy,
1018 pub description_anomaly: DescriptionAnomalyStrategy,
1019 pub benford_violation: BenfordViolationStrategy,
1020 pub split_transaction: SplitTransactionStrategy,
1021 pub skipped_approval: SkippedApprovalStrategy,
1022 pub weekend_posting: WeekendPostingStrategy,
1023 pub reversed_amount: ReversedAmountStrategy,
1024 pub transposed_digits: TransposedDigitsStrategy,
1025 pub dormant_account: DormantAccountStrategy,
1026}
1027
1028impl StrategyCollection {
1029 pub fn can_apply(&self, entry: &JournalEntry, anomaly_type: &AnomalyType) -> bool {
1031 match anomaly_type {
1032 AnomalyType::Fraud(FraudType::RoundDollarManipulation)
1034 | AnomalyType::Statistical(StatisticalAnomalyType::UnusuallyHighAmount)
1035 | AnomalyType::Statistical(StatisticalAnomalyType::UnusuallyLowAmount) => {
1036 self.amount_modification.can_apply(entry)
1037 }
1038 AnomalyType::Error(ErrorType::BackdatedEntry)
1040 | AnomalyType::Error(ErrorType::FutureDatedEntry)
1041 | AnomalyType::Error(ErrorType::WrongPeriod)
1042 | AnomalyType::ProcessIssue(ProcessIssueType::LatePosting) => {
1043 self.date_modification.can_apply(entry)
1044 }
1045 AnomalyType::Fraud(FraudType::JustBelowThreshold)
1047 | AnomalyType::Fraud(FraudType::ExceededApprovalLimit) => {
1048 self.approval_anomaly.can_apply(entry)
1049 }
1050 AnomalyType::ProcessIssue(ProcessIssueType::VagueDescription) => {
1052 self.description_anomaly.can_apply(entry)
1053 }
1054 AnomalyType::Statistical(StatisticalAnomalyType::BenfordViolation) => {
1056 self.benford_violation.can_apply(entry)
1057 }
1058 AnomalyType::Fraud(FraudType::SplitTransaction) => {
1060 self.split_transaction.can_apply(entry)
1061 }
1062 AnomalyType::ProcessIssue(ProcessIssueType::SkippedApproval) => {
1064 self.skipped_approval.can_apply(entry)
1065 }
1066 AnomalyType::ProcessIssue(ProcessIssueType::WeekendPosting)
1068 | AnomalyType::ProcessIssue(ProcessIssueType::AfterHoursPosting) => {
1069 self.weekend_posting.can_apply(entry)
1070 }
1071 AnomalyType::Error(ErrorType::ReversedAmount) => self.reversed_amount.can_apply(entry),
1073 AnomalyType::Error(ErrorType::TransposedDigits) => {
1075 self.transposed_digits.can_apply(entry)
1076 }
1077 AnomalyType::Relational(RelationalAnomalyType::DormantAccountActivity) => {
1079 self.dormant_account.can_apply(entry)
1080 }
1081 _ => self.amount_modification.can_apply(entry),
1083 }
1084 }
1085
1086 pub fn apply_strategy<R: Rng>(
1088 &self,
1089 entry: &mut JournalEntry,
1090 anomaly_type: &AnomalyType,
1091 rng: &mut R,
1092 ) -> InjectionResult {
1093 match anomaly_type {
1094 AnomalyType::Fraud(FraudType::RoundDollarManipulation)
1096 | AnomalyType::Statistical(StatisticalAnomalyType::UnusuallyHighAmount)
1097 | AnomalyType::Statistical(StatisticalAnomalyType::UnusuallyLowAmount) => {
1098 self.amount_modification.apply(entry, anomaly_type, rng)
1099 }
1100 AnomalyType::Error(ErrorType::BackdatedEntry)
1102 | AnomalyType::Error(ErrorType::FutureDatedEntry)
1103 | AnomalyType::Error(ErrorType::WrongPeriod)
1104 | AnomalyType::ProcessIssue(ProcessIssueType::LatePosting) => {
1105 self.date_modification.apply(entry, anomaly_type, rng)
1106 }
1107 AnomalyType::Fraud(FraudType::JustBelowThreshold)
1109 | AnomalyType::Fraud(FraudType::ExceededApprovalLimit) => {
1110 self.approval_anomaly.apply(entry, anomaly_type, rng)
1111 }
1112 AnomalyType::ProcessIssue(ProcessIssueType::VagueDescription) => {
1114 self.description_anomaly.apply(entry, anomaly_type, rng)
1115 }
1116 AnomalyType::Statistical(StatisticalAnomalyType::BenfordViolation) => {
1118 self.benford_violation.apply(entry, anomaly_type, rng)
1119 }
1120 AnomalyType::Fraud(FraudType::SplitTransaction) => {
1122 self.split_transaction.apply(entry, anomaly_type, rng)
1123 }
1124 AnomalyType::ProcessIssue(ProcessIssueType::SkippedApproval) => {
1126 self.skipped_approval.apply(entry, anomaly_type, rng)
1127 }
1128 AnomalyType::ProcessIssue(ProcessIssueType::WeekendPosting)
1130 | AnomalyType::ProcessIssue(ProcessIssueType::AfterHoursPosting) => {
1131 self.weekend_posting.apply(entry, anomaly_type, rng)
1132 }
1133 AnomalyType::Error(ErrorType::ReversedAmount) => {
1135 self.reversed_amount.apply(entry, anomaly_type, rng)
1136 }
1137 AnomalyType::Error(ErrorType::TransposedDigits) => {
1139 self.transposed_digits.apply(entry, anomaly_type, rng)
1140 }
1141 AnomalyType::Relational(RelationalAnomalyType::DormantAccountActivity) => {
1143 self.dormant_account.apply(entry, anomaly_type, rng)
1144 }
1145 _ => self.amount_modification.apply(entry, anomaly_type, rng),
1147 }
1148 }
1149}
1150
1151#[cfg(test)]
1152#[allow(clippy::unwrap_used)]
1153mod tests {
1154 use super::*;
1155 use chrono::NaiveDate;
1156 use datasynth_core::models::JournalEntryLine;
1157 use rand::SeedableRng;
1158 use rand_chacha::ChaCha8Rng;
1159 use rust_decimal_macros::dec;
1160
1161 fn create_test_entry() -> JournalEntry {
1162 let mut entry = JournalEntry::new_simple(
1163 "JE001".to_string(),
1164 "1000".to_string(),
1165 NaiveDate::from_ymd_opt(2024, 6, 15).unwrap(),
1166 "Test Entry".to_string(),
1167 );
1168
1169 entry.add_line(JournalEntryLine {
1170 line_number: 1,
1171 gl_account: "5000".to_string(),
1172 debit_amount: dec!(1000),
1173 ..Default::default()
1174 });
1175
1176 entry.add_line(JournalEntryLine {
1177 line_number: 2,
1178 gl_account: "1000".to_string(),
1179 credit_amount: dec!(1000),
1180 ..Default::default()
1181 });
1182
1183 entry
1184 }
1185
1186 #[test]
1187 fn test_amount_modification() {
1188 let strategy = AmountModificationStrategy::default();
1189 let mut entry = create_test_entry();
1190 let mut rng = ChaCha8Rng::seed_from_u64(42);
1191
1192 let result = strategy.apply(
1193 &mut entry,
1194 &AnomalyType::Statistical(StatisticalAnomalyType::UnusuallyHighAmount),
1195 &mut rng,
1196 );
1197
1198 assert!(result.success);
1199 assert!(result.monetary_impact.is_some());
1200 }
1201
1202 #[test]
1203 fn test_amount_modification_rebalanced() {
1204 let strategy = AmountModificationStrategy {
1205 rebalance_entry: true,
1206 ..Default::default()
1207 };
1208 let mut entry = create_test_entry();
1209 let mut rng = ChaCha8Rng::seed_from_u64(42);
1210
1211 assert!(entry.is_balanced());
1213
1214 let result = strategy.apply(
1215 &mut entry,
1216 &AnomalyType::Statistical(StatisticalAnomalyType::UnusuallyHighAmount),
1217 &mut rng,
1218 );
1219
1220 assert!(result.success);
1221 assert!(
1223 entry.is_balanced(),
1224 "Entry should remain balanced after amount modification with rebalancing"
1225 );
1226 }
1227
1228 #[test]
1229 fn test_amount_modification_unbalanced_fraud() {
1230 let strategy = AmountModificationStrategy {
1231 rebalance_entry: false, ..Default::default()
1233 };
1234 let mut entry = create_test_entry();
1235 let mut rng = ChaCha8Rng::seed_from_u64(42);
1236
1237 assert!(entry.is_balanced());
1239
1240 let result = strategy.apply(
1241 &mut entry,
1242 &AnomalyType::Fraud(FraudType::RoundDollarManipulation),
1243 &mut rng,
1244 );
1245
1246 assert!(result.success);
1247 assert!(
1249 !entry.is_balanced(),
1250 "Entry should be unbalanced when rebalance_entry is false"
1251 );
1252 }
1253
1254 #[test]
1255 fn test_benford_violation_rebalanced() {
1256 let strategy = BenfordViolationStrategy {
1257 rebalance_entry: true,
1258 ..Default::default()
1259 };
1260 let mut entry = create_test_entry();
1261 let mut rng = ChaCha8Rng::seed_from_u64(42);
1262
1263 assert!(entry.is_balanced());
1265
1266 let result = strategy.apply(
1267 &mut entry,
1268 &AnomalyType::Statistical(StatisticalAnomalyType::BenfordViolation),
1269 &mut rng,
1270 );
1271
1272 assert!(result.success);
1273 assert!(
1275 entry.is_balanced(),
1276 "Entry should remain balanced after Benford violation with rebalancing"
1277 );
1278 }
1279
1280 #[test]
1281 fn test_date_modification() {
1282 let strategy = DateModificationStrategy::default();
1283 let mut entry = create_test_entry();
1284 let original_date = entry.header.posting_date;
1285 let mut rng = ChaCha8Rng::seed_from_u64(42);
1286
1287 let result = strategy.apply(
1288 &mut entry,
1289 &AnomalyType::Error(ErrorType::BackdatedEntry),
1290 &mut rng,
1291 );
1292
1293 assert!(result.success);
1294 assert!(entry.header.posting_date < original_date);
1295 }
1296
1297 #[test]
1298 fn test_description_anomaly() {
1299 let strategy = DescriptionAnomalyStrategy::default();
1300 let mut entry = create_test_entry();
1301 let mut rng = ChaCha8Rng::seed_from_u64(42);
1302
1303 let result = strategy.apply(
1304 &mut entry,
1305 &AnomalyType::ProcessIssue(ProcessIssueType::VagueDescription),
1306 &mut rng,
1307 );
1308
1309 assert!(result.success);
1310 let desc = entry.description().unwrap_or("").to_string();
1311 assert!(strategy.vague_descriptions.contains(&desc));
1312 }
1313}