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!("{:.2}", multiplier))
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 after transaction",
295 days
296 ))
297 .with_metadata("delay_days", &days.to_string());
298 }
299 _ => (0, "Date unchanged".to_string()),
300 };
301
302 if days_offset != 0 {
303 entry.header.posting_date = original_date + chrono::Duration::days(days_offset);
304 }
305
306 InjectionResult::success(&description)
307 .with_metadata("original_date", &original_date.to_string())
308 .with_metadata("new_date", &entry.header.posting_date.to_string())
309 }
310}
311
312pub struct DuplicationStrategy {
314 pub vary_amounts: bool,
316 pub amount_variance: f64,
318 pub change_doc_number: bool,
320}
321
322impl Default for DuplicationStrategy {
323 fn default() -> Self {
324 Self {
325 vary_amounts: false,
326 amount_variance: 0.01,
327 change_doc_number: true,
328 }
329 }
330}
331
332impl DuplicationStrategy {
333 pub fn duplicate<R: Rng>(
335 &self,
336 entry: &JournalEntry,
337 rng: &mut R,
338 uuid_factory: &DeterministicUuidFactory,
339 ) -> JournalEntry {
340 let mut duplicate = entry.clone();
341
342 if self.change_doc_number {
343 duplicate.header.document_id = uuid_factory.next();
345 for line in &mut duplicate.lines {
347 line.document_id = duplicate.header.document_id;
348 }
349 }
350
351 if self.vary_amounts {
352 for line in &mut duplicate.lines {
353 let variance = 1.0 + rng.random_range(-self.amount_variance..self.amount_variance);
354 let variance_dec = Decimal::from_f64_retain(variance).unwrap_or(Decimal::ONE);
355
356 if line.debit_amount > Decimal::ZERO {
357 line.debit_amount = (line.debit_amount * variance_dec).round_dp(2);
358 }
359 if line.credit_amount > Decimal::ZERO {
360 line.credit_amount = (line.credit_amount * variance_dec).round_dp(2);
361 }
362 }
363 }
364
365 duplicate
366 }
367}
368
369pub struct ApprovalAnomalyStrategy {
371 pub approval_threshold: Decimal,
373 pub threshold_buffer: Decimal,
375}
376
377impl Default for ApprovalAnomalyStrategy {
378 fn default() -> Self {
379 Self {
380 approval_threshold: Decimal::new(10000, 0),
381 threshold_buffer: Decimal::new(100, 0),
382 }
383 }
384}
385
386impl InjectionStrategy for ApprovalAnomalyStrategy {
387 fn name(&self) -> &'static str {
388 "ApprovalAnomaly"
389 }
390
391 fn can_apply(&self, entry: &JournalEntry) -> bool {
392 entry.total_debit() > Decimal::ZERO
393 }
394
395 fn apply<R: Rng>(
396 &self,
397 entry: &mut JournalEntry,
398 anomaly_type: &AnomalyType,
399 rng: &mut R,
400 ) -> InjectionResult {
401 match anomaly_type {
402 AnomalyType::Fraud(FraudType::JustBelowThreshold) => {
403 let target = self.approval_threshold
405 - self.threshold_buffer
406 - Decimal::new(rng.random_range(1..50), 0);
407
408 let current_total = entry.total_debit();
409 if current_total == Decimal::ZERO {
410 return InjectionResult::failure("Cannot scale zero amount");
411 }
412
413 let scale = target / current_total;
414 for line in &mut entry.lines {
415 line.debit_amount = (line.debit_amount * scale).round_dp(2);
416 line.credit_amount = (line.credit_amount * scale).round_dp(2);
417 }
418
419 InjectionResult::success(&format!(
420 "Adjusted total to {} (just below threshold {})",
421 entry.total_debit(),
422 self.approval_threshold
423 ))
424 .with_metadata("threshold", &self.approval_threshold.to_string())
425 }
426 AnomalyType::Fraud(FraudType::ExceededApprovalLimit) => {
427 let target = self.approval_threshold * Decimal::new(15, 1); let current_total = entry.total_debit();
431 if current_total == Decimal::ZERO {
432 return InjectionResult::failure("Cannot scale zero amount");
433 }
434
435 let scale = target / current_total;
436 for line in &mut entry.lines {
437 line.debit_amount = (line.debit_amount * scale).round_dp(2);
438 line.credit_amount = (line.credit_amount * scale).round_dp(2);
439 }
440
441 InjectionResult::success(&format!(
442 "Exceeded approval limit: {} vs limit {}",
443 entry.total_debit(),
444 self.approval_threshold
445 ))
446 .with_impact(entry.total_debit() - self.approval_threshold)
447 }
448 _ => InjectionResult::failure("Unsupported anomaly type for this strategy"),
449 }
450 }
451}
452
453pub struct DescriptionAnomalyStrategy {
455 pub vague_descriptions: Vec<String>,
457}
458
459impl Default for DescriptionAnomalyStrategy {
460 fn default() -> Self {
461 Self {
462 vague_descriptions: vec![
463 "Misc".to_string(),
464 "Adjustment".to_string(),
465 "Correction".to_string(),
466 "Various".to_string(),
467 "Other".to_string(),
468 "TBD".to_string(),
469 "See attachment".to_string(),
470 "As discussed".to_string(),
471 "Per management".to_string(),
472 ".".to_string(),
473 "xxx".to_string(),
474 "test".to_string(),
475 ],
476 }
477 }
478}
479
480impl InjectionStrategy for DescriptionAnomalyStrategy {
481 fn name(&self) -> &'static str {
482 "DescriptionAnomaly"
483 }
484
485 fn can_apply(&self, _entry: &JournalEntry) -> bool {
486 true
487 }
488
489 fn apply<R: Rng>(
490 &self,
491 entry: &mut JournalEntry,
492 _anomaly_type: &AnomalyType,
493 rng: &mut R,
494 ) -> InjectionResult {
495 let original = entry.description().unwrap_or("").to_string();
496 let vague = &self.vague_descriptions[rng.random_range(0..self.vague_descriptions.len())];
497 entry.set_description(vague.clone());
498
499 InjectionResult::success(&format!(
500 "Changed description from '{}' to '{}'",
501 original, vague
502 ))
503 .with_metadata("original_description", &original)
504 }
505}
506
507pub struct BenfordViolationStrategy {
509 pub target_digits: Vec<u32>,
511 pub rebalance_entry: bool,
513}
514
515impl Default for BenfordViolationStrategy {
516 fn default() -> Self {
517 Self {
518 target_digits: vec![5, 6, 7, 8, 9], rebalance_entry: true, }
521 }
522}
523
524impl InjectionStrategy for BenfordViolationStrategy {
525 fn name(&self) -> &'static str {
526 "BenfordViolation"
527 }
528
529 fn can_apply(&self, entry: &JournalEntry) -> bool {
530 !entry.lines.is_empty()
531 }
532
533 fn apply<R: Rng>(
534 &self,
535 entry: &mut JournalEntry,
536 _anomaly_type: &AnomalyType,
537 rng: &mut R,
538 ) -> InjectionResult {
539 if entry.lines.is_empty() {
540 return InjectionResult::failure("No lines to modify");
541 }
542
543 let line_idx = rng.random_range(0..entry.lines.len());
544 let is_debit = entry.lines[line_idx].debit_amount > Decimal::ZERO;
545 let original_amount = if is_debit {
546 entry.lines[line_idx].debit_amount
547 } else {
548 entry.lines[line_idx].credit_amount
549 };
550
551 let target_digit = self.target_digits[rng.random_range(0..self.target_digits.len())];
553
554 let original_str = original_amount.to_string();
556 let magnitude = original_str.replace('.', "").trim_start_matches('0').len() as i32 - 1;
557 let safe_magnitude = magnitude.clamp(0, 18) as u32;
559
560 let base = Decimal::new(10_i64.pow(safe_magnitude), 0);
561 let new_amount = base * Decimal::new(target_digit as i64, 0)
562 + Decimal::new(rng.random_range(0..10_i64.pow(safe_magnitude)), 0);
563
564 let impact = new_amount - original_amount;
565
566 if is_debit {
568 entry.lines[line_idx].debit_amount = new_amount;
569 } else {
570 entry.lines[line_idx].credit_amount = new_amount;
571 }
572
573 if self.rebalance_entry {
575 let balancing_idx = entry.lines.iter().position(|l| {
577 if is_debit {
578 l.credit_amount > Decimal::ZERO
579 } else {
580 l.debit_amount > Decimal::ZERO
581 }
582 });
583
584 if let Some(bal_idx) = balancing_idx {
585 if is_debit {
587 entry.lines[bal_idx].credit_amount += impact;
588 } else {
589 entry.lines[bal_idx].debit_amount += impact;
590 }
591 }
592 }
593
594 let first_digit = target_digit;
595 let benford_prob = (1.0 + 1.0 / first_digit as f64).log10();
596
597 InjectionResult::success(&format!(
598 "Created Benford violation: first digit {} (expected probability {:.1}%){}",
599 first_digit,
600 benford_prob * 100.0,
601 if self.rebalance_entry {
602 " [rebalanced]"
603 } else {
604 " [UNBALANCED]"
605 }
606 ))
607 .with_impact(impact)
608 .with_metadata("first_digit", &first_digit.to_string())
609 .with_metadata("benford_probability", &format!("{:.4}", benford_prob))
610 }
611}
612
613pub struct SplitTransactionStrategy {
615 pub split_threshold: Decimal,
617 pub min_splits: usize,
619 pub max_splits: usize,
620 pub threshold_buffer: Decimal,
622}
623
624impl Default for SplitTransactionStrategy {
625 fn default() -> Self {
626 Self {
627 split_threshold: Decimal::new(10000, 0),
628 min_splits: 2,
629 max_splits: 5,
630 threshold_buffer: Decimal::new(500, 0),
631 }
632 }
633}
634
635impl InjectionStrategy for SplitTransactionStrategy {
636 fn name(&self) -> &'static str {
637 "SplitTransaction"
638 }
639
640 fn can_apply(&self, entry: &JournalEntry) -> bool {
641 entry.total_debit() > self.split_threshold
643 }
644
645 fn apply<R: Rng>(
646 &self,
647 entry: &mut JournalEntry,
648 _anomaly_type: &AnomalyType,
649 rng: &mut R,
650 ) -> InjectionResult {
651 let total = entry.total_debit();
652 if total <= self.split_threshold {
653 return InjectionResult::failure("Amount below split threshold");
654 }
655
656 let num_splits = rng.random_range(self.min_splits..=self.max_splits);
657 let target_per_split = self.split_threshold
658 - self.threshold_buffer
659 - Decimal::new(rng.random_range(1..100), 0);
660
661 let scale = target_per_split / total;
663 for line in &mut entry.lines {
664 line.debit_amount = (line.debit_amount * scale).round_dp(2);
665 line.credit_amount = (line.credit_amount * scale).round_dp(2);
666 }
667
668 InjectionResult::success(&format!(
669 "Split ${} transaction into {} parts of ~${} each (below ${} threshold)",
670 total, num_splits, target_per_split, self.split_threshold
671 ))
672 .with_impact(total)
673 .with_metadata("original_amount", &total.to_string())
674 .with_metadata("num_splits", &num_splits.to_string())
675 .with_metadata("threshold", &self.split_threshold.to_string())
676 }
677}
678
679pub struct SkippedApprovalStrategy {
681 pub approval_threshold: Decimal,
683}
684
685impl Default for SkippedApprovalStrategy {
686 fn default() -> Self {
687 Self {
688 approval_threshold: Decimal::new(5000, 0),
689 }
690 }
691}
692
693impl InjectionStrategy for SkippedApprovalStrategy {
694 fn name(&self) -> &'static str {
695 "SkippedApproval"
696 }
697
698 fn can_apply(&self, entry: &JournalEntry) -> bool {
699 entry.total_debit() > self.approval_threshold
701 }
702
703 fn apply<R: Rng>(
704 &self,
705 entry: &mut JournalEntry,
706 _anomaly_type: &AnomalyType,
707 _rng: &mut R,
708 ) -> InjectionResult {
709 let amount = entry.total_debit();
710 if amount <= self.approval_threshold {
711 return InjectionResult::failure("Amount below approval threshold");
712 }
713
714 entry.header.control_status = ControlStatus::Exception;
716 entry.header.sod_violation = true;
717
718 InjectionResult::success(&format!(
719 "Skipped required approval for ${} entry (threshold: ${})",
720 amount, self.approval_threshold
721 ))
722 .with_impact(amount)
723 .with_metadata("threshold", &self.approval_threshold.to_string())
724 }
725}
726
727pub struct WeekendPostingStrategy;
729
730impl Default for WeekendPostingStrategy {
731 fn default() -> Self {
732 Self
733 }
734}
735
736impl InjectionStrategy for WeekendPostingStrategy {
737 fn name(&self) -> &'static str {
738 "WeekendPosting"
739 }
740
741 fn can_apply(&self, _entry: &JournalEntry) -> bool {
742 true
743 }
744
745 fn apply<R: Rng>(
746 &self,
747 entry: &mut JournalEntry,
748 _anomaly_type: &AnomalyType,
749 rng: &mut R,
750 ) -> InjectionResult {
751 use chrono::Weekday;
752
753 let original_date = entry.header.posting_date;
754 let weekday = original_date.weekday();
755
756 let days_to_weekend = match weekday {
758 Weekday::Mon => 5,
759 Weekday::Tue => 4,
760 Weekday::Wed => 3,
761 Weekday::Thu => 2,
762 Weekday::Fri => 1,
763 Weekday::Sat => 0,
764 Weekday::Sun => 0,
765 };
766
767 let weekend_day = if rng.random_bool(0.6) {
769 days_to_weekend
770 } else {
771 days_to_weekend + 1
772 };
773 let new_date = original_date + chrono::Duration::days(weekend_day as i64);
774
775 entry.header.posting_date = new_date;
776
777 InjectionResult::success(&format!(
778 "Moved posting from {} ({:?}) to {} ({:?})",
779 original_date,
780 weekday,
781 new_date,
782 new_date.weekday()
783 ))
784 .with_metadata("original_date", &original_date.to_string())
785 .with_metadata("new_date", &new_date.to_string())
786 }
787}
788
789pub struct ReversedAmountStrategy;
791
792impl Default for ReversedAmountStrategy {
793 fn default() -> Self {
794 Self
795 }
796}
797
798impl InjectionStrategy for ReversedAmountStrategy {
799 fn name(&self) -> &'static str {
800 "ReversedAmount"
801 }
802
803 fn can_apply(&self, entry: &JournalEntry) -> bool {
804 entry.lines.len() >= 2
805 }
806
807 fn apply<R: Rng>(
808 &self,
809 entry: &mut JournalEntry,
810 _anomaly_type: &AnomalyType,
811 rng: &mut R,
812 ) -> InjectionResult {
813 if entry.lines.len() < 2 {
814 return InjectionResult::failure("Need at least 2 lines to reverse");
815 }
816
817 let line_idx = rng.random_range(0..entry.lines.len());
819 let line = &mut entry.lines[line_idx];
820
821 let original_debit = line.debit_amount;
822 let original_credit = line.credit_amount;
823
824 line.debit_amount = original_credit;
825 line.credit_amount = original_debit;
826
827 let impact = original_debit.max(original_credit);
828
829 InjectionResult::success(&format!(
830 "Reversed amounts on line {}: DR {} → CR {}, CR {} → DR {}",
831 line_idx + 1,
832 original_debit,
833 line.credit_amount,
834 original_credit,
835 line.debit_amount
836 ))
837 .with_impact(impact * Decimal::new(2, 0)) .with_metadata("line_number", &(line_idx + 1).to_string())
839 }
840}
841
842pub struct TransposedDigitsStrategy;
844
845impl Default for TransposedDigitsStrategy {
846 fn default() -> Self {
847 Self
848 }
849}
850
851impl InjectionStrategy for TransposedDigitsStrategy {
852 fn name(&self) -> &'static str {
853 "TransposedDigits"
854 }
855
856 fn can_apply(&self, entry: &JournalEntry) -> bool {
857 entry.lines.iter().any(|l| {
859 let amount = if l.debit_amount > Decimal::ZERO {
860 l.debit_amount
861 } else {
862 l.credit_amount
863 };
864 amount >= Decimal::new(10, 0)
865 })
866 }
867
868 fn apply<R: Rng>(
869 &self,
870 entry: &mut JournalEntry,
871 _anomaly_type: &AnomalyType,
872 rng: &mut R,
873 ) -> InjectionResult {
874 let valid_lines: Vec<usize> = entry
876 .lines
877 .iter()
878 .enumerate()
879 .filter(|(_, l)| {
880 let amount = if l.debit_amount > Decimal::ZERO {
881 l.debit_amount
882 } else {
883 l.credit_amount
884 };
885 amount >= Decimal::new(10, 0)
886 })
887 .map(|(i, _)| i)
888 .collect();
889
890 if valid_lines.is_empty() {
891 return InjectionResult::failure("No lines with transposable amounts");
892 }
893
894 let line_idx = valid_lines[rng.random_range(0..valid_lines.len())];
895 let line = &mut entry.lines[line_idx];
896
897 let is_debit = line.debit_amount > Decimal::ZERO;
898 let original_amount = if is_debit {
899 line.debit_amount
900 } else {
901 line.credit_amount
902 };
903
904 let amount_str = original_amount.to_string().replace('.', "");
906 let chars: Vec<char> = amount_str.chars().collect();
907
908 if chars.len() < 2 {
909 return InjectionResult::failure("Amount too small to transpose");
910 }
911
912 let pos = rng.random_range(0..chars.len() - 1);
914 let mut new_chars = chars.clone();
915 new_chars.swap(pos, pos + 1);
916
917 let new_str: String = new_chars.into_iter().collect();
918 let new_amount = new_str.parse::<i64>().unwrap_or(0);
919 let scale = original_amount.scale();
920 let new_decimal = Decimal::new(new_amount, scale);
921
922 let impact = (new_decimal - original_amount).abs();
923
924 if is_debit {
925 line.debit_amount = new_decimal;
926 } else {
927 line.credit_amount = new_decimal;
928 }
929
930 InjectionResult::success(&format!(
931 "Transposed digits: {} → {} (positions {} and {})",
932 original_amount,
933 new_decimal,
934 pos + 1,
935 pos + 2
936 ))
937 .with_impact(impact)
938 .with_metadata("original_amount", &original_amount.to_string())
939 .with_metadata("new_amount", &new_decimal.to_string())
940 }
941}
942
943pub struct DormantAccountStrategy {
945 pub dormant_accounts: Vec<String>,
947}
948
949impl Default for DormantAccountStrategy {
950 fn default() -> Self {
951 Self {
952 dormant_accounts: vec![
953 "199999".to_string(), "299999".to_string(), "399999".to_string(), "999999".to_string(), ],
958 }
959 }
960}
961
962impl InjectionStrategy for DormantAccountStrategy {
963 fn name(&self) -> &'static str {
964 "DormantAccountActivity"
965 }
966
967 fn can_apply(&self, entry: &JournalEntry) -> bool {
968 !entry.lines.is_empty() && !self.dormant_accounts.is_empty()
969 }
970
971 fn apply<R: Rng>(
972 &self,
973 entry: &mut JournalEntry,
974 _anomaly_type: &AnomalyType,
975 rng: &mut R,
976 ) -> InjectionResult {
977 if entry.lines.is_empty() || self.dormant_accounts.is_empty() {
978 return InjectionResult::failure("No lines or dormant accounts");
979 }
980
981 let line_idx = rng.random_range(0..entry.lines.len());
982 let line = &mut entry.lines[line_idx];
983
984 let original_account = line.gl_account.clone();
985 let dormant_account =
986 &self.dormant_accounts[rng.random_range(0..self.dormant_accounts.len())];
987
988 line.gl_account = dormant_account.clone();
989 line.account_code = dormant_account.clone();
990
991 let amount = if line.debit_amount > Decimal::ZERO {
992 line.debit_amount
993 } else {
994 line.credit_amount
995 };
996
997 InjectionResult::success(&format!(
998 "Changed account from {} to dormant account {}",
999 original_account, dormant_account
1000 ))
1001 .with_impact(amount)
1002 .with_entity(dormant_account)
1003 .with_metadata("original_account", &original_account)
1004 }
1005}
1006
1007#[derive(Default)]
1009pub struct StrategyCollection {
1010 pub amount_modification: AmountModificationStrategy,
1011 pub date_modification: DateModificationStrategy,
1012 pub duplication: DuplicationStrategy,
1013 pub approval_anomaly: ApprovalAnomalyStrategy,
1014 pub description_anomaly: DescriptionAnomalyStrategy,
1015 pub benford_violation: BenfordViolationStrategy,
1016 pub split_transaction: SplitTransactionStrategy,
1017 pub skipped_approval: SkippedApprovalStrategy,
1018 pub weekend_posting: WeekendPostingStrategy,
1019 pub reversed_amount: ReversedAmountStrategy,
1020 pub transposed_digits: TransposedDigitsStrategy,
1021 pub dormant_account: DormantAccountStrategy,
1022}
1023
1024impl StrategyCollection {
1025 pub fn can_apply(&self, entry: &JournalEntry, anomaly_type: &AnomalyType) -> bool {
1027 match anomaly_type {
1028 AnomalyType::Fraud(FraudType::RoundDollarManipulation)
1030 | AnomalyType::Statistical(StatisticalAnomalyType::UnusuallyHighAmount)
1031 | AnomalyType::Statistical(StatisticalAnomalyType::UnusuallyLowAmount) => {
1032 self.amount_modification.can_apply(entry)
1033 }
1034 AnomalyType::Error(ErrorType::BackdatedEntry)
1036 | AnomalyType::Error(ErrorType::FutureDatedEntry)
1037 | AnomalyType::Error(ErrorType::WrongPeriod)
1038 | AnomalyType::ProcessIssue(ProcessIssueType::LatePosting) => {
1039 self.date_modification.can_apply(entry)
1040 }
1041 AnomalyType::Fraud(FraudType::JustBelowThreshold)
1043 | AnomalyType::Fraud(FraudType::ExceededApprovalLimit) => {
1044 self.approval_anomaly.can_apply(entry)
1045 }
1046 AnomalyType::ProcessIssue(ProcessIssueType::VagueDescription) => {
1048 self.description_anomaly.can_apply(entry)
1049 }
1050 AnomalyType::Statistical(StatisticalAnomalyType::BenfordViolation) => {
1052 self.benford_violation.can_apply(entry)
1053 }
1054 AnomalyType::Fraud(FraudType::SplitTransaction) => {
1056 self.split_transaction.can_apply(entry)
1057 }
1058 AnomalyType::ProcessIssue(ProcessIssueType::SkippedApproval) => {
1060 self.skipped_approval.can_apply(entry)
1061 }
1062 AnomalyType::ProcessIssue(ProcessIssueType::WeekendPosting)
1064 | AnomalyType::ProcessIssue(ProcessIssueType::AfterHoursPosting) => {
1065 self.weekend_posting.can_apply(entry)
1066 }
1067 AnomalyType::Error(ErrorType::ReversedAmount) => self.reversed_amount.can_apply(entry),
1069 AnomalyType::Error(ErrorType::TransposedDigits) => {
1071 self.transposed_digits.can_apply(entry)
1072 }
1073 AnomalyType::Relational(RelationalAnomalyType::DormantAccountActivity) => {
1075 self.dormant_account.can_apply(entry)
1076 }
1077 _ => self.amount_modification.can_apply(entry),
1079 }
1080 }
1081
1082 pub fn apply_strategy<R: Rng>(
1084 &self,
1085 entry: &mut JournalEntry,
1086 anomaly_type: &AnomalyType,
1087 rng: &mut R,
1088 ) -> InjectionResult {
1089 match anomaly_type {
1090 AnomalyType::Fraud(FraudType::RoundDollarManipulation)
1092 | AnomalyType::Statistical(StatisticalAnomalyType::UnusuallyHighAmount)
1093 | AnomalyType::Statistical(StatisticalAnomalyType::UnusuallyLowAmount) => {
1094 self.amount_modification.apply(entry, anomaly_type, rng)
1095 }
1096 AnomalyType::Error(ErrorType::BackdatedEntry)
1098 | AnomalyType::Error(ErrorType::FutureDatedEntry)
1099 | AnomalyType::Error(ErrorType::WrongPeriod)
1100 | AnomalyType::ProcessIssue(ProcessIssueType::LatePosting) => {
1101 self.date_modification.apply(entry, anomaly_type, rng)
1102 }
1103 AnomalyType::Fraud(FraudType::JustBelowThreshold)
1105 | AnomalyType::Fraud(FraudType::ExceededApprovalLimit) => {
1106 self.approval_anomaly.apply(entry, anomaly_type, rng)
1107 }
1108 AnomalyType::ProcessIssue(ProcessIssueType::VagueDescription) => {
1110 self.description_anomaly.apply(entry, anomaly_type, rng)
1111 }
1112 AnomalyType::Statistical(StatisticalAnomalyType::BenfordViolation) => {
1114 self.benford_violation.apply(entry, anomaly_type, rng)
1115 }
1116 AnomalyType::Fraud(FraudType::SplitTransaction) => {
1118 self.split_transaction.apply(entry, anomaly_type, rng)
1119 }
1120 AnomalyType::ProcessIssue(ProcessIssueType::SkippedApproval) => {
1122 self.skipped_approval.apply(entry, anomaly_type, rng)
1123 }
1124 AnomalyType::ProcessIssue(ProcessIssueType::WeekendPosting)
1126 | AnomalyType::ProcessIssue(ProcessIssueType::AfterHoursPosting) => {
1127 self.weekend_posting.apply(entry, anomaly_type, rng)
1128 }
1129 AnomalyType::Error(ErrorType::ReversedAmount) => {
1131 self.reversed_amount.apply(entry, anomaly_type, rng)
1132 }
1133 AnomalyType::Error(ErrorType::TransposedDigits) => {
1135 self.transposed_digits.apply(entry, anomaly_type, rng)
1136 }
1137 AnomalyType::Relational(RelationalAnomalyType::DormantAccountActivity) => {
1139 self.dormant_account.apply(entry, anomaly_type, rng)
1140 }
1141 _ => self.amount_modification.apply(entry, anomaly_type, rng),
1143 }
1144 }
1145}
1146
1147#[cfg(test)]
1148#[allow(clippy::unwrap_used)]
1149mod tests {
1150 use super::*;
1151 use chrono::NaiveDate;
1152 use datasynth_core::models::JournalEntryLine;
1153 use rand::SeedableRng;
1154 use rand_chacha::ChaCha8Rng;
1155 use rust_decimal_macros::dec;
1156
1157 fn create_test_entry() -> JournalEntry {
1158 let mut entry = JournalEntry::new_simple(
1159 "JE001".to_string(),
1160 "1000".to_string(),
1161 NaiveDate::from_ymd_opt(2024, 6, 15).unwrap(),
1162 "Test Entry".to_string(),
1163 );
1164
1165 entry.add_line(JournalEntryLine {
1166 line_number: 1,
1167 gl_account: "5000".to_string(),
1168 debit_amount: dec!(1000),
1169 ..Default::default()
1170 });
1171
1172 entry.add_line(JournalEntryLine {
1173 line_number: 2,
1174 gl_account: "1000".to_string(),
1175 credit_amount: dec!(1000),
1176 ..Default::default()
1177 });
1178
1179 entry
1180 }
1181
1182 #[test]
1183 fn test_amount_modification() {
1184 let strategy = AmountModificationStrategy::default();
1185 let mut entry = create_test_entry();
1186 let mut rng = ChaCha8Rng::seed_from_u64(42);
1187
1188 let result = strategy.apply(
1189 &mut entry,
1190 &AnomalyType::Statistical(StatisticalAnomalyType::UnusuallyHighAmount),
1191 &mut rng,
1192 );
1193
1194 assert!(result.success);
1195 assert!(result.monetary_impact.is_some());
1196 }
1197
1198 #[test]
1199 fn test_amount_modification_rebalanced() {
1200 let strategy = AmountModificationStrategy {
1201 rebalance_entry: true,
1202 ..Default::default()
1203 };
1204 let mut entry = create_test_entry();
1205 let mut rng = ChaCha8Rng::seed_from_u64(42);
1206
1207 assert!(entry.is_balanced());
1209
1210 let result = strategy.apply(
1211 &mut entry,
1212 &AnomalyType::Statistical(StatisticalAnomalyType::UnusuallyHighAmount),
1213 &mut rng,
1214 );
1215
1216 assert!(result.success);
1217 assert!(
1219 entry.is_balanced(),
1220 "Entry should remain balanced after amount modification with rebalancing"
1221 );
1222 }
1223
1224 #[test]
1225 fn test_amount_modification_unbalanced_fraud() {
1226 let strategy = AmountModificationStrategy {
1227 rebalance_entry: false, ..Default::default()
1229 };
1230 let mut entry = create_test_entry();
1231 let mut rng = ChaCha8Rng::seed_from_u64(42);
1232
1233 assert!(entry.is_balanced());
1235
1236 let result = strategy.apply(
1237 &mut entry,
1238 &AnomalyType::Fraud(FraudType::RoundDollarManipulation),
1239 &mut rng,
1240 );
1241
1242 assert!(result.success);
1243 assert!(
1245 !entry.is_balanced(),
1246 "Entry should be unbalanced when rebalance_entry is false"
1247 );
1248 }
1249
1250 #[test]
1251 fn test_benford_violation_rebalanced() {
1252 let strategy = BenfordViolationStrategy {
1253 rebalance_entry: true,
1254 ..Default::default()
1255 };
1256 let mut entry = create_test_entry();
1257 let mut rng = ChaCha8Rng::seed_from_u64(42);
1258
1259 assert!(entry.is_balanced());
1261
1262 let result = strategy.apply(
1263 &mut entry,
1264 &AnomalyType::Statistical(StatisticalAnomalyType::BenfordViolation),
1265 &mut rng,
1266 );
1267
1268 assert!(result.success);
1269 assert!(
1271 entry.is_balanced(),
1272 "Entry should remain balanced after Benford violation with rebalancing"
1273 );
1274 }
1275
1276 #[test]
1277 fn test_date_modification() {
1278 let strategy = DateModificationStrategy::default();
1279 let mut entry = create_test_entry();
1280 let original_date = entry.header.posting_date;
1281 let mut rng = ChaCha8Rng::seed_from_u64(42);
1282
1283 let result = strategy.apply(
1284 &mut entry,
1285 &AnomalyType::Error(ErrorType::BackdatedEntry),
1286 &mut rng,
1287 );
1288
1289 assert!(result.success);
1290 assert!(entry.header.posting_date < original_date);
1291 }
1292
1293 #[test]
1294 fn test_description_anomaly() {
1295 let strategy = DescriptionAnomalyStrategy::default();
1296 let mut entry = create_test_entry();
1297 let mut rng = ChaCha8Rng::seed_from_u64(42);
1298
1299 let result = strategy.apply(
1300 &mut entry,
1301 &AnomalyType::ProcessIssue(ProcessIssueType::VagueDescription),
1302 &mut rng,
1303 );
1304
1305 assert!(result.success);
1306 let desc = entry.description().unwrap_or("").to_string();
1307 assert!(strategy.vague_descriptions.contains(&desc));
1308 }
1309}