1use chrono::Datelike;
6use rand::{Rng, RngExt};
7use rust_decimal::Decimal;
8
9use datasynth_core::accounts::dormant_accounts;
10use datasynth_core::models::{
11 AnomalyType, ControlStatus, ErrorType, FraudType, JournalEntry, ProcessIssueType,
12 RelationalAnomalyType, StatisticalAnomalyType,
13};
14use datasynth_core::uuid_factory::DeterministicUuidFactory;
15
16pub trait InjectionStrategy {
18 fn name(&self) -> &'static str;
20
21 fn can_apply(&self, entry: &JournalEntry) -> bool;
23
24 fn apply<R: Rng>(
26 &self,
27 entry: &mut JournalEntry,
28 anomaly_type: &AnomalyType,
29 rng: &mut R,
30 ) -> InjectionResult;
31}
32
33#[derive(Debug, Clone)]
35pub struct InjectionResult {
36 pub success: bool,
38 pub description: String,
40 pub monetary_impact: Option<Decimal>,
42 pub related_entities: Vec<String>,
44 pub metadata: Vec<(String, String)>,
46}
47
48impl InjectionResult {
49 pub fn success(description: &str) -> Self {
51 Self {
52 success: true,
53 description: description.to_string(),
54 monetary_impact: None,
55 related_entities: Vec::new(),
56 metadata: Vec::new(),
57 }
58 }
59
60 pub fn failure(reason: &str) -> Self {
62 Self {
63 success: false,
64 description: reason.to_string(),
65 monetary_impact: None,
66 related_entities: Vec::new(),
67 metadata: Vec::new(),
68 }
69 }
70
71 pub fn with_impact(mut self, impact: Decimal) -> Self {
73 self.monetary_impact = Some(impact);
74 self
75 }
76
77 pub fn with_entity(mut self, entity: &str) -> Self {
79 self.related_entities.push(entity.to_string());
80 self
81 }
82
83 pub fn with_metadata(mut self, key: &str, value: &str) -> Self {
85 self.metadata.push((key.to_string(), value.to_string()));
86 self
87 }
88}
89
90pub struct AmountModificationStrategy {
92 pub min_multiplier: f64,
94 pub max_multiplier: f64,
96 pub prefer_round_numbers: bool,
98 pub rebalance_entry: bool,
102}
103
104impl Default for AmountModificationStrategy {
105 fn default() -> Self {
106 Self {
107 min_multiplier: 2.0,
108 max_multiplier: 10.0,
109 prefer_round_numbers: false,
110 rebalance_entry: true, }
112 }
113}
114
115impl InjectionStrategy for AmountModificationStrategy {
116 fn name(&self) -> &'static str {
117 "AmountModification"
118 }
119
120 fn can_apply(&self, entry: &JournalEntry) -> bool {
121 !entry.lines.is_empty() && entry.header.ic_pair_id.is_none()
127 }
128
129 fn apply<R: Rng>(
130 &self,
131 entry: &mut JournalEntry,
132 anomaly_type: &AnomalyType,
133 rng: &mut R,
134 ) -> InjectionResult {
135 if entry.lines.is_empty() {
136 return InjectionResult::failure("No lines to modify");
137 }
138
139 let line_idx = rng.random_range(0..entry.lines.len());
140 let is_debit = entry.lines[line_idx].debit_amount > Decimal::ZERO;
141 let original_amount = if is_debit {
142 entry.lines[line_idx].debit_amount
143 } else {
144 entry.lines[line_idx].credit_amount
145 };
146
147 let multiplier = rng.random_range(self.min_multiplier..self.max_multiplier);
148 let mut new_amount =
149 original_amount * Decimal::from_f64_retain(multiplier).unwrap_or(Decimal::ONE);
150
151 if self.prefer_round_numbers {
153 let abs_amount = new_amount.abs();
154 let magnitude = if abs_amount >= Decimal::ONE {
155 let digits = abs_amount
156 .to_string()
157 .split('.')
158 .next()
159 .map(|s| s.trim_start_matches('-').len())
160 .unwrap_or(1);
161 (digits as i32 - 1).max(0)
162 } else {
163 0
164 };
165 let round_factor = Decimal::new(10_i64.pow(magnitude as u32), 0);
166 new_amount = (new_amount / round_factor).round() * round_factor;
167 }
168
169 let impact = new_amount - original_amount;
170 let account_code = entry.lines[line_idx].account_code.clone();
171
172 if is_debit {
174 entry.lines[line_idx].debit_amount = new_amount;
175 } else {
176 entry.lines[line_idx].credit_amount = new_amount;
177 }
178
179 if self.rebalance_entry {
181 let balancing_idx = entry.lines.iter().position(|l| {
183 if is_debit {
184 l.credit_amount > Decimal::ZERO
185 } else {
186 l.debit_amount > Decimal::ZERO
187 }
188 });
189
190 if let Some(bal_idx) = balancing_idx {
191 if is_debit {
193 entry.lines[bal_idx].credit_amount += impact;
194 } else {
195 entry.lines[bal_idx].debit_amount += impact;
196 }
197 }
198 }
199
200 match anomaly_type {
201 AnomalyType::Fraud(FraudType::RoundDollarManipulation) => {
202 InjectionResult::success(&format!(
203 "Modified amount from {} to {} (round dollar){}",
204 original_amount,
205 new_amount,
206 if self.rebalance_entry {
207 " [rebalanced]"
208 } else {
209 " [UNBALANCED]"
210 }
211 ))
212 .with_impact(impact)
213 .with_entity(&account_code)
214 }
215 AnomalyType::Statistical(StatisticalAnomalyType::UnusuallyHighAmount) => {
216 InjectionResult::success(&format!(
217 "Inflated amount by {:.1}x to {}{}",
218 multiplier,
219 new_amount,
220 if self.rebalance_entry {
221 " [rebalanced]"
222 } else {
223 " [UNBALANCED]"
224 }
225 ))
226 .with_impact(impact)
227 .with_metadata("multiplier", &format!("{multiplier:.2}"))
228 }
229 _ => InjectionResult::success(&format!(
230 "Modified amount to {}{}",
231 new_amount,
232 if self.rebalance_entry {
233 " [rebalanced]"
234 } else {
235 " [UNBALANCED]"
236 }
237 ))
238 .with_impact(impact),
239 }
240 }
241}
242
243pub struct DateModificationStrategy {
245 pub max_backdate_days: i64,
247 pub max_future_days: i64,
249 pub cross_period_boundary: bool,
251}
252
253impl Default for DateModificationStrategy {
254 fn default() -> Self {
255 Self {
256 max_backdate_days: 30,
257 max_future_days: 7,
258 cross_period_boundary: true,
259 }
260 }
261}
262
263impl InjectionStrategy for DateModificationStrategy {
264 fn name(&self) -> &'static str {
265 "DateModification"
266 }
267
268 fn can_apply(&self, _entry: &JournalEntry) -> bool {
269 true
270 }
271
272 fn apply<R: Rng>(
273 &self,
274 entry: &mut JournalEntry,
275 anomaly_type: &AnomalyType,
276 rng: &mut R,
277 ) -> InjectionResult {
278 let original_date = entry.header.posting_date;
279
280 let (days_offset, description) = match anomaly_type {
281 AnomalyType::Error(ErrorType::BackdatedEntry) => {
282 let days = rng.random_range(1..=self.max_backdate_days);
283 (-days, format!("Backdated by {days} days"))
284 }
285 AnomalyType::Error(ErrorType::FutureDatedEntry) => {
286 let days = rng.random_range(1..=self.max_future_days);
287 (days, format!("Future-dated by {days} days"))
288 }
289 AnomalyType::Error(ErrorType::WrongPeriod) => {
290 let direction: i64 = if rng.random_bool(0.5) { -1 } else { 1 };
292 let days = direction * 32; (days, "Posted to wrong period".to_string())
294 }
295 AnomalyType::ProcessIssue(ProcessIssueType::LatePosting) => {
296 let days = rng.random_range(5..=15);
297 entry.header.document_date = entry.header.posting_date; entry.header.posting_date = original_date + chrono::Duration::days(days);
299 return InjectionResult::success(&format!(
300 "Late posting: {days} days after transaction"
301 ))
302 .with_metadata("delay_days", &days.to_string());
303 }
304 _ => (0, "Date unchanged".to_string()),
305 };
306
307 if days_offset != 0 {
308 entry.header.posting_date = original_date + chrono::Duration::days(days_offset);
309 }
310
311 InjectionResult::success(&description)
312 .with_metadata("original_date", &original_date.to_string())
313 .with_metadata("new_date", &entry.header.posting_date.to_string())
314 }
315}
316
317pub struct DuplicationStrategy {
319 pub vary_amounts: bool,
321 pub amount_variance: f64,
323 pub change_doc_number: bool,
325}
326
327impl Default for DuplicationStrategy {
328 fn default() -> Self {
329 Self {
330 vary_amounts: false,
331 amount_variance: 0.01,
332 change_doc_number: true,
333 }
334 }
335}
336
337impl DuplicationStrategy {
338 pub fn duplicate<R: Rng>(
340 &self,
341 entry: &JournalEntry,
342 rng: &mut R,
343 uuid_factory: &DeterministicUuidFactory,
344 ) -> JournalEntry {
345 let mut duplicate = entry.clone();
346
347 if self.change_doc_number {
348 duplicate.header.document_id = uuid_factory.next();
350 for line in &mut duplicate.lines {
352 line.document_id = duplicate.header.document_id;
353 }
354 }
355
356 if self.vary_amounts {
357 for line in &mut duplicate.lines {
358 let variance = 1.0 + rng.random_range(-self.amount_variance..self.amount_variance);
359 let variance_dec = Decimal::from_f64_retain(variance).unwrap_or(Decimal::ONE);
360
361 if line.debit_amount > Decimal::ZERO {
362 line.debit_amount = (line.debit_amount * variance_dec).round_dp(2);
363 }
364 if line.credit_amount > Decimal::ZERO {
365 line.credit_amount = (line.credit_amount * variance_dec).round_dp(2);
366 }
367 }
368 }
369
370 duplicate
371 }
372}
373
374pub struct ApprovalAnomalyStrategy {
376 pub approval_threshold: Decimal,
378 pub threshold_buffer: Decimal,
380}
381
382impl Default for ApprovalAnomalyStrategy {
383 fn default() -> Self {
384 Self {
385 approval_threshold: Decimal::new(10000, 0),
386 threshold_buffer: Decimal::new(100, 0),
387 }
388 }
389}
390
391impl InjectionStrategy for ApprovalAnomalyStrategy {
392 fn name(&self) -> &'static str {
393 "ApprovalAnomaly"
394 }
395
396 fn can_apply(&self, entry: &JournalEntry) -> bool {
397 entry.total_debit() > Decimal::ZERO
398 }
399
400 fn apply<R: Rng>(
401 &self,
402 entry: &mut JournalEntry,
403 anomaly_type: &AnomalyType,
404 rng: &mut R,
405 ) -> InjectionResult {
406 match anomaly_type {
407 AnomalyType::Fraud(FraudType::JustBelowThreshold) => {
408 let target = self.approval_threshold
410 - self.threshold_buffer
411 - Decimal::new(rng.random_range(1..50), 0);
412
413 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 "Adjusted total to {} (just below threshold {})",
426 entry.total_debit(),
427 self.approval_threshold
428 ))
429 .with_metadata("threshold", &self.approval_threshold.to_string())
430 }
431 AnomalyType::Fraud(FraudType::ExceededApprovalLimit) => {
432 let target = self.approval_threshold * Decimal::new(15, 1); let current_total = entry.total_debit();
436 if current_total == Decimal::ZERO {
437 return InjectionResult::failure("Cannot scale zero amount");
438 }
439
440 let scale = target / current_total;
441 for line in &mut entry.lines {
442 line.debit_amount = (line.debit_amount * scale).round_dp(2);
443 line.credit_amount = (line.credit_amount * scale).round_dp(2);
444 }
445
446 InjectionResult::success(&format!(
447 "Exceeded approval limit: {} vs limit {}",
448 entry.total_debit(),
449 self.approval_threshold
450 ))
451 .with_impact(entry.total_debit() - self.approval_threshold)
452 }
453 _ => InjectionResult::failure("Unsupported anomaly type for this strategy"),
454 }
455 }
456}
457
458pub struct DescriptionAnomalyStrategy {
460 pub vague_descriptions: Vec<String>,
462}
463
464impl Default for DescriptionAnomalyStrategy {
465 fn default() -> Self {
466 Self {
467 vague_descriptions: vec![
468 "Misc".to_string(),
469 "Adjustment".to_string(),
470 "Correction".to_string(),
471 "Various".to_string(),
472 "Other".to_string(),
473 "TBD".to_string(),
474 "See attachment".to_string(),
475 "As discussed".to_string(),
476 "Per management".to_string(),
477 ".".to_string(),
478 "xxx".to_string(),
479 "test".to_string(),
480 ],
481 }
482 }
483}
484
485impl InjectionStrategy for DescriptionAnomalyStrategy {
486 fn name(&self) -> &'static str {
487 "DescriptionAnomaly"
488 }
489
490 fn can_apply(&self, _entry: &JournalEntry) -> bool {
491 true
492 }
493
494 fn apply<R: Rng>(
495 &self,
496 entry: &mut JournalEntry,
497 _anomaly_type: &AnomalyType,
498 rng: &mut R,
499 ) -> InjectionResult {
500 let original = entry.description().unwrap_or("").to_string();
501 let vague = &self.vague_descriptions[rng.random_range(0..self.vague_descriptions.len())];
502 entry.set_description(vague.clone());
503
504 InjectionResult::success(&format!(
505 "Changed description from '{original}' to '{vague}'"
506 ))
507 .with_metadata("original_description", &original)
508 }
509}
510
511pub struct BenfordViolationStrategy {
513 pub target_digits: Vec<u32>,
515 pub rebalance_entry: bool,
517}
518
519impl Default for BenfordViolationStrategy {
520 fn default() -> Self {
521 Self {
522 target_digits: vec![5, 6, 7, 8, 9], rebalance_entry: true, }
525 }
526}
527
528impl InjectionStrategy for BenfordViolationStrategy {
529 fn name(&self) -> &'static str {
530 "BenfordViolation"
531 }
532
533 fn can_apply(&self, entry: &JournalEntry) -> bool {
534 !entry.lines.is_empty()
535 }
536
537 fn apply<R: Rng>(
538 &self,
539 entry: &mut JournalEntry,
540 _anomaly_type: &AnomalyType,
541 rng: &mut R,
542 ) -> InjectionResult {
543 if entry.lines.is_empty() {
544 return InjectionResult::failure("No lines to modify");
545 }
546
547 let line_idx = rng.random_range(0..entry.lines.len());
548 let is_debit = entry.lines[line_idx].debit_amount > Decimal::ZERO;
549 let original_amount = if is_debit {
550 entry.lines[line_idx].debit_amount
551 } else {
552 entry.lines[line_idx].credit_amount
553 };
554
555 let target_digit = self.target_digits[rng.random_range(0..self.target_digits.len())];
557
558 let abs_amount: f64 = original_amount.abs().try_into().unwrap_or(0.0);
571 let magnitude = if abs_amount >= 1.0 {
572 abs_amount.log10().floor() as i32
573 } else {
574 0
575 };
576 let safe_magnitude = magnitude.clamp(0, 12) as u32;
580
581 let base = Decimal::new(10_i64.pow(safe_magnitude), 0);
582 let new_amount = base * Decimal::new(target_digit as i64, 0)
583 + Decimal::new(rng.random_range(0..10_i64.pow(safe_magnitude)), 0);
584
585 let impact = new_amount - original_amount;
586
587 if is_debit {
589 entry.lines[line_idx].debit_amount = new_amount;
590 } else {
591 entry.lines[line_idx].credit_amount = new_amount;
592 }
593
594 if self.rebalance_entry {
596 let balancing_idx = entry.lines.iter().position(|l| {
598 if is_debit {
599 l.credit_amount > Decimal::ZERO
600 } else {
601 l.debit_amount > Decimal::ZERO
602 }
603 });
604
605 if let Some(bal_idx) = balancing_idx {
606 if is_debit {
608 entry.lines[bal_idx].credit_amount += impact;
609 } else {
610 entry.lines[bal_idx].debit_amount += impact;
611 }
612 }
613 }
614
615 let first_digit = target_digit;
616 let benford_prob = (1.0 + 1.0 / first_digit as f64).log10();
617
618 InjectionResult::success(&format!(
619 "Created Benford violation: first digit {} (expected probability {:.1}%){}",
620 first_digit,
621 benford_prob * 100.0,
622 if self.rebalance_entry {
623 " [rebalanced]"
624 } else {
625 " [UNBALANCED]"
626 }
627 ))
628 .with_impact(impact)
629 .with_metadata("first_digit", &first_digit.to_string())
630 .with_metadata("benford_probability", &format!("{benford_prob:.4}"))
631 }
632}
633
634pub struct SplitTransactionStrategy {
636 pub split_threshold: Decimal,
638 pub min_splits: usize,
640 pub max_splits: usize,
641 pub threshold_buffer: Decimal,
643}
644
645impl Default for SplitTransactionStrategy {
646 fn default() -> Self {
647 Self {
648 split_threshold: Decimal::new(10000, 0),
649 min_splits: 2,
650 max_splits: 5,
651 threshold_buffer: Decimal::new(500, 0),
652 }
653 }
654}
655
656impl InjectionStrategy for SplitTransactionStrategy {
657 fn name(&self) -> &'static str {
658 "SplitTransaction"
659 }
660
661 fn can_apply(&self, entry: &JournalEntry) -> bool {
662 entry.total_debit() > self.split_threshold && entry.header.ic_pair_id.is_none()
667 }
668
669 fn apply<R: Rng>(
670 &self,
671 entry: &mut JournalEntry,
672 _anomaly_type: &AnomalyType,
673 rng: &mut R,
674 ) -> InjectionResult {
675 let total = entry.total_debit();
676 if total <= self.split_threshold || total.is_zero() {
677 return InjectionResult::failure("Amount below split threshold");
678 }
679
680 let num_splits = rng.random_range(self.min_splits..=self.max_splits);
681 let target_per_split = self.split_threshold
682 - self.threshold_buffer
683 - Decimal::new(rng.random_range(1..100), 0);
684
685 let scale = target_per_split / total;
687 for line in &mut entry.lines {
688 line.debit_amount = (line.debit_amount * scale).round_dp(2);
689 line.credit_amount = (line.credit_amount * scale).round_dp(2);
690 }
691
692 InjectionResult::success(&format!(
693 "Split ${} transaction into {} parts of ~${} each (below ${} threshold)",
694 total, num_splits, target_per_split, self.split_threshold
695 ))
696 .with_impact(total)
697 .with_metadata("original_amount", &total.to_string())
698 .with_metadata("num_splits", &num_splits.to_string())
699 .with_metadata("threshold", &self.split_threshold.to_string())
700 }
701}
702
703pub struct SkippedApprovalStrategy {
705 pub approval_threshold: Decimal,
707}
708
709impl Default for SkippedApprovalStrategy {
710 fn default() -> Self {
711 Self {
712 approval_threshold: Decimal::new(5000, 0),
713 }
714 }
715}
716
717impl InjectionStrategy for SkippedApprovalStrategy {
718 fn name(&self) -> &'static str {
719 "SkippedApproval"
720 }
721
722 fn can_apply(&self, entry: &JournalEntry) -> bool {
723 entry.total_debit() > self.approval_threshold
725 }
726
727 fn apply<R: Rng>(
728 &self,
729 entry: &mut JournalEntry,
730 _anomaly_type: &AnomalyType,
731 _rng: &mut R,
732 ) -> InjectionResult {
733 let amount = entry.total_debit();
734 if amount <= self.approval_threshold {
735 return InjectionResult::failure("Amount below approval threshold");
736 }
737
738 entry.header.control_status = ControlStatus::Exception;
740 entry.header.sod_violation = true;
741
742 InjectionResult::success(&format!(
743 "Skipped required approval for ${} entry (threshold: ${})",
744 amount, self.approval_threshold
745 ))
746 .with_impact(amount)
747 .with_metadata("threshold", &self.approval_threshold.to_string())
748 }
749}
750
751pub struct WeekendPostingStrategy;
753
754impl Default for WeekendPostingStrategy {
755 fn default() -> Self {
756 Self
757 }
758}
759
760impl InjectionStrategy for WeekendPostingStrategy {
761 fn name(&self) -> &'static str {
762 "WeekendPosting"
763 }
764
765 fn can_apply(&self, _entry: &JournalEntry) -> bool {
766 true
767 }
768
769 fn apply<R: Rng>(
770 &self,
771 entry: &mut JournalEntry,
772 _anomaly_type: &AnomalyType,
773 rng: &mut R,
774 ) -> InjectionResult {
775 use chrono::Weekday;
776
777 let original_date = entry.header.posting_date;
778 let weekday = original_date.weekday();
779
780 let days_to_weekend = match weekday {
782 Weekday::Mon => 5,
783 Weekday::Tue => 4,
784 Weekday::Wed => 3,
785 Weekday::Thu => 2,
786 Weekday::Fri => 1,
787 Weekday::Sat => 0,
788 Weekday::Sun => 0,
789 };
790
791 let weekend_day = if rng.random_bool(0.6) {
793 days_to_weekend
794 } else {
795 days_to_weekend + 1
796 };
797 let new_date = original_date + chrono::Duration::days(weekend_day as i64);
798
799 entry.header.posting_date = new_date;
800
801 InjectionResult::success(&format!(
802 "Moved posting from {} ({:?}) to {} ({:?})",
803 original_date,
804 weekday,
805 new_date,
806 new_date.weekday()
807 ))
808 .with_metadata("original_date", &original_date.to_string())
809 .with_metadata("new_date", &new_date.to_string())
810 }
811}
812
813pub struct ReversedAmountStrategy;
815
816impl Default for ReversedAmountStrategy {
817 fn default() -> Self {
818 Self
819 }
820}
821
822impl InjectionStrategy for ReversedAmountStrategy {
823 fn name(&self) -> &'static str {
824 "ReversedAmount"
825 }
826
827 fn can_apply(&self, entry: &JournalEntry) -> bool {
828 entry.lines.len() >= 2 && entry.header.ic_pair_id.is_none()
836 }
837
838 fn apply<R: Rng>(
839 &self,
840 entry: &mut JournalEntry,
841 _anomaly_type: &AnomalyType,
842 rng: &mut R,
843 ) -> InjectionResult {
844 if entry.lines.len() < 2 {
845 return InjectionResult::failure("Need at least 2 lines to reverse");
846 }
847
848 let line_idx = rng.random_range(0..entry.lines.len());
850 let line = &mut entry.lines[line_idx];
851
852 let original_debit = line.debit_amount;
853 let original_credit = line.credit_amount;
854
855 line.debit_amount = original_credit;
856 line.credit_amount = original_debit;
857
858 let impact = original_debit.max(original_credit);
859
860 InjectionResult::success(&format!(
861 "Reversed amounts on line {}: DR {} → CR {}, CR {} → DR {}",
862 line_idx + 1,
863 original_debit,
864 line.credit_amount,
865 original_credit,
866 line.debit_amount
867 ))
868 .with_impact(impact * Decimal::new(2, 0)) .with_metadata("line_number", &(line_idx + 1).to_string())
870 }
871}
872
873pub struct TransposedDigitsStrategy;
875
876impl Default for TransposedDigitsStrategy {
877 fn default() -> Self {
878 Self
879 }
880}
881
882impl InjectionStrategy for TransposedDigitsStrategy {
883 fn name(&self) -> &'static str {
884 "TransposedDigits"
885 }
886
887 fn can_apply(&self, entry: &JournalEntry) -> bool {
888 if entry.header.ic_pair_id.is_some() {
893 return false;
894 }
895 entry.lines.iter().any(|l| {
897 let amount = if l.debit_amount > Decimal::ZERO {
898 l.debit_amount
899 } else {
900 l.credit_amount
901 };
902 amount >= Decimal::new(10, 0)
903 })
904 }
905
906 fn apply<R: Rng>(
907 &self,
908 entry: &mut JournalEntry,
909 _anomaly_type: &AnomalyType,
910 rng: &mut R,
911 ) -> InjectionResult {
912 let valid_lines: Vec<usize> = entry
914 .lines
915 .iter()
916 .enumerate()
917 .filter(|(_, l)| {
918 let amount = if l.debit_amount > Decimal::ZERO {
919 l.debit_amount
920 } else {
921 l.credit_amount
922 };
923 amount >= Decimal::new(10, 0)
924 })
925 .map(|(i, _)| i)
926 .collect();
927
928 if valid_lines.is_empty() {
929 return InjectionResult::failure("No lines with transposable amounts");
930 }
931
932 let line_idx = valid_lines[rng.random_range(0..valid_lines.len())];
933 let line = &mut entry.lines[line_idx];
934
935 let is_debit = line.debit_amount > Decimal::ZERO;
936 let original_amount = if is_debit {
937 line.debit_amount
938 } else {
939 line.credit_amount
940 };
941
942 let amount_str = original_amount.to_string().replace('.', "");
944 let chars: Vec<char> = amount_str.chars().collect();
945
946 if chars.len() < 2 {
947 return InjectionResult::failure("Amount too small to transpose");
948 }
949
950 let pos = rng.random_range(0..chars.len() - 1);
952 let mut new_chars = chars.clone();
953 new_chars.swap(pos, pos + 1);
954
955 let new_str: String = new_chars.into_iter().collect();
956 let new_amount = new_str.parse::<i64>().unwrap_or(0);
957 let scale = original_amount.scale();
958 let new_decimal = Decimal::new(new_amount, scale);
959
960 let impact = (new_decimal - original_amount).abs();
961
962 if is_debit {
963 line.debit_amount = new_decimal;
964 } else {
965 line.credit_amount = new_decimal;
966 }
967
968 InjectionResult::success(&format!(
969 "Transposed digits: {} → {} (positions {} and {})",
970 original_amount,
971 new_decimal,
972 pos + 1,
973 pos + 2
974 ))
975 .with_impact(impact)
976 .with_metadata("original_amount", &original_amount.to_string())
977 .with_metadata("new_amount", &new_decimal.to_string())
978 }
979}
980
981pub struct DormantAccountStrategy {
983 pub dormant_accounts: Vec<String>,
985}
986
987impl Default for DormantAccountStrategy {
988 fn default() -> Self {
989 Self {
996 dormant_accounts: vec![
997 dormant_accounts::LEGACY_SUSPENSE.to_string(),
998 dormant_accounts::LEGACY_CLEARING.to_string(),
999 dormant_accounts::OBSOLETE.to_string(),
1000 dormant_accounts::TEST_ACCOUNT.to_string(),
1001 ],
1002 }
1003 }
1004}
1005
1006impl InjectionStrategy for DormantAccountStrategy {
1007 fn name(&self) -> &'static str {
1008 "DormantAccountActivity"
1009 }
1010
1011 fn can_apply(&self, entry: &JournalEntry) -> bool {
1012 !entry.lines.is_empty() && !self.dormant_accounts.is_empty()
1013 }
1014
1015 fn apply<R: Rng>(
1016 &self,
1017 entry: &mut JournalEntry,
1018 _anomaly_type: &AnomalyType,
1019 rng: &mut R,
1020 ) -> InjectionResult {
1021 if entry.lines.is_empty() || self.dormant_accounts.is_empty() {
1022 return InjectionResult::failure("No lines or dormant accounts");
1023 }
1024
1025 let line_idx = rng.random_range(0..entry.lines.len());
1026 let line = &mut entry.lines[line_idx];
1027
1028 let original_account = line.gl_account.clone();
1029 let dormant_account =
1030 &self.dormant_accounts[rng.random_range(0..self.dormant_accounts.len())];
1031
1032 line.gl_account = dormant_account.clone();
1033 line.account_code = dormant_account.clone();
1034
1035 let amount = if line.debit_amount > Decimal::ZERO {
1036 line.debit_amount
1037 } else {
1038 line.credit_amount
1039 };
1040
1041 InjectionResult::success(&format!(
1042 "Changed account from {original_account} to dormant account {dormant_account}"
1043 ))
1044 .with_impact(amount)
1045 .with_entity(dormant_account)
1046 .with_metadata("original_account", &original_account)
1047 }
1048}
1049
1050#[derive(Default)]
1052pub struct StrategyCollection {
1053 pub amount_modification: AmountModificationStrategy,
1054 pub date_modification: DateModificationStrategy,
1055 pub duplication: DuplicationStrategy,
1056 pub approval_anomaly: ApprovalAnomalyStrategy,
1057 pub description_anomaly: DescriptionAnomalyStrategy,
1058 pub benford_violation: BenfordViolationStrategy,
1059 pub split_transaction: SplitTransactionStrategy,
1060 pub skipped_approval: SkippedApprovalStrategy,
1061 pub weekend_posting: WeekendPostingStrategy,
1062 pub reversed_amount: ReversedAmountStrategy,
1063 pub transposed_digits: TransposedDigitsStrategy,
1064 pub dormant_account: DormantAccountStrategy,
1065}
1066
1067impl StrategyCollection {
1068 pub fn can_apply(&self, entry: &JournalEntry, anomaly_type: &AnomalyType) -> bool {
1070 match anomaly_type {
1071 AnomalyType::Fraud(FraudType::RoundDollarManipulation)
1073 | AnomalyType::Statistical(StatisticalAnomalyType::UnusuallyHighAmount)
1074 | AnomalyType::Statistical(StatisticalAnomalyType::UnusuallyLowAmount) => {
1075 self.amount_modification.can_apply(entry)
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.can_apply(entry)
1083 }
1084 AnomalyType::Fraud(FraudType::JustBelowThreshold)
1086 | AnomalyType::Fraud(FraudType::ExceededApprovalLimit) => {
1087 self.approval_anomaly.can_apply(entry)
1088 }
1089 AnomalyType::ProcessIssue(ProcessIssueType::VagueDescription) => {
1091 self.description_anomaly.can_apply(entry)
1092 }
1093 AnomalyType::Statistical(StatisticalAnomalyType::BenfordViolation) => {
1095 self.benford_violation.can_apply(entry)
1096 }
1097 AnomalyType::Fraud(FraudType::SplitTransaction) => {
1099 self.split_transaction.can_apply(entry)
1100 }
1101 AnomalyType::ProcessIssue(ProcessIssueType::SkippedApproval) => {
1103 self.skipped_approval.can_apply(entry)
1104 }
1105 AnomalyType::ProcessIssue(ProcessIssueType::WeekendPosting)
1107 | AnomalyType::ProcessIssue(ProcessIssueType::AfterHoursPosting) => {
1108 self.weekend_posting.can_apply(entry)
1109 }
1110 AnomalyType::Error(ErrorType::ReversedAmount) => self.reversed_amount.can_apply(entry),
1112 AnomalyType::Error(ErrorType::TransposedDigits) => {
1114 self.transposed_digits.can_apply(entry)
1115 }
1116 AnomalyType::Relational(RelationalAnomalyType::DormantAccountActivity) => {
1118 self.dormant_account.can_apply(entry)
1119 }
1120 _ => self.amount_modification.can_apply(entry),
1122 }
1123 }
1124
1125 pub fn apply_strategy<R: Rng>(
1127 &self,
1128 entry: &mut JournalEntry,
1129 anomaly_type: &AnomalyType,
1130 rng: &mut R,
1131 ) -> InjectionResult {
1132 match anomaly_type {
1133 AnomalyType::Fraud(FraudType::RoundDollarManipulation)
1135 | AnomalyType::Statistical(StatisticalAnomalyType::UnusuallyHighAmount)
1136 | AnomalyType::Statistical(StatisticalAnomalyType::UnusuallyLowAmount) => {
1137 self.amount_modification.apply(entry, anomaly_type, rng)
1138 }
1139 AnomalyType::Error(ErrorType::BackdatedEntry)
1141 | AnomalyType::Error(ErrorType::FutureDatedEntry)
1142 | AnomalyType::Error(ErrorType::WrongPeriod)
1143 | AnomalyType::ProcessIssue(ProcessIssueType::LatePosting) => {
1144 self.date_modification.apply(entry, anomaly_type, rng)
1145 }
1146 AnomalyType::Fraud(FraudType::JustBelowThreshold)
1148 | AnomalyType::Fraud(FraudType::ExceededApprovalLimit) => {
1149 self.approval_anomaly.apply(entry, anomaly_type, rng)
1150 }
1151 AnomalyType::ProcessIssue(ProcessIssueType::VagueDescription) => {
1153 self.description_anomaly.apply(entry, anomaly_type, rng)
1154 }
1155 AnomalyType::Statistical(StatisticalAnomalyType::BenfordViolation) => {
1157 self.benford_violation.apply(entry, anomaly_type, rng)
1158 }
1159 AnomalyType::Fraud(FraudType::SplitTransaction) => {
1161 self.split_transaction.apply(entry, anomaly_type, rng)
1162 }
1163 AnomalyType::ProcessIssue(ProcessIssueType::SkippedApproval) => {
1165 self.skipped_approval.apply(entry, anomaly_type, rng)
1166 }
1167 AnomalyType::ProcessIssue(ProcessIssueType::WeekendPosting)
1169 | AnomalyType::ProcessIssue(ProcessIssueType::AfterHoursPosting) => {
1170 self.weekend_posting.apply(entry, anomaly_type, rng)
1171 }
1172 AnomalyType::Error(ErrorType::ReversedAmount) => {
1174 self.reversed_amount.apply(entry, anomaly_type, rng)
1175 }
1176 AnomalyType::Error(ErrorType::TransposedDigits) => {
1178 self.transposed_digits.apply(entry, anomaly_type, rng)
1179 }
1180 AnomalyType::Relational(RelationalAnomalyType::DormantAccountActivity) => {
1182 self.dormant_account.apply(entry, anomaly_type, rng)
1183 }
1184 _ => self.amount_modification.apply(entry, anomaly_type, rng),
1186 }
1187 }
1188}
1189
1190#[cfg(test)]
1191mod tests {
1192 use super::*;
1193 use chrono::NaiveDate;
1194 use datasynth_core::models::JournalEntryLine;
1195 use rand::SeedableRng;
1196 use rand_chacha::ChaCha8Rng;
1197 use rust_decimal_macros::dec;
1198
1199 fn create_test_entry() -> JournalEntry {
1200 let mut entry = JournalEntry::new_simple(
1201 "JE001".to_string(),
1202 "1000".to_string(),
1203 NaiveDate::from_ymd_opt(2024, 6, 15).unwrap(),
1204 "Test Entry".to_string(),
1205 );
1206
1207 entry.add_line(JournalEntryLine {
1208 line_number: 1,
1209 gl_account: "5000".to_string(),
1210 debit_amount: dec!(1000),
1211 ..Default::default()
1212 });
1213
1214 entry.add_line(JournalEntryLine {
1215 line_number: 2,
1216 gl_account: "1000".to_string(),
1217 credit_amount: dec!(1000),
1218 ..Default::default()
1219 });
1220
1221 entry
1222 }
1223
1224 fn create_test_ic_entry() -> JournalEntry {
1228 use datasynth_core::models::IcPairId;
1229 let mut entry = create_test_entry();
1230 entry.header.ic_pair_id = Some(IcPairId::from_bytes([0xAB; 32]));
1231 entry
1232 }
1233
1234 #[test]
1240 fn ic_je_is_skipped_by_contract_violating_strategies() {
1241 let ic_entry = create_test_ic_entry();
1242 let normal_entry = create_test_entry();
1243
1244 assert!(
1246 !ReversedAmountStrategy.can_apply(&ic_entry),
1247 "ReversedAmountStrategy must skip IC JEs (debit/credit swap breaks elim contract)"
1248 );
1249 assert!(ReversedAmountStrategy.can_apply(&normal_entry));
1250
1251 assert!(
1253 !AmountModificationStrategy::default().can_apply(&ic_entry),
1254 "AmountModificationStrategy must skip IC JEs"
1255 );
1256 assert!(AmountModificationStrategy::default().can_apply(&normal_entry));
1257
1258 let mut big_ic = create_test_ic_entry();
1261 big_ic.lines[0].debit_amount = dec!(20000);
1262 big_ic.lines[1].credit_amount = dec!(20000);
1263 assert!(
1264 !SplitTransactionStrategy::default().can_apply(&big_ic),
1265 "SplitTransactionStrategy must skip IC JEs (silent seller/buyer notional drift)"
1266 );
1267 let mut big_normal = create_test_entry();
1269 big_normal.lines[0].debit_amount = dec!(20000);
1270 big_normal.lines[1].credit_amount = dec!(20000);
1271 assert!(SplitTransactionStrategy::default().can_apply(&big_normal));
1272
1273 assert!(
1275 !TransposedDigitsStrategy.can_apply(&ic_entry),
1276 "TransposedDigitsStrategy must skip IC JEs"
1277 );
1278 assert!(TransposedDigitsStrategy.can_apply(&normal_entry));
1279 }
1280
1281 #[test]
1282 fn test_amount_modification() {
1283 let strategy = AmountModificationStrategy::default();
1284 let mut entry = create_test_entry();
1285 let mut rng = ChaCha8Rng::seed_from_u64(42);
1286
1287 let result = strategy.apply(
1288 &mut entry,
1289 &AnomalyType::Statistical(StatisticalAnomalyType::UnusuallyHighAmount),
1290 &mut rng,
1291 );
1292
1293 assert!(result.success);
1294 assert!(result.monetary_impact.is_some());
1295 }
1296
1297 #[test]
1298 fn test_amount_modification_rebalanced() {
1299 let strategy = AmountModificationStrategy {
1300 rebalance_entry: true,
1301 ..Default::default()
1302 };
1303 let mut entry = create_test_entry();
1304 let mut rng = ChaCha8Rng::seed_from_u64(42);
1305
1306 assert!(entry.is_balanced());
1308
1309 let result = strategy.apply(
1310 &mut entry,
1311 &AnomalyType::Statistical(StatisticalAnomalyType::UnusuallyHighAmount),
1312 &mut rng,
1313 );
1314
1315 assert!(result.success);
1316 assert!(
1318 entry.is_balanced(),
1319 "Entry should remain balanced after amount modification with rebalancing"
1320 );
1321 }
1322
1323 #[test]
1324 fn test_amount_modification_unbalanced_fraud() {
1325 let strategy = AmountModificationStrategy {
1326 rebalance_entry: false, ..Default::default()
1328 };
1329 let mut entry = create_test_entry();
1330 let mut rng = ChaCha8Rng::seed_from_u64(42);
1331
1332 assert!(entry.is_balanced());
1334
1335 let result = strategy.apply(
1336 &mut entry,
1337 &AnomalyType::Fraud(FraudType::RoundDollarManipulation),
1338 &mut rng,
1339 );
1340
1341 assert!(result.success);
1342 assert!(
1344 !entry.is_balanced(),
1345 "Entry should be unbalanced when rebalance_entry is false"
1346 );
1347 }
1348
1349 #[test]
1350 fn test_benford_violation_rebalanced() {
1351 let strategy = BenfordViolationStrategy {
1352 rebalance_entry: true,
1353 ..Default::default()
1354 };
1355 let mut entry = create_test_entry();
1356 let mut rng = ChaCha8Rng::seed_from_u64(42);
1357
1358 assert!(entry.is_balanced());
1360
1361 let result = strategy.apply(
1362 &mut entry,
1363 &AnomalyType::Statistical(StatisticalAnomalyType::BenfordViolation),
1364 &mut rng,
1365 );
1366
1367 assert!(result.success);
1368 assert!(
1370 entry.is_balanced(),
1371 "Entry should remain balanced after Benford violation with rebalancing"
1372 );
1373 }
1374
1375 #[test]
1376 fn test_date_modification() {
1377 let strategy = DateModificationStrategy::default();
1378 let mut entry = create_test_entry();
1379 let original_date = entry.header.posting_date;
1380 let mut rng = ChaCha8Rng::seed_from_u64(42);
1381
1382 let result = strategy.apply(
1383 &mut entry,
1384 &AnomalyType::Error(ErrorType::BackdatedEntry),
1385 &mut rng,
1386 );
1387
1388 assert!(result.success);
1389 assert!(entry.header.posting_date < original_date);
1390 }
1391
1392 #[test]
1393 fn test_description_anomaly() {
1394 let strategy = DescriptionAnomalyStrategy::default();
1395 let mut entry = create_test_entry();
1396 let mut rng = ChaCha8Rng::seed_from_u64(42);
1397
1398 let result = strategy.apply(
1399 &mut entry,
1400 &AnomalyType::ProcessIssue(ProcessIssueType::VagueDescription),
1401 &mut rng,
1402 );
1403
1404 assert!(result.success);
1405 let desc = entry.description().unwrap_or("").to_string();
1406 assert!(strategy.vague_descriptions.contains(&desc));
1407 }
1408}