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
14#[derive(Debug, Clone, Serialize, Deserialize)]
16pub struct BankTransaction {
17 pub transaction_id: Uuid,
19 pub account_id: Uuid,
21 pub timestamp_initiated: DateTime<Utc>,
23 pub timestamp_booked: DateTime<Utc>,
25 pub timestamp_settled: Option<DateTime<Utc>>,
27 #[serde(with = "rust_decimal::serde::str")]
29 pub amount: Decimal,
30 pub currency: String,
32 pub direction: Direction,
34 pub channel: TransactionChannel,
36 pub category: TransactionCategory,
38 pub counterparty: CounterpartyRef,
40 pub mcc: Option<MerchantCategoryCode>,
42 pub reference: String,
44 #[serde(with = "rust_decimal::serde::str_option")]
46 pub balance_before: Option<Decimal>,
47 #[serde(with = "rust_decimal::serde::str_option")]
49 pub balance_after: Option<Decimal>,
50 pub original_currency: Option<String>,
52 #[serde(with = "rust_decimal::serde::str_option")]
54 pub original_amount: Option<Decimal>,
55 #[serde(with = "rust_decimal::serde::str_option")]
57 pub fx_rate: Option<Decimal>,
58 pub location_country: Option<String>,
60 pub location_city: Option<String>,
62 pub device_id: Option<String>,
64 pub ip_address: Option<String>,
66 pub is_authorized: bool,
68 pub auth_code: Option<String>,
70 pub status: TransactionStatus,
72 pub parent_transaction_id: Option<Uuid>,
74
75 pub is_suspicious: bool,
78 pub suspicion_reason: Option<AmlTypology>,
80 pub laundering_stage: Option<LaunderingStage>,
82 pub case_id: Option<String>,
84 pub is_spoofed: bool,
86 pub spoofing_intensity: Option<f64>,
88 pub scenario_id: Option<String>,
90 pub scenario_sequence: Option<u32>,
92}
93
94impl BankTransaction {
95 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 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 pub fn with_laundering_stage(mut self, stage: LaunderingStage) -> Self {
156 self.laundering_stage = Some(stage);
157 self
158 }
159
160 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 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 pub fn with_mcc(mut self, mcc: MerchantCategoryCode) -> Self {
176 self.mcc = Some(mcc);
177 self
178 }
179
180 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 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 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 pub fn calculate_risk_score(&self) -> u8 {
209 let mut score = 0.0;
210
211 score += self.channel.risk_weight() * 10.0;
213
214 score += self.category.risk_weight() * 10.0;
216
217 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 if let Some(mcc) = self.mcc {
225 score += mcc.risk_weight() * 5.0;
226 }
227
228 if self.original_currency.is_some() {
230 score += 10.0;
231 }
232
233 if self.is_suspicious {
235 score += 50.0;
236 }
237
238 score.min(100.0) as u8
239 }
240
241 pub fn is_cash(&self) -> bool {
243 matches!(
244 self.channel,
245 TransactionChannel::Cash | TransactionChannel::Atm
246 )
247 }
248
249 pub fn is_cross_border(&self) -> bool {
251 self.original_currency.is_some() || matches!(self.channel, TransactionChannel::Swift)
252 }
253}
254
255#[derive(Debug, Clone, Serialize, Deserialize)]
257pub struct CounterpartyRef {
258 pub counterparty_type: CounterpartyType,
260 pub counterparty_id: Option<Uuid>,
262 pub name: String,
264 pub account_identifier: Option<String>,
266 pub bank_identifier: Option<String>,
268 pub country: Option<String>,
270}
271
272impl CounterpartyRef {
273 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 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 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 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 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 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 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 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 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()), }
379 }
380
381 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 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 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#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
420#[serde(rename_all = "snake_case")]
421pub enum CounterpartyType {
422 Merchant,
424 Employer,
426 Utility,
428 Government,
430 FinancialInstitution,
432 Peer,
434 Atm,
436 SelfAccount,
438 Investment,
440 CryptoExchange,
442 Unknown,
444}
445
446impl CounterpartyType {
447 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#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize, Default)]
466#[serde(rename_all = "snake_case")]
467pub enum TransactionStatus {
468 Pending,
470 Authorized,
472 #[default]
474 Completed,
475 Failed,
477 Declined,
479 Reversed,
481 Disputed,
483 OnHold,
485}
486
487impl TransactionStatus {
488 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}