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