1#![allow(clippy::too_many_arguments)]
4
5use chrono::{DateTime, Utc};
6use datasynth_core::models::banking::{
7 AmlTypology, Direction, LaunderingStage, MerchantCategoryCode, TransactionCategory,
8 TransactionChannel,
9};
10use rust_decimal::Decimal;
11use serde::{Deserialize, Serialize};
12use uuid::Uuid;
13
14fn derive_transaction_type(channel: TransactionChannel, category: TransactionCategory) -> String {
20 fn to_screaming_snake(name: &str) -> String {
21 let mut result = String::with_capacity(name.len() + 4);
22 for (i, ch) in name.chars().enumerate() {
23 if ch.is_uppercase() && i > 0 {
24 result.push('_');
25 }
26 result.push(ch.to_ascii_uppercase());
27 }
28 result
29 }
30 let channel_str = to_screaming_snake(&format!("{channel:?}"));
31 let category_str = to_screaming_snake(&format!("{category:?}"));
32 format!("{channel_str}_{category_str}")
33}
34
35#[derive(Debug, Clone, Serialize, Deserialize)]
37pub struct BankTransaction {
38 pub transaction_id: Uuid,
40 pub account_id: Uuid,
42 pub timestamp_initiated: DateTime<Utc>,
44 pub timestamp_booked: DateTime<Utc>,
46 pub timestamp_settled: Option<DateTime<Utc>>,
48 #[serde(with = "rust_decimal::serde::str")]
50 pub amount: Decimal,
51 pub currency: String,
53 pub direction: Direction,
55 pub channel: TransactionChannel,
57 pub category: TransactionCategory,
59 pub counterparty: CounterpartyRef,
61 pub mcc: Option<MerchantCategoryCode>,
63 pub reference: String,
65 #[serde(with = "rust_decimal::serde::str_option")]
67 pub balance_before: Option<Decimal>,
68 #[serde(with = "rust_decimal::serde::str_option")]
70 pub balance_after: Option<Decimal>,
71 pub original_currency: Option<String>,
73 #[serde(with = "rust_decimal::serde::str_option")]
75 pub original_amount: Option<Decimal>,
76 #[serde(with = "rust_decimal::serde::str_option")]
78 pub fx_rate: Option<Decimal>,
79 pub location_country: Option<String>,
81 pub location_city: Option<String>,
83 pub device_id: Option<String>,
85 pub ip_address: Option<String>,
87 pub is_authorized: bool,
89 pub auth_code: Option<String>,
91 pub status: TransactionStatus,
93 pub parent_transaction_id: Option<Uuid>,
95
96 pub is_suspicious: bool,
99 pub suspicion_reason: Option<AmlTypology>,
101 pub laundering_stage: Option<LaunderingStage>,
103 pub case_id: Option<String>,
105 pub is_spoofed: bool,
107 pub spoofing_intensity: Option<f64>,
109 pub scenario_id: Option<String>,
111 pub scenario_sequence: Option<u32>,
113 pub transaction_type: String,
115}
116
117impl BankTransaction {
118 pub fn new(
120 transaction_id: Uuid,
121 account_id: Uuid,
122 amount: Decimal,
123 currency: &str,
124 direction: Direction,
125 channel: TransactionChannel,
126 category: TransactionCategory,
127 counterparty: CounterpartyRef,
128 reference: &str,
129 timestamp: DateTime<Utc>,
130 ) -> Self {
131 let transaction_type = derive_transaction_type(channel, category);
132 Self {
133 transaction_id,
134 account_id,
135 timestamp_initiated: timestamp,
136 timestamp_booked: timestamp,
137 timestamp_settled: None,
138 amount,
139 currency: currency.to_string(),
140 direction,
141 channel,
142 category,
143 counterparty,
144 mcc: None,
145 reference: reference.to_string(),
146 balance_before: None,
147 balance_after: None,
148 original_currency: None,
149 original_amount: None,
150 fx_rate: None,
151 location_country: None,
152 location_city: None,
153 device_id: None,
154 ip_address: None,
155 is_authorized: true,
156 auth_code: None,
157 status: TransactionStatus::Completed,
158 parent_transaction_id: None,
159 is_suspicious: false,
160 suspicion_reason: None,
161 laundering_stage: None,
162 case_id: None,
163 is_spoofed: false,
164 spoofing_intensity: None,
165 scenario_id: None,
166 scenario_sequence: None,
167 transaction_type,
168 }
169 }
170
171 pub fn mark_suspicious(mut self, reason: AmlTypology, case_id: &str) -> Self {
173 self.is_suspicious = true;
174 self.suspicion_reason = Some(reason);
175 self.case_id = Some(case_id.to_string());
176 self
177 }
178
179 pub fn with_laundering_stage(mut self, stage: LaunderingStage) -> Self {
181 self.laundering_stage = Some(stage);
182 self
183 }
184
185 pub fn mark_spoofed(mut self, intensity: f64) -> Self {
187 self.is_spoofed = true;
188 self.spoofing_intensity = Some(intensity);
189 self
190 }
191
192 pub fn with_scenario(mut self, scenario_id: &str, sequence: u32) -> Self {
194 self.scenario_id = Some(scenario_id.to_string());
195 self.scenario_sequence = Some(sequence);
196 self
197 }
198
199 pub fn with_mcc(mut self, mcc: MerchantCategoryCode) -> Self {
201 self.mcc = Some(mcc);
202 self
203 }
204
205 pub fn with_location(mut self, country: &str, city: Option<&str>) -> Self {
207 self.location_country = Some(country.to_string());
208 self.location_city = city.map(std::string::ToString::to_string);
209 self
210 }
211
212 pub fn with_fx_conversion(
214 mut self,
215 original_currency: &str,
216 original_amount: Decimal,
217 rate: Decimal,
218 ) -> Self {
219 self.original_currency = Some(original_currency.to_string());
220 self.original_amount = Some(original_amount);
221 self.fx_rate = Some(rate);
222 self
223 }
224
225 pub fn with_balance(mut self, before: Decimal, after: Decimal) -> Self {
227 self.balance_before = Some(before);
228 self.balance_after = Some(after);
229 self
230 }
231
232 pub fn calculate_risk_score(&self) -> u8 {
234 let mut score = 0.0;
235
236 score += self.channel.risk_weight() * 10.0;
238
239 score += self.category.risk_weight() * 10.0;
241
242 let amount_f64: f64 = self.amount.try_into().unwrap_or(0.0);
244 if amount_f64 > 10_000.0 {
245 score += ((amount_f64 / 10_000.0).ln() * 5.0).min(20.0);
246 }
247
248 if let Some(mcc) = self.mcc {
250 score += mcc.risk_weight() * 5.0;
251 }
252
253 if self.original_currency.is_some() {
255 score += 10.0;
256 }
257
258 if self.is_suspicious {
260 score += 50.0;
261 }
262
263 score.min(100.0) as u8
264 }
265
266 pub fn is_cash(&self) -> bool {
268 matches!(
269 self.channel,
270 TransactionChannel::Cash | TransactionChannel::Atm
271 )
272 }
273
274 pub fn is_cross_border(&self) -> bool {
276 self.original_currency.is_some() || matches!(self.channel, TransactionChannel::Swift)
277 }
278}
279
280#[derive(Debug, Clone, Serialize, Deserialize)]
282pub struct CounterpartyRef {
283 pub counterparty_type: CounterpartyType,
285 pub counterparty_id: Option<Uuid>,
287 pub name: String,
289 pub account_identifier: Option<String>,
291 pub bank_identifier: Option<String>,
293 pub country: Option<String>,
295}
296
297impl CounterpartyRef {
298 pub fn merchant(id: Uuid, name: &str) -> Self {
300 Self {
301 counterparty_type: CounterpartyType::Merchant,
302 counterparty_id: Some(id),
303 name: name.to_string(),
304 account_identifier: None,
305 bank_identifier: None,
306 country: None,
307 }
308 }
309
310 pub fn employer(id: Uuid, name: &str) -> Self {
312 Self {
313 counterparty_type: CounterpartyType::Employer,
314 counterparty_id: Some(id),
315 name: name.to_string(),
316 account_identifier: None,
317 bank_identifier: None,
318 country: None,
319 }
320 }
321
322 pub fn peer(name: &str, account: Option<&str>) -> Self {
324 Self {
325 counterparty_type: CounterpartyType::Peer,
326 counterparty_id: None,
327 name: name.to_string(),
328 account_identifier: account.map(std::string::ToString::to_string),
329 bank_identifier: None,
330 country: None,
331 }
332 }
333
334 pub fn atm(location: &str) -> Self {
336 Self {
337 counterparty_type: CounterpartyType::Atm,
338 counterparty_id: None,
339 name: format!("ATM - {location}"),
340 account_identifier: None,
341 bank_identifier: None,
342 country: None,
343 }
344 }
345
346 pub fn self_account(account_id: Uuid, account_name: &str) -> Self {
348 Self {
349 counterparty_type: CounterpartyType::SelfAccount,
350 counterparty_id: Some(account_id),
351 name: account_name.to_string(),
352 account_identifier: None,
353 bank_identifier: None,
354 country: None,
355 }
356 }
357
358 pub fn unknown(name: &str) -> Self {
360 Self {
361 counterparty_type: CounterpartyType::Unknown,
362 counterparty_id: None,
363 name: name.to_string(),
364 account_identifier: None,
365 bank_identifier: None,
366 country: None,
367 }
368 }
369
370 pub fn person(name: &str) -> Self {
372 Self {
373 counterparty_type: CounterpartyType::Peer,
374 counterparty_id: None,
375 name: name.to_string(),
376 account_identifier: None,
377 bank_identifier: None,
378 country: None,
379 }
380 }
381
382 pub fn business(name: &str) -> Self {
384 Self {
385 counterparty_type: CounterpartyType::Unknown,
386 counterparty_id: None,
387 name: name.to_string(),
388 account_identifier: None,
389 bank_identifier: None,
390 country: None,
391 }
392 }
393
394 pub fn international(name: &str) -> Self {
396 Self {
397 counterparty_type: CounterpartyType::FinancialInstitution,
398 counterparty_id: None,
399 name: name.to_string(),
400 account_identifier: None,
401 bank_identifier: None,
402 country: Some("XX".to_string()), }
404 }
405
406 pub fn crypto_exchange(name: &str) -> Self {
408 Self {
409 counterparty_type: CounterpartyType::CryptoExchange,
410 counterparty_id: None,
411 name: name.to_string(),
412 account_identifier: None,
413 bank_identifier: None,
414 country: None,
415 }
416 }
417
418 pub fn service(name: &str) -> Self {
420 Self {
421 counterparty_type: CounterpartyType::Unknown,
422 counterparty_id: None,
423 name: name.to_string(),
424 account_identifier: None,
425 bank_identifier: None,
426 country: None,
427 }
428 }
429
430 pub fn merchant_by_name(name: &str, _mcc: &str) -> Self {
432 Self {
433 counterparty_type: CounterpartyType::Merchant,
434 counterparty_id: None,
435 name: name.to_string(),
436 account_identifier: None,
437 bank_identifier: None,
438 country: None,
439 }
440 }
441}
442
443#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
445#[serde(rename_all = "snake_case")]
446pub enum CounterpartyType {
447 Merchant,
449 Employer,
451 Utility,
453 Government,
455 FinancialInstitution,
457 Peer,
459 Atm,
461 SelfAccount,
463 Investment,
465 CryptoExchange,
467 Unknown,
469}
470
471impl CounterpartyType {
472 pub fn risk_weight(&self) -> f64 {
474 match self {
475 Self::Merchant => 1.0,
476 Self::Employer => 0.5,
477 Self::Utility | Self::Government => 0.3,
478 Self::FinancialInstitution => 1.2,
479 Self::Peer => 1.5,
480 Self::Atm => 1.3,
481 Self::SelfAccount => 0.8,
482 Self::Investment => 1.2,
483 Self::CryptoExchange => 2.0,
484 Self::Unknown => 1.8,
485 }
486 }
487}
488
489#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize, Default)]
491#[serde(rename_all = "snake_case")]
492pub enum TransactionStatus {
493 Pending,
495 Authorized,
497 #[default]
499 Completed,
500 Failed,
502 Declined,
504 Reversed,
506 Disputed,
508 OnHold,
510}
511
512impl TransactionStatus {
513 pub fn is_final(&self) -> bool {
515 matches!(
516 self,
517 Self::Completed | Self::Failed | Self::Declined | Self::Reversed
518 )
519 }
520}
521
522#[cfg(test)]
523#[allow(clippy::unwrap_used)]
524mod tests {
525 use super::*;
526
527 #[test]
528 fn test_transaction_creation() {
529 let txn = BankTransaction::new(
530 Uuid::new_v4(),
531 Uuid::new_v4(),
532 Decimal::from(100),
533 "USD",
534 Direction::Outbound,
535 TransactionChannel::CardPresent,
536 TransactionCategory::Shopping,
537 CounterpartyRef::merchant(Uuid::new_v4(), "Test Store"),
538 "Purchase at Test Store",
539 Utc::now(),
540 );
541
542 assert!(!txn.is_suspicious);
543 assert!(!txn.is_cross_border());
544 assert!(!txn.transaction_type.is_empty());
545 }
546
547 #[test]
548 fn test_suspicious_transaction() {
549 let txn = BankTransaction::new(
550 Uuid::new_v4(),
551 Uuid::new_v4(),
552 Decimal::from(9500),
553 "USD",
554 Direction::Inbound,
555 TransactionChannel::Cash,
556 TransactionCategory::CashDeposit,
557 CounterpartyRef::atm("Main Branch"),
558 "Cash deposit",
559 Utc::now(),
560 )
561 .mark_suspicious(AmlTypology::Structuring, "CASE-001");
562
563 assert!(txn.is_suspicious);
564 assert_eq!(txn.suspicion_reason, Some(AmlTypology::Structuring));
565 }
566
567 #[test]
568 fn test_risk_score() {
569 let low_risk = BankTransaction::new(
570 Uuid::new_v4(),
571 Uuid::new_v4(),
572 Decimal::from(50),
573 "USD",
574 Direction::Outbound,
575 TransactionChannel::CardPresent,
576 TransactionCategory::Groceries,
577 CounterpartyRef::merchant(Uuid::new_v4(), "Grocery Store"),
578 "Groceries",
579 Utc::now(),
580 );
581
582 let high_risk = BankTransaction::new(
583 Uuid::new_v4(),
584 Uuid::new_v4(),
585 Decimal::from(50000),
586 "USD",
587 Direction::Outbound,
588 TransactionChannel::Wire,
589 TransactionCategory::InternationalTransfer,
590 CounterpartyRef::unknown("Unknown Recipient"),
591 "Wire transfer",
592 Utc::now(),
593 );
594
595 assert!(high_risk.calculate_risk_score() > low_risk.calculate_risk_score());
596 }
597
598 #[test]
599 fn test_transaction_type_derivation() {
600 let txn = BankTransaction::new(
601 Uuid::new_v4(),
602 Uuid::new_v4(),
603 Decimal::from(100),
604 "USD",
605 Direction::Outbound,
606 TransactionChannel::CardPresent,
607 TransactionCategory::Shopping,
608 CounterpartyRef::merchant(Uuid::new_v4(), "Test Store"),
609 "Purchase",
610 Utc::now(),
611 );
612 assert_eq!(txn.transaction_type, "CARD_PRESENT_SHOPPING");
613
614 let txn2 = BankTransaction::new(
615 Uuid::new_v4(),
616 Uuid::new_v4(),
617 Decimal::from(500),
618 "USD",
619 Direction::Outbound,
620 TransactionChannel::Wire,
621 TransactionCategory::InternationalTransfer,
622 CounterpartyRef::unknown("Recipient"),
623 "Wire transfer",
624 Utc::now(),
625 );
626 assert_eq!(txn2.transaction_type, "WIRE_INTERNATIONAL_TRANSFER");
627
628 let txn3 = BankTransaction::new(
629 Uuid::new_v4(),
630 Uuid::new_v4(),
631 Decimal::from(200),
632 "USD",
633 Direction::Outbound,
634 TransactionChannel::Atm,
635 TransactionCategory::AtmWithdrawal,
636 CounterpartyRef::atm("Branch"),
637 "ATM",
638 Utc::now(),
639 );
640 assert_eq!(txn3.transaction_type, "ATM_ATM_WITHDRAWAL");
641 }
642
643 #[test]
644 fn test_transaction_type_all_channels_non_empty() {
645 let channels = [
647 TransactionChannel::CardPresent,
648 TransactionChannel::CardNotPresent,
649 TransactionChannel::Atm,
650 TransactionChannel::Ach,
651 TransactionChannel::Wire,
652 TransactionChannel::InternalTransfer,
653 TransactionChannel::Mobile,
654 TransactionChannel::Online,
655 TransactionChannel::Branch,
656 TransactionChannel::Cash,
657 TransactionChannel::Check,
658 TransactionChannel::RealTimePayment,
659 TransactionChannel::Swift,
660 TransactionChannel::PeerToPeer,
661 ];
662
663 for channel in channels {
664 let txn = BankTransaction::new(
665 Uuid::new_v4(),
666 Uuid::new_v4(),
667 Decimal::from(100),
668 "USD",
669 Direction::Outbound,
670 channel,
671 TransactionCategory::Other,
672 CounterpartyRef::unknown("Test"),
673 "Test",
674 Utc::now(),
675 );
676 assert!(
677 !txn.transaction_type.is_empty(),
678 "transaction_type was empty for channel {:?}",
679 channel
680 );
681 assert!(
683 txn.transaction_type
684 .chars()
685 .all(|c| c.is_ascii_uppercase() || c == '_' || c.is_ascii_digit()),
686 "transaction_type '{}' is not SCREAMING_SNAKE_CASE for channel {:?}",
687 txn.transaction_type,
688 channel
689 );
690 }
691 }
692}