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