Skip to main content

datasynth_banking/generators/
transaction_generator.rs

1//! Transaction generator for banking data.
2
3use 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
19/// Generator for banking transactions.
20pub 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    /// Create a new transaction generator.
31    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    /// Set custom counterparty pool.
50    pub fn with_counterparty_pool(mut self, pool: CounterpartyPool) -> Self {
51        self.counterparty_pool = pool;
52        self
53    }
54
55    /// Generate transactions for all accounts.
56    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        // Create customer lookup
64        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        // Sort by timestamp
75        transactions.sort_by_key(|t| t.timestamp_initiated);
76
77        transactions
78    }
79
80    /// Generate transactions for a single account.
81    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            // Generate transactions for this day
93            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        // Update account balance
101        account.current_balance = balance;
102        account.available_balance = balance;
103
104        transactions
105    }
106
107    /// Generate transactions for a single day.
108    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        // Determine expected transaction count for this day
118        let expected_count = self.calculate_daily_transaction_count(customer, date);
119
120        // Generate income transactions (if applicable)
121        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        // Generate recurring payments (if applicable)
128        if self.should_generate_recurring(customer, date) {
129            transactions
130                .extend(self.generate_recurring_transactions(customer, account, date, balance));
131        }
132
133        // Generate discretionary transactions
134        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    /// Calculate expected daily transaction count.
147    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        // Weekend adjustment
161        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    /// Check if income should be generated today.
172    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, // Pension
178                    RetailPersona::GigWorker => self.rng.gen::<f64>() < 0.15, // Variable income
179                    _ => {
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, // Business income
186            _ => date.day() == 1,
187        }
188    }
189
190    /// Check if recurring payments should be generated today.
191    fn should_generate_recurring(&mut self, _customer: &BankingCustomer, date: NaiveDate) -> bool {
192        // Most recurring payments on 1st, 15th, or end of month
193        date.day() == 1 || date.day() == 15 || date.day() >= 28
194    }
195
196    /// Generate an income transaction.
197    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    /// Generate recurring payment transactions.
251    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        // Select random recurring payments for today
261        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                // Skip if insufficient balance
274                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    /// Generate a discretionary transaction.
307    fn generate_discretionary_transaction(
308        &mut self,
309        _customer: &BankingCustomer,
310        account: &BankAccount,
311        date: NaiveDate,
312        balance: &mut Decimal,
313    ) -> Option<BankTransaction> {
314        // Determine channel
315        let channel = self.select_channel();
316
317        // Determine category
318        let (category, mcc) = self.select_category(channel);
319
320        // Determine amount
321        let amount = self.generate_transaction_amount(category);
322
323        // Determine direction (mostly outbound for discretionary)
324        let direction = if self.rng.gen::<f64>() < 0.1 {
325            Direction::Inbound
326        } else {
327            Direction::Outbound
328        };
329
330        // Check balance for outbound
331        if direction == Direction::Outbound && *balance < amount {
332            return None;
333        }
334
335        // Select counterparty
336        let counterparty = self.select_counterparty(category);
337
338        // Update balance
339        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    /// Select transaction channel.
371    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    /// Select transaction category.
393    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    /// Generate transaction amount.
453    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    /// Select counterparty.
470    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    /// Generate transaction reference.
484    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    /// Generate random timestamp for a date.
500    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}