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