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/// A bank transaction with full metadata and ground truth labels.
15#[derive(Debug, Clone, Serialize, Deserialize)]
16pub struct BankTransaction {
17    /// Unique transaction identifier
18    pub transaction_id: Uuid,
19    /// Account ID
20    pub account_id: Uuid,
21    /// Timestamp when transaction was initiated
22    pub timestamp_initiated: DateTime<Utc>,
23    /// Timestamp when transaction was booked
24    pub timestamp_booked: DateTime<Utc>,
25    /// Timestamp when transaction was settled
26    pub timestamp_settled: Option<DateTime<Utc>>,
27    /// Transaction amount (always positive)
28    #[serde(with = "rust_decimal::serde::str")]
29    pub amount: Decimal,
30    /// Transaction currency (ISO 4217)
31    pub currency: String,
32    /// Transaction direction (inbound/outbound)
33    pub direction: Direction,
34    /// Transaction channel
35    pub channel: TransactionChannel,
36    /// Transaction category
37    pub category: TransactionCategory,
38    /// Counterparty reference
39    pub counterparty: CounterpartyRef,
40    /// Merchant category code (for card transactions)
41    pub mcc: Option<MerchantCategoryCode>,
42    /// Transaction reference/description
43    pub reference: String,
44    /// Balance before transaction
45    #[serde(with = "rust_decimal::serde::str_option")]
46    pub balance_before: Option<Decimal>,
47    /// Balance after transaction
48    #[serde(with = "rust_decimal::serde::str_option")]
49    pub balance_after: Option<Decimal>,
50    /// Original currency (if FX conversion)
51    pub original_currency: Option<String>,
52    /// Original amount (if FX conversion)
53    #[serde(with = "rust_decimal::serde::str_option")]
54    pub original_amount: Option<Decimal>,
55    /// FX rate applied
56    #[serde(with = "rust_decimal::serde::str_option")]
57    pub fx_rate: Option<Decimal>,
58    /// Location (country code)
59    pub location_country: Option<String>,
60    /// Location (city)
61    pub location_city: Option<String>,
62    /// Device fingerprint (for online/mobile)
63    pub device_id: Option<String>,
64    /// IP address (masked for output)
65    pub ip_address: Option<String>,
66    /// Whether transaction was authorized
67    pub is_authorized: bool,
68    /// Authorization code
69    pub auth_code: Option<String>,
70    /// Transaction status
71    pub status: TransactionStatus,
72    /// Parent transaction ID (for reversals, fees)
73    pub parent_transaction_id: Option<Uuid>,
74
75    // Ground truth labels for ML
76    /// Whether transaction is suspicious (ground truth)
77    pub is_suspicious: bool,
78    /// Suspicion reason (AML typology)
79    pub suspicion_reason: Option<AmlTypology>,
80    /// Money laundering stage
81    pub laundering_stage: Option<LaunderingStage>,
82    /// Case ID linking suspicious transactions
83    pub case_id: Option<String>,
84    /// Whether transaction is spoofed (adversarial mode)
85    pub is_spoofed: bool,
86    /// Spoofing intensity (0.0-1.0)
87    pub spoofing_intensity: Option<f64>,
88    /// Scenario ID for linked transactions
89    pub scenario_id: Option<String>,
90    /// Transaction sequence number within scenario
91    pub scenario_sequence: Option<u32>,
92}
93
94impl BankTransaction {
95    /// Create a new transaction.
96    pub fn new(
97        transaction_id: Uuid,
98        account_id: Uuid,
99        amount: Decimal,
100        currency: &str,
101        direction: Direction,
102        channel: TransactionChannel,
103        category: TransactionCategory,
104        counterparty: CounterpartyRef,
105        reference: &str,
106        timestamp: DateTime<Utc>,
107    ) -> Self {
108        Self {
109            transaction_id,
110            account_id,
111            timestamp_initiated: timestamp,
112            timestamp_booked: timestamp,
113            timestamp_settled: None,
114            amount,
115            currency: currency.to_string(),
116            direction,
117            channel,
118            category,
119            counterparty,
120            mcc: None,
121            reference: reference.to_string(),
122            balance_before: None,
123            balance_after: None,
124            original_currency: None,
125            original_amount: None,
126            fx_rate: None,
127            location_country: None,
128            location_city: None,
129            device_id: None,
130            ip_address: None,
131            is_authorized: true,
132            auth_code: None,
133            status: TransactionStatus::Completed,
134            parent_transaction_id: None,
135            is_suspicious: false,
136            suspicion_reason: None,
137            laundering_stage: None,
138            case_id: None,
139            is_spoofed: false,
140            spoofing_intensity: None,
141            scenario_id: None,
142            scenario_sequence: None,
143        }
144    }
145
146    /// Mark as suspicious.
147    pub fn mark_suspicious(mut self, reason: AmlTypology, case_id: &str) -> Self {
148        self.is_suspicious = true;
149        self.suspicion_reason = Some(reason);
150        self.case_id = Some(case_id.to_string());
151        self
152    }
153
154    /// Set laundering stage.
155    pub fn with_laundering_stage(mut self, stage: LaunderingStage) -> Self {
156        self.laundering_stage = Some(stage);
157        self
158    }
159
160    /// Mark as spoofed.
161    pub fn mark_spoofed(mut self, intensity: f64) -> Self {
162        self.is_spoofed = true;
163        self.spoofing_intensity = Some(intensity);
164        self
165    }
166
167    /// Set scenario information.
168    pub fn with_scenario(mut self, scenario_id: &str, sequence: u32) -> Self {
169        self.scenario_id = Some(scenario_id.to_string());
170        self.scenario_sequence = Some(sequence);
171        self
172    }
173
174    /// Set MCC.
175    pub fn with_mcc(mut self, mcc: MerchantCategoryCode) -> Self {
176        self.mcc = Some(mcc);
177        self
178    }
179
180    /// Set location.
181    pub fn with_location(mut self, country: &str, city: Option<&str>) -> Self {
182        self.location_country = Some(country.to_string());
183        self.location_city = city.map(|c| c.to_string());
184        self
185    }
186
187    /// Set FX conversion.
188    pub fn with_fx_conversion(
189        mut self,
190        original_currency: &str,
191        original_amount: Decimal,
192        rate: Decimal,
193    ) -> Self {
194        self.original_currency = Some(original_currency.to_string());
195        self.original_amount = Some(original_amount);
196        self.fx_rate = Some(rate);
197        self
198    }
199
200    /// Set balance information.
201    pub fn with_balance(mut self, before: Decimal, after: Decimal) -> Self {
202        self.balance_before = Some(before);
203        self.balance_after = Some(after);
204        self
205    }
206
207    /// Calculate risk score for the transaction.
208    pub fn calculate_risk_score(&self) -> u8 {
209        let mut score = 0.0;
210
211        // Channel risk
212        score += self.channel.risk_weight() * 10.0;
213
214        // Category risk
215        score += self.category.risk_weight() * 10.0;
216
217        // Amount risk (log scale)
218        let amount_f64: f64 = self.amount.try_into().unwrap_or(0.0);
219        if amount_f64 > 10_000.0 {
220            score += ((amount_f64 / 10_000.0).ln() * 5.0).min(20.0);
221        }
222
223        // MCC risk
224        if let Some(mcc) = self.mcc {
225            score += mcc.risk_weight() * 5.0;
226        }
227
228        // Cross-border risk
229        if self.original_currency.is_some() {
230            score += 10.0;
231        }
232
233        // Ground truth (if available, would dominate)
234        if self.is_suspicious {
235            score += 50.0;
236        }
237
238        score.min(100.0) as u8
239    }
240
241    /// Check if this is a cash transaction.
242    pub fn is_cash(&self) -> bool {
243        matches!(
244            self.channel,
245            TransactionChannel::Cash | TransactionChannel::Atm
246        )
247    }
248
249    /// Check if this is a cross-border transaction.
250    pub fn is_cross_border(&self) -> bool {
251        self.original_currency.is_some() || matches!(self.channel, TransactionChannel::Swift)
252    }
253}
254
255/// Reference to a counterparty.
256#[derive(Debug, Clone, Serialize, Deserialize)]
257pub struct CounterpartyRef {
258    /// Counterparty type
259    pub counterparty_type: CounterpartyType,
260    /// Counterparty ID (if known)
261    pub counterparty_id: Option<Uuid>,
262    /// Counterparty name
263    pub name: String,
264    /// Account identifier (masked)
265    pub account_identifier: Option<String>,
266    /// Bank identifier (BIC/SWIFT)
267    pub bank_identifier: Option<String>,
268    /// Country (ISO 3166-1 alpha-2)
269    pub country: Option<String>,
270}
271
272impl CounterpartyRef {
273    /// Create a merchant counterparty.
274    pub fn merchant(id: Uuid, name: &str) -> Self {
275        Self {
276            counterparty_type: CounterpartyType::Merchant,
277            counterparty_id: Some(id),
278            name: name.to_string(),
279            account_identifier: None,
280            bank_identifier: None,
281            country: None,
282        }
283    }
284
285    /// Create an employer counterparty.
286    pub fn employer(id: Uuid, name: &str) -> Self {
287        Self {
288            counterparty_type: CounterpartyType::Employer,
289            counterparty_id: Some(id),
290            name: name.to_string(),
291            account_identifier: None,
292            bank_identifier: None,
293            country: None,
294        }
295    }
296
297    /// Create a peer-to-peer counterparty.
298    pub fn peer(name: &str, account: Option<&str>) -> Self {
299        Self {
300            counterparty_type: CounterpartyType::Peer,
301            counterparty_id: None,
302            name: name.to_string(),
303            account_identifier: account.map(|a| a.to_string()),
304            bank_identifier: None,
305            country: None,
306        }
307    }
308
309    /// Create an ATM counterparty.
310    pub fn atm(location: &str) -> Self {
311        Self {
312            counterparty_type: CounterpartyType::Atm,
313            counterparty_id: None,
314            name: format!("ATM - {}", location),
315            account_identifier: None,
316            bank_identifier: None,
317            country: None,
318        }
319    }
320
321    /// Create a self-transfer counterparty.
322    pub fn self_account(account_id: Uuid, account_name: &str) -> Self {
323        Self {
324            counterparty_type: CounterpartyType::SelfAccount,
325            counterparty_id: Some(account_id),
326            name: account_name.to_string(),
327            account_identifier: None,
328            bank_identifier: None,
329            country: None,
330        }
331    }
332
333    /// Create an unknown counterparty.
334    pub fn unknown(name: &str) -> Self {
335        Self {
336            counterparty_type: CounterpartyType::Unknown,
337            counterparty_id: None,
338            name: name.to_string(),
339            account_identifier: None,
340            bank_identifier: None,
341            country: None,
342        }
343    }
344
345    /// Create a person/individual counterparty.
346    pub fn person(name: &str) -> Self {
347        Self {
348            counterparty_type: CounterpartyType::Peer,
349            counterparty_id: None,
350            name: name.to_string(),
351            account_identifier: None,
352            bank_identifier: None,
353            country: None,
354        }
355    }
356
357    /// Create a business counterparty.
358    pub fn business(name: &str) -> Self {
359        Self {
360            counterparty_type: CounterpartyType::Unknown,
361            counterparty_id: None,
362            name: name.to_string(),
363            account_identifier: None,
364            bank_identifier: None,
365            country: None,
366        }
367    }
368
369    /// Create an international counterparty.
370    pub fn international(name: &str) -> Self {
371        Self {
372            counterparty_type: CounterpartyType::FinancialInstitution,
373            counterparty_id: None,
374            name: name.to_string(),
375            account_identifier: None,
376            bank_identifier: None,
377            country: Some("XX".to_string()), // Unknown foreign country
378        }
379    }
380
381    /// Create a crypto exchange counterparty.
382    pub fn crypto_exchange(name: &str) -> Self {
383        Self {
384            counterparty_type: CounterpartyType::CryptoExchange,
385            counterparty_id: None,
386            name: name.to_string(),
387            account_identifier: None,
388            bank_identifier: None,
389            country: None,
390        }
391    }
392
393    /// Create a service provider counterparty.
394    pub fn service(name: &str) -> Self {
395        Self {
396            counterparty_type: CounterpartyType::Unknown,
397            counterparty_id: None,
398            name: name.to_string(),
399            account_identifier: None,
400            bank_identifier: None,
401            country: None,
402        }
403    }
404
405    /// Create a merchant counterparty by name only.
406    pub fn merchant_by_name(name: &str, _mcc: &str) -> Self {
407        Self {
408            counterparty_type: CounterpartyType::Merchant,
409            counterparty_id: None,
410            name: name.to_string(),
411            account_identifier: None,
412            bank_identifier: None,
413            country: None,
414        }
415    }
416}
417
418/// Type of counterparty.
419#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
420#[serde(rename_all = "snake_case")]
421pub enum CounterpartyType {
422    /// Merchant / retailer
423    Merchant,
424    /// Employer (salary source)
425    Employer,
426    /// Utility company
427    Utility,
428    /// Government agency
429    Government,
430    /// Financial institution
431    FinancialInstitution,
432    /// Peer (another individual)
433    Peer,
434    /// ATM
435    Atm,
436    /// Own account (transfer)
437    SelfAccount,
438    /// Investment platform
439    Investment,
440    /// Cryptocurrency exchange
441    CryptoExchange,
442    /// Unknown
443    Unknown,
444}
445
446impl CounterpartyType {
447    /// Risk weight for AML scoring.
448    pub fn risk_weight(&self) -> f64 {
449        match self {
450            Self::Merchant => 1.0,
451            Self::Employer => 0.5,
452            Self::Utility | Self::Government => 0.3,
453            Self::FinancialInstitution => 1.2,
454            Self::Peer => 1.5,
455            Self::Atm => 1.3,
456            Self::SelfAccount => 0.8,
457            Self::Investment => 1.2,
458            Self::CryptoExchange => 2.0,
459            Self::Unknown => 1.8,
460        }
461    }
462}
463
464/// Transaction status.
465#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize, Default)]
466#[serde(rename_all = "snake_case")]
467pub enum TransactionStatus {
468    /// Pending authorization
469    Pending,
470    /// Authorized but not settled
471    Authorized,
472    /// Completed/settled
473    #[default]
474    Completed,
475    /// Failed
476    Failed,
477    /// Declined
478    Declined,
479    /// Reversed
480    Reversed,
481    /// Disputed
482    Disputed,
483    /// On hold for review
484    OnHold,
485}
486
487impl TransactionStatus {
488    /// Whether the transaction is finalized.
489    pub fn is_final(&self) -> bool {
490        matches!(
491            self,
492            Self::Completed | Self::Failed | Self::Declined | Self::Reversed
493        )
494    }
495}
496
497#[cfg(test)]
498mod tests {
499    use super::*;
500
501    #[test]
502    fn test_transaction_creation() {
503        let txn = BankTransaction::new(
504            Uuid::new_v4(),
505            Uuid::new_v4(),
506            Decimal::from(100),
507            "USD",
508            Direction::Outbound,
509            TransactionChannel::CardPresent,
510            TransactionCategory::Shopping,
511            CounterpartyRef::merchant(Uuid::new_v4(), "Test Store"),
512            "Purchase at Test Store",
513            Utc::now(),
514        );
515
516        assert!(!txn.is_suspicious);
517        assert!(!txn.is_cross_border());
518    }
519
520    #[test]
521    fn test_suspicious_transaction() {
522        let txn = BankTransaction::new(
523            Uuid::new_v4(),
524            Uuid::new_v4(),
525            Decimal::from(9500),
526            "USD",
527            Direction::Inbound,
528            TransactionChannel::Cash,
529            TransactionCategory::CashDeposit,
530            CounterpartyRef::atm("Main Branch"),
531            "Cash deposit",
532            Utc::now(),
533        )
534        .mark_suspicious(AmlTypology::Structuring, "CASE-001");
535
536        assert!(txn.is_suspicious);
537        assert_eq!(txn.suspicion_reason, Some(AmlTypology::Structuring));
538    }
539
540    #[test]
541    fn test_risk_score() {
542        let low_risk = BankTransaction::new(
543            Uuid::new_v4(),
544            Uuid::new_v4(),
545            Decimal::from(50),
546            "USD",
547            Direction::Outbound,
548            TransactionChannel::CardPresent,
549            TransactionCategory::Groceries,
550            CounterpartyRef::merchant(Uuid::new_v4(), "Grocery Store"),
551            "Groceries",
552            Utc::now(),
553        );
554
555        let high_risk = BankTransaction::new(
556            Uuid::new_v4(),
557            Uuid::new_v4(),
558            Decimal::from(50000),
559            "USD",
560            Direction::Outbound,
561            TransactionChannel::Wire,
562            TransactionCategory::InternationalTransfer,
563            CounterpartyRef::unknown("Unknown Recipient"),
564            "Wire transfer",
565            Utc::now(),
566        );
567
568        assert!(high_risk.calculate_risk_score() > low_risk.calculate_risk_score());
569    }
570}