1use chrono::{DateTime, Datelike, Duration, NaiveDate, Utc};
4use datasynth_core::models::banking::{
5 Direction, MerchantCategoryCode, TransactionCategory, TransactionChannel,
6};
7use datasynth_core::DeterministicUuidFactory;
8use rand::prelude::*;
9use rand_chacha::ChaCha8Rng;
10use rust_decimal::Decimal;
11use uuid::Uuid;
12
13use crate::config::BankingConfig;
14use crate::models::{
15 BankAccount, BankTransaction, BankingCustomer, CounterpartyPool, CounterpartyRef,
16 PersonaVariant,
17};
18
19pub struct TransactionGenerator {
21 config: BankingConfig,
22 rng: ChaCha8Rng,
23 uuid_factory: DeterministicUuidFactory,
24 counterparty_pool: CounterpartyPool,
25 start_date: NaiveDate,
26 end_date: NaiveDate,
27}
28
29impl TransactionGenerator {
30 pub fn new(config: BankingConfig, seed: u64) -> Self {
32 let start_date = NaiveDate::parse_from_str(&config.population.start_date, "%Y-%m-%d")
33 .unwrap_or_else(|_| NaiveDate::from_ymd_opt(2024, 1, 1).unwrap());
34 let end_date = start_date + chrono::Months::new(config.population.period_months);
35
36 Self {
37 config,
38 rng: ChaCha8Rng::seed_from_u64(seed.wrapping_add(2000)),
39 uuid_factory: DeterministicUuidFactory::new(
40 seed,
41 datasynth_core::GeneratorType::JournalEntry,
42 ),
43 counterparty_pool: CounterpartyPool::standard(),
44 start_date,
45 end_date,
46 }
47 }
48
49 pub fn with_counterparty_pool(mut self, pool: CounterpartyPool) -> Self {
51 self.counterparty_pool = pool;
52 self
53 }
54
55 pub fn generate_all(
57 &mut self,
58 customers: &[BankingCustomer],
59 accounts: &mut [BankAccount],
60 ) -> Vec<BankTransaction> {
61 let mut transactions = Vec::new();
62
63 let customer_map: std::collections::HashMap<Uuid, &BankingCustomer> =
65 customers.iter().map(|c| (c.customer_id, c)).collect();
66
67 for account in accounts.iter_mut() {
68 if let Some(customer) = customer_map.get(&account.primary_owner_id) {
69 let account_txns = self.generate_account_transactions(customer, account);
70 transactions.extend(account_txns);
71 }
72 }
73
74 transactions.sort_by_key(|t| t.timestamp_initiated);
76
77 transactions
78 }
79
80 pub fn generate_account_transactions(
82 &mut self,
83 customer: &BankingCustomer,
84 account: &mut BankAccount,
85 ) -> Vec<BankTransaction> {
86 let mut transactions = Vec::new();
87
88 let mut current_date = self.start_date.max(account.opening_date);
89 let mut balance = account.current_balance;
90
91 while current_date <= self.end_date {
92 let daily_txns =
94 self.generate_daily_transactions(customer, account, current_date, &mut balance);
95 transactions.extend(daily_txns);
96
97 current_date += Duration::days(1);
98 }
99
100 account.current_balance = balance;
102 account.available_balance = balance;
103
104 transactions
105 }
106
107 fn generate_daily_transactions(
109 &mut self,
110 customer: &BankingCustomer,
111 account: &BankAccount,
112 date: NaiveDate,
113 balance: &mut Decimal,
114 ) -> Vec<BankTransaction> {
115 let mut transactions = Vec::new();
116
117 let expected_count = self.calculate_daily_transaction_count(customer, date);
119
120 if self.should_generate_income(customer, date) {
122 if let Some(txn) = self.generate_income_transaction(customer, account, date, balance) {
123 transactions.push(txn);
124 }
125 }
126
127 if self.should_generate_recurring(customer, date) {
129 transactions
130 .extend(self.generate_recurring_transactions(customer, account, date, balance));
131 }
132
133 let discretionary_count = expected_count.saturating_sub(transactions.len() as u32);
135 for _ in 0..discretionary_count {
136 if let Some(txn) =
137 self.generate_discretionary_transaction(customer, account, date, balance)
138 {
139 transactions.push(txn);
140 }
141 }
142
143 transactions
144 }
145
146 fn calculate_daily_transaction_count(
148 &mut self,
149 customer: &BankingCustomer,
150 date: NaiveDate,
151 ) -> u32 {
152 let (freq_min, freq_max) = match &customer.persona {
153 Some(PersonaVariant::Retail(p)) => p.transaction_frequency_range(),
154 Some(PersonaVariant::Business(_)) => (50, 200),
155 _ => (10, 50),
156 };
157
158 let avg_daily = (freq_min + freq_max) as f64 / 2.0 / 30.0;
159
160 let day_of_week = date.weekday();
162 let multiplier = match day_of_week {
163 chrono::Weekday::Sat | chrono::Weekday::Sun => 0.5,
164 _ => 1.0,
165 };
166
167 let expected = avg_daily * multiplier + self.rng.gen_range(-1.0..1.0);
168 expected.max(0.0) as u32
169 }
170
171 fn should_generate_income(&mut self, customer: &BankingCustomer, date: NaiveDate) -> bool {
173 match &customer.persona {
174 Some(PersonaVariant::Retail(p)) => {
175 use datasynth_core::models::banking::RetailPersona;
176 match p {
177 RetailPersona::Retiree => date.day() == 1 || date.day() == 15, RetailPersona::GigWorker => self.rng.gen::<f64>() < 0.15, _ => {
180 (date.day() == 1 || date.day() == 15)
181 && date.weekday().num_days_from_monday() < 5
182 }
183 }
184 }
185 Some(PersonaVariant::Business(_)) => self.rng.gen::<f64>() < 0.3, _ => date.day() == 1,
187 }
188 }
189
190 fn should_generate_recurring(&mut self, _customer: &BankingCustomer, date: NaiveDate) -> bool {
192 date.day() == 1 || date.day() == 15 || date.day() >= 28
194 }
195
196 fn generate_income_transaction(
198 &mut self,
199 customer: &BankingCustomer,
200 account: &BankAccount,
201 date: NaiveDate,
202 balance: &mut Decimal,
203 ) -> Option<BankTransaction> {
204 let (amount, category, counterparty) = match &customer.persona {
205 Some(PersonaVariant::Retail(p)) => {
206 let (min, max) = p.income_range();
207 let amount = Decimal::from_f64_retain(self.rng.gen_range(min as f64..max as f64))
208 .unwrap_or(Decimal::ZERO);
209
210 let category = match p {
211 datasynth_core::models::banking::RetailPersona::Retiree => {
212 TransactionCategory::Pension
213 }
214 datasynth_core::models::banking::RetailPersona::GigWorker => {
215 TransactionCategory::FreelanceIncome
216 }
217 _ => TransactionCategory::Salary,
218 };
219
220 let employer = self.counterparty_pool.employers.choose(&mut self.rng);
221 let counterparty = employer
222 .map(|e| CounterpartyRef::employer(e.employer_id, &e.name))
223 .unwrap_or_else(|| CounterpartyRef::unknown("Employer"));
224
225 (amount, category, counterparty)
226 }
227 _ => return None,
228 };
229
230 let timestamp = self.random_timestamp(date);
231 *balance += amount;
232
233 let txn = BankTransaction::new(
234 self.uuid_factory.next(),
235 account.account_id,
236 amount,
237 &account.currency,
238 Direction::Inbound,
239 TransactionChannel::Ach,
240 category,
241 counterparty,
242 "Direct deposit",
243 timestamp,
244 )
245 .with_balance(*balance - amount, *balance);
246
247 Some(txn)
248 }
249
250 fn generate_recurring_transactions(
252 &mut self,
253 _customer: &BankingCustomer,
254 account: &BankAccount,
255 date: NaiveDate,
256 balance: &mut Decimal,
257 ) -> Vec<BankTransaction> {
258 let mut transactions = Vec::new();
259
260 let recurring_types = [
262 (TransactionCategory::Housing, 1000.0, 3000.0, 0.3),
263 (TransactionCategory::Utilities, 50.0, 200.0, 0.2),
264 (TransactionCategory::Insurance, 100.0, 500.0, 0.15),
265 (TransactionCategory::Subscription, 10.0, 100.0, 0.3),
266 ];
267
268 for (category, min, max, probability) in recurring_types {
269 if self.rng.gen::<f64>() < probability {
270 let amount =
271 Decimal::from_f64_retain(self.rng.gen_range(min..max)).unwrap_or(Decimal::ZERO);
272
273 if *balance < amount {
275 continue;
276 }
277
278 *balance -= amount;
279
280 let utility = self.counterparty_pool.utilities.choose(&mut self.rng);
281 let counterparty = utility
282 .map(|u| CounterpartyRef::unknown(&u.name))
283 .unwrap_or_else(|| CounterpartyRef::unknown("Service Provider"));
284
285 let txn = BankTransaction::new(
286 self.uuid_factory.next(),
287 account.account_id,
288 amount,
289 &account.currency,
290 Direction::Outbound,
291 TransactionChannel::Ach,
292 category,
293 counterparty,
294 &format!("{:?} payment", category),
295 self.random_timestamp(date),
296 )
297 .with_balance(*balance + amount, *balance);
298
299 transactions.push(txn);
300 }
301 }
302
303 transactions
304 }
305
306 fn generate_discretionary_transaction(
308 &mut self,
309 _customer: &BankingCustomer,
310 account: &BankAccount,
311 date: NaiveDate,
312 balance: &mut Decimal,
313 ) -> Option<BankTransaction> {
314 let channel = self.select_channel();
316
317 let (category, mcc) = self.select_category(channel);
319
320 let amount = self.generate_transaction_amount(category);
322
323 let direction = if self.rng.gen::<f64>() < 0.1 {
325 Direction::Inbound
326 } else {
327 Direction::Outbound
328 };
329
330 if direction == Direction::Outbound && *balance < amount {
332 return None;
333 }
334
335 let counterparty = self.select_counterparty(category);
337
338 match direction {
340 Direction::Inbound => *balance += amount,
341 Direction::Outbound => *balance -= amount,
342 }
343
344 let balance_before = match direction {
345 Direction::Inbound => *balance - amount,
346 Direction::Outbound => *balance + amount,
347 };
348
349 let mut txn = BankTransaction::new(
350 self.uuid_factory.next(),
351 account.account_id,
352 amount,
353 &account.currency,
354 direction,
355 channel,
356 category,
357 counterparty,
358 &self.generate_reference(category),
359 self.random_timestamp(date),
360 )
361 .with_balance(balance_before, *balance);
362
363 if let Some(mcc) = mcc {
364 txn = txn.with_mcc(mcc);
365 }
366
367 Some(txn)
368 }
369
370 fn select_channel(&mut self) -> TransactionChannel {
372 let card_ratio = self.config.products.card_vs_transfer;
373 let roll: f64 = self.rng.gen();
374
375 if roll < card_ratio * 0.6 {
376 TransactionChannel::CardPresent
377 } else if roll < card_ratio {
378 TransactionChannel::CardNotPresent
379 } else if roll < card_ratio + (1.0 - card_ratio) * 0.3 {
380 TransactionChannel::Ach
381 } else if roll < card_ratio + (1.0 - card_ratio) * 0.5 {
382 TransactionChannel::Online
383 } else if roll < card_ratio + (1.0 - card_ratio) * 0.7 {
384 TransactionChannel::Mobile
385 } else if roll < card_ratio + (1.0 - card_ratio) * 0.85 {
386 TransactionChannel::Atm
387 } else {
388 TransactionChannel::PeerToPeer
389 }
390 }
391
392 fn select_category(
394 &mut self,
395 channel: TransactionChannel,
396 ) -> (TransactionCategory, Option<MerchantCategoryCode>) {
397 let categories: Vec<(TransactionCategory, Option<MerchantCategoryCode>, f64)> =
398 match channel {
399 TransactionChannel::CardPresent | TransactionChannel::CardNotPresent => vec![
400 (
401 TransactionCategory::Groceries,
402 Some(MerchantCategoryCode::GROCERY_STORES),
403 0.25,
404 ),
405 (
406 TransactionCategory::Dining,
407 Some(MerchantCategoryCode::RESTAURANTS),
408 0.20,
409 ),
410 (
411 TransactionCategory::Shopping,
412 Some(MerchantCategoryCode::DEPARTMENT_STORES),
413 0.20,
414 ),
415 (
416 TransactionCategory::Transportation,
417 Some(MerchantCategoryCode::GAS_STATIONS),
418 0.15,
419 ),
420 (TransactionCategory::Entertainment, None, 0.10),
421 (
422 TransactionCategory::Healthcare,
423 Some(MerchantCategoryCode::MEDICAL),
424 0.05,
425 ),
426 (TransactionCategory::Other, None, 0.05),
427 ],
428 TransactionChannel::Atm => vec![(TransactionCategory::AtmWithdrawal, None, 1.0)],
429 TransactionChannel::PeerToPeer => {
430 vec![(TransactionCategory::P2PPayment, None, 1.0)]
431 }
432 _ => vec![
433 (TransactionCategory::TransferOut, None, 0.5),
434 (TransactionCategory::Other, None, 0.5),
435 ],
436 };
437
438 let total: f64 = categories.iter().map(|(_, _, w)| w).sum();
439 let roll: f64 = self.rng.gen::<f64>() * total;
440 let mut cumulative = 0.0;
441
442 for (cat, mcc, weight) in categories {
443 cumulative += weight;
444 if roll < cumulative {
445 return (cat, mcc);
446 }
447 }
448
449 (TransactionCategory::Other, None)
450 }
451
452 fn generate_transaction_amount(&mut self, category: TransactionCategory) -> Decimal {
454 let (min, max) = match category {
455 TransactionCategory::Groceries => (20.0, 200.0),
456 TransactionCategory::Dining => (10.0, 150.0),
457 TransactionCategory::Shopping => (15.0, 500.0),
458 TransactionCategory::Transportation => (20.0, 100.0),
459 TransactionCategory::Entertainment => (10.0, 200.0),
460 TransactionCategory::Healthcare => (20.0, 500.0),
461 TransactionCategory::AtmWithdrawal => (20.0, 500.0),
462 TransactionCategory::P2PPayment => (5.0, 200.0),
463 _ => (10.0, 200.0),
464 };
465
466 Decimal::from_f64_retain(self.rng.gen_range(min..max)).unwrap_or(Decimal::ZERO)
467 }
468
469 fn select_counterparty(&mut self, category: TransactionCategory) -> CounterpartyRef {
471 match category {
472 TransactionCategory::AtmWithdrawal => CounterpartyRef::atm("Branch ATM"),
473 TransactionCategory::P2PPayment => CounterpartyRef::peer("Friend", None),
474 _ => self
475 .counterparty_pool
476 .merchants
477 .choose(&mut self.rng)
478 .map(|m| CounterpartyRef::merchant(m.merchant_id, &m.name))
479 .unwrap_or_else(|| CounterpartyRef::unknown("Merchant")),
480 }
481 }
482
483 fn generate_reference(&self, category: TransactionCategory) -> String {
485 match category {
486 TransactionCategory::Groceries => "Grocery purchase",
487 TransactionCategory::Dining => "Restaurant",
488 TransactionCategory::Shopping => "Retail purchase",
489 TransactionCategory::Transportation => "Fuel purchase",
490 TransactionCategory::Entertainment => "Entertainment",
491 TransactionCategory::Healthcare => "Medical expense",
492 TransactionCategory::AtmWithdrawal => "ATM withdrawal",
493 TransactionCategory::P2PPayment => "P2P transfer",
494 _ => "Transaction",
495 }
496 .to_string()
497 }
498
499 fn random_timestamp(&mut self, date: NaiveDate) -> DateTime<Utc> {
501 let hour: u32 = self.rng.gen_range(8..22);
502 let minute: u32 = self.rng.gen_range(0..60);
503 let second: u32 = self.rng.gen_range(0..60);
504
505 date.and_hms_opt(hour, minute, second)
506 .map(|dt| DateTime::<Utc>::from_naive_utc_and_offset(dt, Utc))
507 .unwrap_or_else(Utc::now)
508 }
509}
510
511#[cfg(test)]
512mod tests {
513 use super::*;
514
515 #[test]
516 fn test_transaction_generation() {
517 let config = BankingConfig::small();
518 let mut customer_gen = crate::generators::CustomerGenerator::new(config.clone(), 12345);
519 let mut customers = customer_gen.generate_all();
520
521 let mut account_gen = crate::generators::AccountGenerator::new(config.clone(), 12345);
522 let mut accounts = account_gen.generate_for_customers(&mut customers);
523
524 let mut txn_gen = TransactionGenerator::new(config, 12345);
525 let transactions = txn_gen.generate_all(&customers, &mut accounts);
526
527 assert!(!transactions.is_empty());
528 }
529}