Skip to main content

datasynth_banking/models/
transaction.rs

1//! Banking transaction model for KYC/AML simulation.
2
3#![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
14/// Derive a transaction type string from channel and category.
15///
16/// Converts `TransactionChannel` and `TransactionCategory` Debug names from
17/// CamelCase to SCREAMING_SNAKE_CASE and joins them with an underscore.
18/// For example, `CardPresent` + `Shopping` becomes `"CARD_PRESENT_SHOPPING"`.
19fn 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/// A bank transaction with full metadata and ground truth labels.
36#[derive(Debug, Clone, Serialize, Deserialize)]
37pub struct BankTransaction {
38    /// Unique transaction identifier
39    pub transaction_id: Uuid,
40    /// Account ID
41    pub account_id: Uuid,
42    /// Timestamp when transaction was initiated
43    pub timestamp_initiated: DateTime<Utc>,
44    /// Timestamp when transaction was booked
45    pub timestamp_booked: DateTime<Utc>,
46    /// Timestamp when transaction was settled
47    pub timestamp_settled: Option<DateTime<Utc>>,
48    /// Transaction amount (always positive)
49    #[serde(with = "rust_decimal::serde::str")]
50    pub amount: Decimal,
51    /// Transaction currency (ISO 4217)
52    pub currency: String,
53    /// Transaction direction (inbound/outbound)
54    pub direction: Direction,
55    /// Transaction channel
56    pub channel: TransactionChannel,
57    /// Transaction category
58    pub category: TransactionCategory,
59    /// Counterparty reference
60    pub counterparty: CounterpartyRef,
61    /// Merchant category code (for card transactions)
62    pub mcc: Option<MerchantCategoryCode>,
63    /// Transaction reference/description
64    pub reference: String,
65    /// Balance before transaction
66    #[serde(with = "rust_decimal::serde::str_option")]
67    pub balance_before: Option<Decimal>,
68    /// Balance after transaction
69    #[serde(with = "rust_decimal::serde::str_option")]
70    pub balance_after: Option<Decimal>,
71    /// Original currency (if FX conversion)
72    pub original_currency: Option<String>,
73    /// Original amount (if FX conversion)
74    #[serde(with = "rust_decimal::serde::str_option")]
75    pub original_amount: Option<Decimal>,
76    /// FX rate applied
77    #[serde(with = "rust_decimal::serde::str_option")]
78    pub fx_rate: Option<Decimal>,
79    /// Location (country code)
80    pub location_country: Option<String>,
81    /// Location (city)
82    pub location_city: Option<String>,
83    /// Device fingerprint (for online/mobile)
84    pub device_id: Option<String>,
85    /// IP address (masked for output)
86    pub ip_address: Option<String>,
87    /// Whether transaction was authorized
88    pub is_authorized: bool,
89    /// Authorization code
90    pub auth_code: Option<String>,
91    /// Transaction status
92    pub status: TransactionStatus,
93    /// Parent transaction ID (for reversals, fees)
94    pub parent_transaction_id: Option<Uuid>,
95
96    // Ground truth labels for ML
97    /// Whether transaction is suspicious (ground truth)
98    pub is_suspicious: bool,
99    /// Suspicion reason (AML typology)
100    pub suspicion_reason: Option<AmlTypology>,
101    /// Money laundering stage
102    pub laundering_stage: Option<LaunderingStage>,
103    /// Case ID linking suspicious transactions
104    pub case_id: Option<String>,
105    /// Whether transaction is spoofed (adversarial mode)
106    pub is_spoofed: bool,
107    /// Spoofing intensity (0.0-1.0)
108    pub spoofing_intensity: Option<f64>,
109    /// Scenario ID for linked transactions
110    pub scenario_id: Option<String>,
111    /// Transaction sequence number within scenario
112    pub scenario_sequence: Option<u32>,
113    /// Derived transaction type (e.g., "CARD_PRESENT_SHOPPING")
114    pub transaction_type: String,
115}
116
117impl BankTransaction {
118    /// Create a new transaction.
119    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    /// Mark as suspicious.
172    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    /// Set laundering stage.
180    pub fn with_laundering_stage(mut self, stage: LaunderingStage) -> Self {
181        self.laundering_stage = Some(stage);
182        self
183    }
184
185    /// Mark as spoofed.
186    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    /// Set scenario information.
193    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    /// Set MCC.
200    pub fn with_mcc(mut self, mcc: MerchantCategoryCode) -> Self {
201        self.mcc = Some(mcc);
202        self
203    }
204
205    /// Set location.
206    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    /// Set FX conversion.
213    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    /// Set balance information.
226    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    /// Calculate risk score for the transaction.
233    pub fn calculate_risk_score(&self) -> u8 {
234        let mut score = 0.0;
235
236        // Channel risk
237        score += self.channel.risk_weight() * 10.0;
238
239        // Category risk
240        score += self.category.risk_weight() * 10.0;
241
242        // Amount risk (log scale)
243        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        // MCC risk
249        if let Some(mcc) = self.mcc {
250            score += mcc.risk_weight() * 5.0;
251        }
252
253        // Cross-border risk
254        if self.original_currency.is_some() {
255            score += 10.0;
256        }
257
258        // Ground truth (if available, would dominate)
259        if self.is_suspicious {
260            score += 50.0;
261        }
262
263        score.min(100.0) as u8
264    }
265
266    /// Check if this is a cash transaction.
267    pub fn is_cash(&self) -> bool {
268        matches!(
269            self.channel,
270            TransactionChannel::Cash | TransactionChannel::Atm
271        )
272    }
273
274    /// Check if this is a cross-border transaction.
275    pub fn is_cross_border(&self) -> bool {
276        self.original_currency.is_some() || matches!(self.channel, TransactionChannel::Swift)
277    }
278}
279
280/// Reference to a counterparty.
281#[derive(Debug, Clone, Serialize, Deserialize)]
282pub struct CounterpartyRef {
283    /// Counterparty type
284    pub counterparty_type: CounterpartyType,
285    /// Counterparty ID (if known)
286    pub counterparty_id: Option<Uuid>,
287    /// Counterparty name
288    pub name: String,
289    /// Account identifier (masked)
290    pub account_identifier: Option<String>,
291    /// Bank identifier (BIC/SWIFT)
292    pub bank_identifier: Option<String>,
293    /// Country (ISO 3166-1 alpha-2)
294    pub country: Option<String>,
295}
296
297impl CounterpartyRef {
298    /// Create a merchant counterparty.
299    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    /// Create an employer counterparty.
311    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    /// Create a peer-to-peer counterparty.
323    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    /// Create an ATM counterparty.
335    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    /// Create a self-transfer counterparty.
347    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    /// Create an unknown counterparty.
359    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    /// Create a person/individual counterparty.
371    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    /// Create a business counterparty.
383    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    /// Create an international counterparty.
395    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()), // Unknown foreign country
403        }
404    }
405
406    /// Create a crypto exchange counterparty.
407    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    /// Create a service provider counterparty.
419    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    /// Create a merchant counterparty by name only.
431    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/// Type of counterparty.
444#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
445#[serde(rename_all = "snake_case")]
446pub enum CounterpartyType {
447    /// Merchant / retailer
448    Merchant,
449    /// Employer (salary source)
450    Employer,
451    /// Utility company
452    Utility,
453    /// Government agency
454    Government,
455    /// Financial institution
456    FinancialInstitution,
457    /// Peer (another individual)
458    Peer,
459    /// ATM
460    Atm,
461    /// Own account (transfer)
462    SelfAccount,
463    /// Investment platform
464    Investment,
465    /// Cryptocurrency exchange
466    CryptoExchange,
467    /// Unknown
468    Unknown,
469}
470
471impl CounterpartyType {
472    /// Risk weight for AML scoring.
473    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/// Transaction status.
490#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize, Default)]
491#[serde(rename_all = "snake_case")]
492pub enum TransactionStatus {
493    /// Pending authorization
494    Pending,
495    /// Authorized but not settled
496    Authorized,
497    /// Completed/settled
498    #[default]
499    Completed,
500    /// Failed
501    Failed,
502    /// Declined
503    Declined,
504    /// Reversed
505    Reversed,
506    /// Disputed
507    Disputed,
508    /// On hold for review
509    OnHold,
510}
511
512impl TransactionStatus {
513    /// Whether the transaction is finalized.
514    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        // Ensure transaction_type is never empty for any channel/category combo
646        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            // Should be SCREAMING_SNAKE_CASE: only uppercase letters, digits, underscores
682            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}