Skip to main content

datasynth_generators/
je_generator.rs

1//! Journal Entry generator with statistical distributions.
2
3use chrono::{Datelike, NaiveDate};
4use rand::prelude::*;
5use rand_chacha::ChaCha8Rng;
6use rust_decimal::prelude::*;
7use rust_decimal::Decimal;
8use std::sync::Arc;
9
10use datasynth_config::schema::{
11    FraudConfig, GeneratorConfig, TemplateConfig, TemporalPatternsConfig, TransactionConfig,
12};
13use datasynth_core::distributions::{
14    BusinessDayCalculator, CrossDayConfig, DriftAdjustments, DriftConfig, DriftController,
15    EventType, LagDistribution, PeriodEndConfig, PeriodEndDynamics, PeriodEndModel,
16    ProcessingLagCalculator, ProcessingLagConfig, *,
17};
18use datasynth_core::models::*;
19use datasynth_core::templates::{
20    descriptions::DescriptionContext, DescriptionGenerator, ReferenceGenerator, ReferenceType,
21};
22use datasynth_core::traits::Generator;
23use datasynth_core::uuid_factory::{DeterministicUuidFactory, GeneratorType};
24
25use crate::company_selector::WeightedCompanySelector;
26use crate::user_generator::{UserGenerator, UserGeneratorConfig};
27
28/// Generator for realistic journal entries.
29pub struct JournalEntryGenerator {
30    rng: ChaCha8Rng,
31    seed: u64,
32    config: TransactionConfig,
33    coa: Arc<ChartOfAccounts>,
34    companies: Vec<String>,
35    company_selector: WeightedCompanySelector,
36    line_sampler: LineItemSampler,
37    amount_sampler: AmountSampler,
38    temporal_sampler: TemporalSampler,
39    start_date: NaiveDate,
40    end_date: NaiveDate,
41    count: u64,
42    uuid_factory: DeterministicUuidFactory,
43    // Enhanced features
44    user_pool: Option<UserPool>,
45    description_generator: DescriptionGenerator,
46    reference_generator: ReferenceGenerator,
47    template_config: TemplateConfig,
48    vendor_pool: VendorPool,
49    customer_pool: CustomerPool,
50    // Material pool for realistic material references
51    material_pool: Option<MaterialPool>,
52    // Flag indicating whether we're using real master data vs defaults
53    using_real_master_data: bool,
54    // Fraud generation
55    fraud_config: FraudConfig,
56    // Persona-based error injection
57    persona_errors_enabled: bool,
58    // Approval threshold enforcement
59    approval_enabled: bool,
60    approval_threshold: rust_decimal::Decimal,
61    // Batching behavior - humans often process similar items together
62    batch_state: Option<BatchState>,
63    // Temporal drift controller for simulating distribution changes over time
64    drift_controller: Option<DriftController>,
65    // Temporal patterns components
66    business_day_calculator: Option<BusinessDayCalculator>,
67    processing_lag_calculator: Option<ProcessingLagCalculator>,
68    temporal_patterns_config: Option<TemporalPatternsConfig>,
69}
70
71/// State for tracking batch processing behavior.
72///
73/// When humans process transactions, they often batch similar items together
74/// (e.g., processing all invoices from one vendor, entering similar expenses).
75#[derive(Clone)]
76struct BatchState {
77    /// The base entry template to vary
78    base_vendor: Option<String>,
79    base_customer: Option<String>,
80    base_account_number: String,
81    base_amount: rust_decimal::Decimal,
82    base_business_process: Option<BusinessProcess>,
83    base_posting_date: NaiveDate,
84    /// Remaining entries in this batch
85    remaining: u8,
86}
87
88impl JournalEntryGenerator {
89    /// Create a new journal entry generator.
90    pub fn new_with_params(
91        config: TransactionConfig,
92        coa: Arc<ChartOfAccounts>,
93        companies: Vec<String>,
94        start_date: NaiveDate,
95        end_date: NaiveDate,
96        seed: u64,
97    ) -> Self {
98        Self::new_with_full_config(
99            config,
100            coa,
101            companies,
102            start_date,
103            end_date,
104            seed,
105            TemplateConfig::default(),
106            None,
107        )
108    }
109
110    /// Create a new journal entry generator with full configuration.
111    #[allow(clippy::too_many_arguments)]
112    pub fn new_with_full_config(
113        config: TransactionConfig,
114        coa: Arc<ChartOfAccounts>,
115        companies: Vec<String>,
116        start_date: NaiveDate,
117        end_date: NaiveDate,
118        seed: u64,
119        template_config: TemplateConfig,
120        user_pool: Option<UserPool>,
121    ) -> Self {
122        // Initialize user pool if not provided
123        let user_pool = user_pool.or_else(|| {
124            if template_config.names.generate_realistic_names {
125                let user_gen_config = UserGeneratorConfig {
126                    culture_distribution: vec![
127                        (
128                            datasynth_core::templates::NameCulture::WesternUs,
129                            template_config.names.culture_distribution.western_us,
130                        ),
131                        (
132                            datasynth_core::templates::NameCulture::Hispanic,
133                            template_config.names.culture_distribution.hispanic,
134                        ),
135                        (
136                            datasynth_core::templates::NameCulture::German,
137                            template_config.names.culture_distribution.german,
138                        ),
139                        (
140                            datasynth_core::templates::NameCulture::French,
141                            template_config.names.culture_distribution.french,
142                        ),
143                        (
144                            datasynth_core::templates::NameCulture::Chinese,
145                            template_config.names.culture_distribution.chinese,
146                        ),
147                        (
148                            datasynth_core::templates::NameCulture::Japanese,
149                            template_config.names.culture_distribution.japanese,
150                        ),
151                        (
152                            datasynth_core::templates::NameCulture::Indian,
153                            template_config.names.culture_distribution.indian,
154                        ),
155                    ],
156                    email_domain: template_config.names.email_domain.clone(),
157                    generate_realistic_names: true,
158                };
159                let mut user_gen = UserGenerator::with_config(seed + 100, user_gen_config);
160                Some(user_gen.generate_standard(&companies))
161            } else {
162                None
163            }
164        });
165
166        // Initialize reference generator
167        let mut ref_gen = ReferenceGenerator::new(
168            start_date.year(),
169            companies.first().map(|s| s.as_str()).unwrap_or("1000"),
170        );
171        ref_gen.set_prefix(
172            ReferenceType::Invoice,
173            &template_config.references.invoice_prefix,
174        );
175        ref_gen.set_prefix(
176            ReferenceType::PurchaseOrder,
177            &template_config.references.po_prefix,
178        );
179        ref_gen.set_prefix(
180            ReferenceType::SalesOrder,
181            &template_config.references.so_prefix,
182        );
183
184        // Create weighted company selector (uniform weights for this constructor)
185        let company_selector = WeightedCompanySelector::uniform(companies.clone());
186
187        Self {
188            rng: ChaCha8Rng::seed_from_u64(seed),
189            seed,
190            config: config.clone(),
191            coa,
192            companies,
193            company_selector,
194            line_sampler: LineItemSampler::with_config(
195                seed + 1,
196                config.line_item_distribution.clone(),
197                config.even_odd_distribution.clone(),
198                config.debit_credit_distribution.clone(),
199            ),
200            amount_sampler: AmountSampler::with_config(seed + 2, config.amounts.clone()),
201            temporal_sampler: TemporalSampler::with_config(
202                seed + 3,
203                config.seasonality.clone(),
204                WorkingHoursConfig::default(),
205                Vec::new(),
206            ),
207            start_date,
208            end_date,
209            count: 0,
210            uuid_factory: DeterministicUuidFactory::new(seed, GeneratorType::JournalEntry),
211            user_pool,
212            description_generator: DescriptionGenerator::new(),
213            reference_generator: ref_gen,
214            template_config,
215            vendor_pool: VendorPool::standard(),
216            customer_pool: CustomerPool::standard(),
217            material_pool: None,
218            using_real_master_data: false,
219            fraud_config: FraudConfig::default(),
220            persona_errors_enabled: true, // Enable by default for realism
221            approval_enabled: true,       // Enable by default for realism
222            approval_threshold: rust_decimal::Decimal::new(10000, 0), // $10,000 default threshold
223            batch_state: None,
224            drift_controller: None,
225            business_day_calculator: None,
226            processing_lag_calculator: None,
227            temporal_patterns_config: None,
228        }
229    }
230
231    /// Create from a full GeneratorConfig.
232    ///
233    /// This constructor uses the volume_weight from company configs
234    /// for weighted company selection, and fraud config from GeneratorConfig.
235    pub fn from_generator_config(
236        full_config: &GeneratorConfig,
237        coa: Arc<ChartOfAccounts>,
238        start_date: NaiveDate,
239        end_date: NaiveDate,
240        seed: u64,
241    ) -> Self {
242        let companies: Vec<String> = full_config
243            .companies
244            .iter()
245            .map(|c| c.code.clone())
246            .collect();
247
248        // Create weighted selector using volume_weight from company configs
249        let company_selector = WeightedCompanySelector::from_configs(&full_config.companies);
250
251        let mut generator = Self::new_with_full_config(
252            full_config.transactions.clone(),
253            coa,
254            companies,
255            start_date,
256            end_date,
257            seed,
258            full_config.templates.clone(),
259            None,
260        );
261
262        // Override the uniform selector with weighted selector
263        generator.company_selector = company_selector;
264
265        // Set fraud config
266        generator.fraud_config = full_config.fraud.clone();
267
268        // Configure temporal patterns if enabled
269        let temporal_config = &full_config.temporal_patterns;
270        if temporal_config.enabled {
271            generator = generator.with_temporal_patterns(temporal_config.clone(), seed);
272        }
273
274        generator
275    }
276
277    /// Configure temporal patterns including business day calculations and processing lags.
278    ///
279    /// This enables realistic temporal behavior including:
280    /// - Business day awareness (no postings on weekends/holidays)
281    /// - Processing lag modeling (event-to-posting delays)
282    /// - Period-end dynamics (volume spikes at month/quarter/year end)
283    pub fn with_temporal_patterns(mut self, config: TemporalPatternsConfig, seed: u64) -> Self {
284        // Create business day calculator if enabled
285        if config.business_days.enabled {
286            let region = config
287                .calendars
288                .regions
289                .first()
290                .map(|r| Self::parse_region(r))
291                .unwrap_or(Region::US);
292
293            let calendar = HolidayCalendar::new(region, self.start_date.year());
294            self.business_day_calculator = Some(BusinessDayCalculator::new(calendar));
295        }
296
297        // Create processing lag calculator if enabled
298        if config.processing_lags.enabled {
299            let lag_config = Self::convert_processing_lag_config(&config.processing_lags);
300            self.processing_lag_calculator =
301                Some(ProcessingLagCalculator::with_config(seed, lag_config));
302        }
303
304        // Create period-end dynamics if configured
305        let model = config.period_end.model.as_deref().unwrap_or("flat");
306        if model != "flat"
307            || config
308                .period_end
309                .month_end
310                .as_ref()
311                .is_some_and(|m| m.peak_multiplier.unwrap_or(1.0) != 1.0)
312        {
313            let dynamics = Self::convert_period_end_config(&config.period_end);
314            self.temporal_sampler.set_period_end_dynamics(dynamics);
315        }
316
317        self.temporal_patterns_config = Some(config);
318        self
319    }
320
321    /// Convert schema processing lag config to core config.
322    fn convert_processing_lag_config(
323        schema: &datasynth_config::schema::ProcessingLagSchemaConfig,
324    ) -> ProcessingLagConfig {
325        let mut config = ProcessingLagConfig {
326            enabled: schema.enabled,
327            ..Default::default()
328        };
329
330        // Helper to convert lag schema to distribution
331        let convert_lag = |lag: &datasynth_config::schema::LagDistributionSchemaConfig| {
332            let mut dist = LagDistribution::log_normal(lag.mu, lag.sigma);
333            if let Some(min) = lag.min_hours {
334                dist.min_lag_hours = min;
335            }
336            if let Some(max) = lag.max_hours {
337                dist.max_lag_hours = max;
338            }
339            dist
340        };
341
342        // Apply event-specific lags
343        if let Some(ref lag) = schema.sales_order_lag {
344            config
345                .event_lags
346                .insert(EventType::SalesOrder, convert_lag(lag));
347        }
348        if let Some(ref lag) = schema.purchase_order_lag {
349            config
350                .event_lags
351                .insert(EventType::PurchaseOrder, convert_lag(lag));
352        }
353        if let Some(ref lag) = schema.goods_receipt_lag {
354            config
355                .event_lags
356                .insert(EventType::GoodsReceipt, convert_lag(lag));
357        }
358        if let Some(ref lag) = schema.invoice_receipt_lag {
359            config
360                .event_lags
361                .insert(EventType::InvoiceReceipt, convert_lag(lag));
362        }
363        if let Some(ref lag) = schema.invoice_issue_lag {
364            config
365                .event_lags
366                .insert(EventType::InvoiceIssue, convert_lag(lag));
367        }
368        if let Some(ref lag) = schema.payment_lag {
369            config
370                .event_lags
371                .insert(EventType::Payment, convert_lag(lag));
372        }
373        if let Some(ref lag) = schema.journal_entry_lag {
374            config
375                .event_lags
376                .insert(EventType::JournalEntry, convert_lag(lag));
377        }
378
379        // Apply cross-day posting config
380        if let Some(ref cross_day) = schema.cross_day_posting {
381            config.cross_day = CrossDayConfig {
382                enabled: cross_day.enabled,
383                probability_by_hour: cross_day.probability_by_hour.clone(),
384                ..Default::default()
385            };
386        }
387
388        config
389    }
390
391    /// Convert schema period-end config to core PeriodEndDynamics.
392    fn convert_period_end_config(
393        schema: &datasynth_config::schema::PeriodEndSchemaConfig,
394    ) -> PeriodEndDynamics {
395        let model_type = schema.model.as_deref().unwrap_or("exponential");
396
397        // Helper to convert period config
398        let convert_period =
399            |period: Option<&datasynth_config::schema::PeriodEndModelSchemaConfig>,
400             default_peak: f64|
401             -> PeriodEndConfig {
402                if let Some(p) = period {
403                    let model = match model_type {
404                        "flat" => PeriodEndModel::FlatMultiplier {
405                            multiplier: p.peak_multiplier.unwrap_or(default_peak),
406                        },
407                        "extended_crunch" => PeriodEndModel::ExtendedCrunch {
408                            start_day: p.start_day.unwrap_or(-10),
409                            sustained_high_days: p.sustained_high_days.unwrap_or(3),
410                            peak_multiplier: p.peak_multiplier.unwrap_or(default_peak),
411                            ramp_up_days: 3, // Default ramp-up period
412                        },
413                        _ => PeriodEndModel::ExponentialAcceleration {
414                            start_day: p.start_day.unwrap_or(-10),
415                            base_multiplier: p.base_multiplier.unwrap_or(1.0),
416                            peak_multiplier: p.peak_multiplier.unwrap_or(default_peak),
417                            decay_rate: p.decay_rate.unwrap_or(0.3),
418                        },
419                    };
420                    PeriodEndConfig {
421                        enabled: true,
422                        model,
423                        additional_multiplier: p.additional_multiplier.unwrap_or(1.0),
424                    }
425                } else {
426                    PeriodEndConfig {
427                        enabled: true,
428                        model: PeriodEndModel::ExponentialAcceleration {
429                            start_day: -10,
430                            base_multiplier: 1.0,
431                            peak_multiplier: default_peak,
432                            decay_rate: 0.3,
433                        },
434                        additional_multiplier: 1.0,
435                    }
436                }
437            };
438
439        PeriodEndDynamics::new(
440            convert_period(schema.month_end.as_ref(), 2.0),
441            convert_period(schema.quarter_end.as_ref(), 3.5),
442            convert_period(schema.year_end.as_ref(), 5.0),
443        )
444    }
445
446    /// Parse a region string into a Region enum.
447    fn parse_region(region_str: &str) -> Region {
448        match region_str.to_uppercase().as_str() {
449            "US" => Region::US,
450            "DE" => Region::DE,
451            "GB" => Region::GB,
452            "CN" => Region::CN,
453            "JP" => Region::JP,
454            "IN" => Region::IN,
455            "BR" => Region::BR,
456            "MX" => Region::MX,
457            "AU" => Region::AU,
458            "SG" => Region::SG,
459            "KR" => Region::KR,
460            _ => Region::US,
461        }
462    }
463
464    /// Set a custom company selector.
465    pub fn set_company_selector(&mut self, selector: WeightedCompanySelector) {
466        self.company_selector = selector;
467    }
468
469    /// Get the current company selector.
470    pub fn company_selector(&self) -> &WeightedCompanySelector {
471        &self.company_selector
472    }
473
474    /// Set fraud configuration.
475    pub fn set_fraud_config(&mut self, config: FraudConfig) {
476        self.fraud_config = config;
477    }
478
479    /// Set vendors from generated master data.
480    ///
481    /// This replaces the default vendor pool with actual generated vendors,
482    /// ensuring JEs reference real master data entities.
483    pub fn with_vendors(mut self, vendors: &[Vendor]) -> Self {
484        if !vendors.is_empty() {
485            self.vendor_pool = VendorPool::from_vendors(vendors.to_vec());
486            self.using_real_master_data = true;
487        }
488        self
489    }
490
491    /// Set customers from generated master data.
492    ///
493    /// This replaces the default customer pool with actual generated customers,
494    /// ensuring JEs reference real master data entities.
495    pub fn with_customers(mut self, customers: &[Customer]) -> Self {
496        if !customers.is_empty() {
497            self.customer_pool = CustomerPool::from_customers(customers.to_vec());
498            self.using_real_master_data = true;
499        }
500        self
501    }
502
503    /// Set materials from generated master data.
504    ///
505    /// This provides material references for JEs that involve inventory movements.
506    pub fn with_materials(mut self, materials: &[Material]) -> Self {
507        if !materials.is_empty() {
508            self.material_pool = Some(MaterialPool::from_materials(materials.to_vec()));
509            self.using_real_master_data = true;
510        }
511        self
512    }
513
514    /// Set all master data at once for convenience.
515    ///
516    /// This is the recommended way to configure the JE generator with
517    /// generated master data to ensure data coherence.
518    pub fn with_master_data(
519        self,
520        vendors: &[Vendor],
521        customers: &[Customer],
522        materials: &[Material],
523    ) -> Self {
524        self.with_vendors(vendors)
525            .with_customers(customers)
526            .with_materials(materials)
527    }
528
529    /// Check if the generator is using real master data.
530    pub fn is_using_real_master_data(&self) -> bool {
531        self.using_real_master_data
532    }
533
534    /// Determine if this transaction should be fraudulent.
535    fn determine_fraud(&mut self) -> Option<FraudType> {
536        if !self.fraud_config.enabled {
537            return None;
538        }
539
540        // Roll for fraud based on fraud rate
541        if self.rng.gen::<f64>() >= self.fraud_config.fraud_rate {
542            return None;
543        }
544
545        // Select fraud type based on distribution
546        Some(self.select_fraud_type())
547    }
548
549    /// Select a fraud type based on the configured distribution.
550    fn select_fraud_type(&mut self) -> FraudType {
551        let dist = &self.fraud_config.fraud_type_distribution;
552        let roll: f64 = self.rng.gen();
553
554        let mut cumulative = 0.0;
555
556        cumulative += dist.suspense_account_abuse;
557        if roll < cumulative {
558            return FraudType::SuspenseAccountAbuse;
559        }
560
561        cumulative += dist.fictitious_transaction;
562        if roll < cumulative {
563            return FraudType::FictitiousTransaction;
564        }
565
566        cumulative += dist.revenue_manipulation;
567        if roll < cumulative {
568            return FraudType::RevenueManipulation;
569        }
570
571        cumulative += dist.expense_capitalization;
572        if roll < cumulative {
573            return FraudType::ExpenseCapitalization;
574        }
575
576        cumulative += dist.split_transaction;
577        if roll < cumulative {
578            return FraudType::SplitTransaction;
579        }
580
581        cumulative += dist.timing_anomaly;
582        if roll < cumulative {
583            return FraudType::TimingAnomaly;
584        }
585
586        cumulative += dist.unauthorized_access;
587        if roll < cumulative {
588            return FraudType::UnauthorizedAccess;
589        }
590
591        // Default fallback
592        FraudType::DuplicatePayment
593    }
594
595    /// Map a fraud type to an amount pattern for suspicious amounts.
596    fn fraud_type_to_amount_pattern(&self, fraud_type: FraudType) -> FraudAmountPattern {
597        match fraud_type {
598            FraudType::SplitTransaction | FraudType::JustBelowThreshold => {
599                FraudAmountPattern::ThresholdAdjacent
600            }
601            FraudType::FictitiousTransaction
602            | FraudType::FictitiousEntry
603            | FraudType::SuspenseAccountAbuse
604            | FraudType::RoundDollarManipulation => FraudAmountPattern::ObviousRoundNumbers,
605            FraudType::RevenueManipulation
606            | FraudType::ExpenseCapitalization
607            | FraudType::ImproperCapitalization
608            | FraudType::ReserveManipulation
609            | FraudType::UnauthorizedAccess
610            | FraudType::PrematureRevenue
611            | FraudType::UnderstatedLiabilities
612            | FraudType::OverstatedAssets
613            | FraudType::ChannelStuffing => FraudAmountPattern::StatisticallyImprobable,
614            FraudType::DuplicatePayment
615            | FraudType::TimingAnomaly
616            | FraudType::SelfApproval
617            | FraudType::ExceededApprovalLimit
618            | FraudType::SegregationOfDutiesViolation
619            | FraudType::UnauthorizedApproval
620            | FraudType::CollusiveApproval
621            | FraudType::FictitiousVendor
622            | FraudType::ShellCompanyPayment
623            | FraudType::Kickback
624            | FraudType::KickbackScheme
625            | FraudType::InvoiceManipulation
626            | FraudType::AssetMisappropriation
627            | FraudType::InventoryTheft
628            | FraudType::GhostEmployee => FraudAmountPattern::Normal,
629            // Accounting Standards Fraud Types (ASC 606/IFRS 15 - Revenue)
630            FraudType::ImproperRevenueRecognition
631            | FraudType::ImproperPoAllocation
632            | FraudType::VariableConsiderationManipulation
633            | FraudType::ContractModificationMisstatement => {
634                FraudAmountPattern::StatisticallyImprobable
635            }
636            // Accounting Standards Fraud Types (ASC 842/IFRS 16 - Leases)
637            FraudType::LeaseClassificationManipulation
638            | FraudType::OffBalanceSheetLease
639            | FraudType::LeaseLiabilityUnderstatement
640            | FraudType::RouAssetMisstatement => FraudAmountPattern::StatisticallyImprobable,
641            // Accounting Standards Fraud Types (ASC 820/IFRS 13 - Fair Value)
642            FraudType::FairValueHierarchyManipulation
643            | FraudType::Level3InputManipulation
644            | FraudType::ValuationTechniqueManipulation => {
645                FraudAmountPattern::StatisticallyImprobable
646            }
647            // Accounting Standards Fraud Types (ASC 360/IAS 36 - Impairment)
648            FraudType::DelayedImpairment
649            | FraudType::ImpairmentTestAvoidance
650            | FraudType::CashFlowProjectionManipulation
651            | FraudType::ImproperImpairmentReversal => FraudAmountPattern::StatisticallyImprobable,
652        }
653    }
654
655    /// Generate a deterministic UUID using the factory.
656    fn generate_deterministic_uuid(&self) -> uuid::Uuid {
657        self.uuid_factory.next()
658    }
659
660    /// Generate a single journal entry.
661    pub fn generate(&mut self) -> JournalEntry {
662        // Check if we're in a batch - if so, generate a batched entry
663        if let Some(ref state) = self.batch_state {
664            if state.remaining > 0 {
665                return self.generate_batched_entry();
666            }
667        }
668
669        self.count += 1;
670
671        // Generate deterministic document ID
672        let document_id = self.generate_deterministic_uuid();
673
674        // Sample posting date
675        let mut posting_date = self
676            .temporal_sampler
677            .sample_date(self.start_date, self.end_date);
678
679        // Adjust posting date to be a business day if business day calculator is configured
680        if let Some(ref calc) = self.business_day_calculator {
681            if !calc.is_business_day(posting_date) {
682                // Move to next business day
683                posting_date = calc.next_business_day(posting_date, false);
684                // Ensure we don't exceed end_date
685                if posting_date > self.end_date {
686                    posting_date = calc.prev_business_day(self.end_date, true);
687                }
688            }
689        }
690
691        // Select company using weighted selector
692        let company_code = self.company_selector.select(&mut self.rng).to_string();
693
694        // Sample line item specification
695        let line_spec = self.line_sampler.sample();
696
697        // Determine source type using full 4-way distribution
698        let source = self.select_source();
699        let is_automated = matches!(
700            source,
701            TransactionSource::Automated | TransactionSource::Recurring
702        );
703
704        // Select business process
705        let business_process = self.select_business_process();
706
707        // Determine if this is a fraudulent transaction
708        let fraud_type = self.determine_fraud();
709        let is_fraud = fraud_type.is_some();
710
711        // Sample time based on source
712        let time = self.temporal_sampler.sample_time(!is_automated);
713        let created_at = posting_date.and_time(time).and_utc();
714
715        // Select user from pool or generate generic
716        let (created_by, user_persona) = self.select_user(is_automated);
717
718        // Create header with deterministic UUID
719        let mut header =
720            JournalEntryHeader::with_deterministic_id(company_code, posting_date, document_id);
721        header.created_at = created_at;
722        header.source = source;
723        header.created_by = created_by;
724        header.user_persona = user_persona;
725        header.business_process = Some(business_process);
726        header.is_fraud = is_fraud;
727        header.fraud_type = fraud_type;
728
729        // Generate description context
730        let mut context =
731            DescriptionContext::with_period(posting_date.month(), posting_date.year());
732
733        // Add vendor/customer context based on business process
734        match business_process {
735            BusinessProcess::P2P => {
736                if let Some(vendor) = self.vendor_pool.random_vendor(&mut self.rng) {
737                    context.vendor_name = Some(vendor.name.clone());
738                }
739            }
740            BusinessProcess::O2C => {
741                if let Some(customer) = self.customer_pool.random_customer(&mut self.rng) {
742                    context.customer_name = Some(customer.name.clone());
743                }
744            }
745            _ => {}
746        }
747
748        // Generate header text if enabled
749        if self.template_config.descriptions.generate_header_text {
750            header.header_text = Some(self.description_generator.generate_header_text(
751                business_process,
752                &context,
753                &mut self.rng,
754            ));
755        }
756
757        // Generate reference if enabled
758        if self.template_config.references.generate_references {
759            header.reference = Some(
760                self.reference_generator
761                    .generate_for_process_year(business_process, posting_date.year()),
762            );
763        }
764
765        // Generate line items
766        let mut entry = JournalEntry::new(header);
767
768        // Generate amount - use fraud pattern if this is a fraudulent transaction
769        let base_amount = if let Some(ft) = fraud_type {
770            let pattern = self.fraud_type_to_amount_pattern(ft);
771            self.amount_sampler.sample_fraud(pattern)
772        } else {
773            self.amount_sampler.sample()
774        };
775
776        // Apply temporal drift if configured
777        let drift_adjusted_amount = {
778            let drift = self.get_drift_adjustments(posting_date);
779            if drift.amount_mean_multiplier != 1.0 {
780                // Apply drift multiplier (includes seasonal factor if enabled)
781                let multiplier = drift.amount_mean_multiplier * drift.seasonal_factor;
782                let adjusted = base_amount.to_f64().unwrap_or(1.0) * multiplier;
783                Decimal::from_f64_retain(adjusted).unwrap_or(base_amount)
784            } else {
785                base_amount
786            }
787        };
788
789        // Apply human variation to amounts for non-automated transactions
790        let total_amount = if is_automated {
791            drift_adjusted_amount // Automated systems use exact amounts
792        } else {
793            self.apply_human_variation(drift_adjusted_amount)
794        };
795
796        // Generate debit lines
797        let debit_amounts = self
798            .amount_sampler
799            .sample_summing_to(line_spec.debit_count, total_amount);
800        for (i, amount) in debit_amounts.into_iter().enumerate() {
801            let account_number = self.select_debit_account().account_number.clone();
802            let mut line = JournalEntryLine::debit(
803                entry.header.document_id,
804                (i + 1) as u32,
805                account_number.clone(),
806                amount,
807            );
808
809            // Generate line text if enabled
810            if self.template_config.descriptions.generate_line_text {
811                line.line_text = Some(self.description_generator.generate_line_text(
812                    &account_number,
813                    &context,
814                    &mut self.rng,
815                ));
816            }
817
818            entry.add_line(line);
819        }
820
821        // Generate credit lines - use the SAME amounts to ensure balance
822        let credit_amounts = self
823            .amount_sampler
824            .sample_summing_to(line_spec.credit_count, total_amount);
825        for (i, amount) in credit_amounts.into_iter().enumerate() {
826            let account_number = self.select_credit_account().account_number.clone();
827            let mut line = JournalEntryLine::credit(
828                entry.header.document_id,
829                (line_spec.debit_count + i + 1) as u32,
830                account_number.clone(),
831                amount,
832            );
833
834            // Generate line text if enabled
835            if self.template_config.descriptions.generate_line_text {
836                line.line_text = Some(self.description_generator.generate_line_text(
837                    &account_number,
838                    &context,
839                    &mut self.rng,
840                ));
841            }
842
843            entry.add_line(line);
844        }
845
846        // Apply persona-based errors if enabled and it's a human user
847        if self.persona_errors_enabled && !is_automated {
848            self.maybe_inject_persona_error(&mut entry);
849        }
850
851        // Apply approval workflow if enabled and amount exceeds threshold
852        if self.approval_enabled {
853            self.maybe_apply_approval_workflow(&mut entry, posting_date);
854        }
855
856        // Maybe start a batch of similar entries for realism
857        self.maybe_start_batch(&entry);
858
859        entry
860    }
861
862    /// Enable or disable persona-based error injection.
863    ///
864    /// When enabled, entries created by human personas have a chance
865    /// to contain realistic human errors based on their experience level.
866    pub fn with_persona_errors(mut self, enabled: bool) -> Self {
867        self.persona_errors_enabled = enabled;
868        self
869    }
870
871    /// Set fraud configuration for fraud injection.
872    ///
873    /// When fraud is enabled in the config, transactions have a chance
874    /// to be marked as fraudulent based on the configured fraud rate.
875    pub fn with_fraud_config(mut self, config: FraudConfig) -> Self {
876        self.fraud_config = config;
877        self
878    }
879
880    /// Check if persona errors are enabled.
881    pub fn persona_errors_enabled(&self) -> bool {
882        self.persona_errors_enabled
883    }
884
885    /// Enable or disable batch processing behavior.
886    ///
887    /// When enabled (default), the generator will occasionally produce batches
888    /// of similar entries, simulating how humans batch similar work together.
889    pub fn with_batching(mut self, enabled: bool) -> Self {
890        if !enabled {
891            self.batch_state = None;
892        }
893        self
894    }
895
896    /// Check if batch processing is enabled.
897    pub fn batching_enabled(&self) -> bool {
898        // Batching is implicitly enabled when not explicitly disabled
899        true
900    }
901
902    /// Maybe start a batch based on the current entry.
903    ///
904    /// Humans often batch similar work: processing invoices from one vendor,
905    /// entering expense reports for a trip, reconciling similar items.
906    fn maybe_start_batch(&mut self, entry: &JournalEntry) {
907        // Only start batch for non-automated, non-fraud entries
908        if entry.header.source == TransactionSource::Automated || entry.header.is_fraud {
909            return;
910        }
911
912        // 15% chance to start a batch (most work is not batched)
913        if self.rng.gen::<f64>() > 0.15 {
914            return;
915        }
916
917        // Extract key attributes for batching
918        let base_account = entry
919            .lines
920            .first()
921            .map(|l| l.gl_account.clone())
922            .unwrap_or_default();
923
924        let base_amount = entry.total_debit();
925
926        self.batch_state = Some(BatchState {
927            base_vendor: None, // Would need vendor from context
928            base_customer: None,
929            base_account_number: base_account,
930            base_amount,
931            base_business_process: entry.header.business_process,
932            base_posting_date: entry.header.posting_date,
933            remaining: self.rng.gen_range(2..7), // 2-6 more similar entries
934        });
935    }
936
937    /// Generate an entry that's part of the current batch.
938    ///
939    /// Batched entries have:
940    /// - Same or very similar business process
941    /// - Same posting date (batched work done together)
942    /// - Similar amounts (within ±15%)
943    /// - Same debit account (processing similar items)
944    fn generate_batched_entry(&mut self) -> JournalEntry {
945        use rust_decimal::Decimal;
946
947        // Decrement batch counter
948        if let Some(ref mut state) = self.batch_state {
949            state.remaining = state.remaining.saturating_sub(1);
950        }
951
952        let batch = self.batch_state.clone().unwrap();
953
954        // Use the batch's posting date (work done on same day)
955        let posting_date = batch.base_posting_date;
956
957        self.count += 1;
958        let document_id = self.generate_deterministic_uuid();
959
960        // Select same company (batched work is usually same company)
961        let company_code = self.company_selector.select(&mut self.rng).to_string();
962
963        // Use simplified line spec for batched entries (usually 2-line)
964        let _line_spec = LineItemSpec {
965            total_count: 2,
966            debit_count: 1,
967            credit_count: 1,
968            split_type: DebitCreditSplit::Equal,
969        };
970
971        // Batched entries are always manual
972        let source = TransactionSource::Manual;
973
974        // Use the batch's business process
975        let business_process = batch.base_business_process.unwrap_or(BusinessProcess::R2R);
976
977        // Sample time
978        let time = self.temporal_sampler.sample_time(true);
979        let created_at = posting_date.and_time(time).and_utc();
980
981        // Same user for batched work
982        let (created_by, user_persona) = self.select_user(false);
983
984        // Create header
985        let mut header =
986            JournalEntryHeader::with_deterministic_id(company_code, posting_date, document_id);
987        header.created_at = created_at;
988        header.source = source;
989        header.created_by = created_by;
990        header.user_persona = user_persona;
991        header.business_process = Some(business_process);
992
993        // Generate similar amount (within ±15% of base)
994        let variation = self.rng.gen_range(-0.15..0.15);
995        let varied_amount =
996            batch.base_amount * (Decimal::ONE + Decimal::try_from(variation).unwrap_or_default());
997        let total_amount = varied_amount.round_dp(2).max(Decimal::from(1));
998
999        // Create the entry
1000        let mut entry = JournalEntry::new(header);
1001
1002        // Use same debit account as batch base
1003        let debit_line = JournalEntryLine::debit(
1004            entry.header.document_id,
1005            1,
1006            batch.base_account_number.clone(),
1007            total_amount,
1008        );
1009        entry.add_line(debit_line);
1010
1011        // Select a credit account
1012        let credit_account = self.select_credit_account().account_number.clone();
1013        let credit_line =
1014            JournalEntryLine::credit(entry.header.document_id, 2, credit_account, total_amount);
1015        entry.add_line(credit_line);
1016
1017        // Apply persona-based errors if enabled
1018        if self.persona_errors_enabled {
1019            self.maybe_inject_persona_error(&mut entry);
1020        }
1021
1022        // Apply approval workflow if enabled
1023        if self.approval_enabled {
1024            self.maybe_apply_approval_workflow(&mut entry, posting_date);
1025        }
1026
1027        // Clear batch state if no more entries remaining
1028        if batch.remaining <= 1 {
1029            self.batch_state = None;
1030        }
1031
1032        entry
1033    }
1034
1035    /// Maybe inject a persona-appropriate error based on the persona's error rate.
1036    fn maybe_inject_persona_error(&mut self, entry: &mut JournalEntry) {
1037        // Parse persona from the entry header
1038        let persona_str = &entry.header.user_persona;
1039        let persona = match persona_str.to_lowercase().as_str() {
1040            s if s.contains("junior") => UserPersona::JuniorAccountant,
1041            s if s.contains("senior") => UserPersona::SeniorAccountant,
1042            s if s.contains("controller") => UserPersona::Controller,
1043            s if s.contains("manager") => UserPersona::Manager,
1044            s if s.contains("executive") => UserPersona::Executive,
1045            _ => return, // Don't inject errors for unknown personas
1046        };
1047
1048        // Get base error rate from persona
1049        let base_error_rate = persona.error_rate();
1050
1051        // Apply stress factors based on posting date
1052        let adjusted_rate = self.apply_stress_factors(base_error_rate, entry.header.posting_date);
1053
1054        // Check if error should occur based on adjusted rate
1055        if self.rng.gen::<f64>() >= adjusted_rate {
1056            return; // No error this time
1057        }
1058
1059        // Select and inject persona-appropriate error
1060        self.inject_human_error(entry, persona);
1061    }
1062
1063    /// Apply contextual stress factors to the base error rate.
1064    ///
1065    /// Stress factors increase error likelihood during:
1066    /// - Month-end (day >= 28): 1.5x more errors due to deadline pressure
1067    /// - Quarter-end (Mar, Jun, Sep, Dec): additional 25% boost
1068    /// - Year-end (December 28-31): 2.0x more errors due to audit pressure
1069    /// - Monday morning (catch-up work): 20% more errors
1070    /// - Friday afternoon (rushing to leave): 30% more errors
1071    fn apply_stress_factors(&self, base_rate: f64, posting_date: chrono::NaiveDate) -> f64 {
1072        use chrono::Datelike;
1073
1074        let mut rate = base_rate;
1075        let day = posting_date.day();
1076        let month = posting_date.month();
1077
1078        // Year-end stress (December 28-31): double the error rate
1079        if month == 12 && day >= 28 {
1080            rate *= 2.0;
1081            return rate.min(0.5); // Cap at 50% to keep it realistic
1082        }
1083
1084        // Quarter-end stress (last days of Mar, Jun, Sep, Dec)
1085        if matches!(month, 3 | 6 | 9 | 12) && day >= 28 {
1086            rate *= 1.75; // 75% more errors at quarter end
1087            return rate.min(0.4);
1088        }
1089
1090        // Month-end stress (last 3 days of month)
1091        if day >= 28 {
1092            rate *= 1.5; // 50% more errors at month end
1093        }
1094
1095        // Day-of-week stress effects
1096        let weekday = posting_date.weekday();
1097        match weekday {
1098            chrono::Weekday::Mon => {
1099                // Monday: catching up, often rushed
1100                rate *= 1.2;
1101            }
1102            chrono::Weekday::Fri => {
1103                // Friday: rushing to finish before weekend
1104                rate *= 1.3;
1105            }
1106            _ => {}
1107        }
1108
1109        // Cap at 40% to keep it realistic
1110        rate.min(0.4)
1111    }
1112
1113    /// Apply human-like variation to an amount.
1114    ///
1115    /// Humans don't enter perfectly calculated amounts - they:
1116    /// - Round amounts differently
1117    /// - Estimate instead of calculating exactly
1118    /// - Make small input variations
1119    ///
1120    /// This applies small variations (typically ±2%) to make amounts more realistic.
1121    fn apply_human_variation(&mut self, amount: rust_decimal::Decimal) -> rust_decimal::Decimal {
1122        use rust_decimal::Decimal;
1123
1124        // Automated transactions or very small amounts don't get variation
1125        if amount < Decimal::from(10) {
1126            return amount;
1127        }
1128
1129        // 70% chance of human variation being applied
1130        if self.rng.gen::<f64>() > 0.70 {
1131            return amount;
1132        }
1133
1134        // Decide which type of human variation to apply
1135        let variation_type: u8 = self.rng.gen_range(0..4);
1136
1137        match variation_type {
1138            0 => {
1139                // ±2% variation (common for estimated amounts)
1140                let variation_pct = self.rng.gen_range(-0.02..0.02);
1141                let variation = amount * Decimal::try_from(variation_pct).unwrap_or_default();
1142                (amount + variation).round_dp(2)
1143            }
1144            1 => {
1145                // Round to nearest $10
1146                let ten = Decimal::from(10);
1147                (amount / ten).round() * ten
1148            }
1149            2 => {
1150                // Round to nearest $100 (for larger amounts)
1151                if amount >= Decimal::from(500) {
1152                    let hundred = Decimal::from(100);
1153                    (amount / hundred).round() * hundred
1154                } else {
1155                    amount
1156                }
1157            }
1158            3 => {
1159                // Slight under/over payment (±$0.01 to ±$1.00)
1160                let cents = Decimal::new(self.rng.gen_range(-100..100), 2);
1161                (amount + cents).max(Decimal::ZERO).round_dp(2)
1162            }
1163            _ => amount,
1164        }
1165    }
1166
1167    /// Rebalance an entry after a one-sided amount modification.
1168    ///
1169    /// When an error modifies one line's amount, this finds a line on the opposite
1170    /// side (credit if modified was debit, or vice versa) and adjusts it by the
1171    /// same impact to maintain balance.
1172    fn rebalance_entry(entry: &mut JournalEntry, modified_was_debit: bool, impact: Decimal) {
1173        // Find a line on the opposite side to adjust
1174        let balancing_idx = entry.lines.iter().position(|l| {
1175            if modified_was_debit {
1176                l.credit_amount > Decimal::ZERO
1177            } else {
1178                l.debit_amount > Decimal::ZERO
1179            }
1180        });
1181
1182        if let Some(idx) = balancing_idx {
1183            if modified_was_debit {
1184                entry.lines[idx].credit_amount += impact;
1185            } else {
1186                entry.lines[idx].debit_amount += impact;
1187            }
1188        }
1189    }
1190
1191    /// Inject a human-like error based on the persona.
1192    ///
1193    /// All error types maintain balance - amount modifications are applied to both sides.
1194    /// Entries are marked with [HUMAN_ERROR:*] tags in header_text for ML detection.
1195    fn inject_human_error(&mut self, entry: &mut JournalEntry, persona: UserPersona) {
1196        use rust_decimal::Decimal;
1197
1198        // Different personas make different types of errors
1199        let error_type: u8 = match persona {
1200            UserPersona::JuniorAccountant => {
1201                // Junior accountants make more varied errors
1202                self.rng.gen_range(0..5)
1203            }
1204            UserPersona::SeniorAccountant => {
1205                // Senior accountants mainly make transposition errors
1206                self.rng.gen_range(0..3)
1207            }
1208            UserPersona::Controller | UserPersona::Manager => {
1209                // Controllers/managers mainly make rounding or cutoff errors
1210                self.rng.gen_range(3..5)
1211            }
1212            _ => return,
1213        };
1214
1215        match error_type {
1216            0 => {
1217                // Transposed digits in an amount
1218                if let Some(line) = entry.lines.get_mut(0) {
1219                    let is_debit = line.debit_amount > Decimal::ZERO;
1220                    let original_amount = if is_debit {
1221                        line.debit_amount
1222                    } else {
1223                        line.credit_amount
1224                    };
1225
1226                    // Simple digit swap in the string representation
1227                    let s = original_amount.to_string();
1228                    if s.len() >= 2 {
1229                        let chars: Vec<char> = s.chars().collect();
1230                        let pos = self.rng.gen_range(0..chars.len().saturating_sub(1));
1231                        if chars[pos].is_ascii_digit()
1232                            && chars.get(pos + 1).is_some_and(|c| c.is_ascii_digit())
1233                        {
1234                            let mut new_chars = chars;
1235                            new_chars.swap(pos, pos + 1);
1236                            if let Ok(new_amount) =
1237                                new_chars.into_iter().collect::<String>().parse::<Decimal>()
1238                            {
1239                                let impact = new_amount - original_amount;
1240
1241                                // Apply to the modified line
1242                                if is_debit {
1243                                    entry.lines[0].debit_amount = new_amount;
1244                                } else {
1245                                    entry.lines[0].credit_amount = new_amount;
1246                                }
1247
1248                                // Rebalance the entry
1249                                Self::rebalance_entry(entry, is_debit, impact);
1250
1251                                entry.header.header_text = Some(
1252                                    entry.header.header_text.clone().unwrap_or_default()
1253                                        + " [HUMAN_ERROR:TRANSPOSITION]",
1254                                );
1255                            }
1256                        }
1257                    }
1258                }
1259            }
1260            1 => {
1261                // Wrong decimal place (off by factor of 10)
1262                if let Some(line) = entry.lines.get_mut(0) {
1263                    let is_debit = line.debit_amount > Decimal::ZERO;
1264                    let original_amount = if is_debit {
1265                        line.debit_amount
1266                    } else {
1267                        line.credit_amount
1268                    };
1269
1270                    let new_amount = original_amount * Decimal::new(10, 0);
1271                    let impact = new_amount - original_amount;
1272
1273                    // Apply to the modified line
1274                    if is_debit {
1275                        entry.lines[0].debit_amount = new_amount;
1276                    } else {
1277                        entry.lines[0].credit_amount = new_amount;
1278                    }
1279
1280                    // Rebalance the entry
1281                    Self::rebalance_entry(entry, is_debit, impact);
1282
1283                    entry.header.header_text = Some(
1284                        entry.header.header_text.clone().unwrap_or_default()
1285                            + " [HUMAN_ERROR:DECIMAL_SHIFT]",
1286                    );
1287                }
1288            }
1289            2 => {
1290                // Typo in description (doesn't affect balance)
1291                if let Some(ref mut text) = entry.header.header_text {
1292                    let typos = ["teh", "adn", "wiht", "taht", "recieve"];
1293                    let correct = ["the", "and", "with", "that", "receive"];
1294                    let idx = self.rng.gen_range(0..typos.len());
1295                    if text.to_lowercase().contains(correct[idx]) {
1296                        *text = text.replace(correct[idx], typos[idx]);
1297                        *text = format!("{} [HUMAN_ERROR:TYPO]", text);
1298                    }
1299                }
1300            }
1301            3 => {
1302                // Rounding to round number
1303                if let Some(line) = entry.lines.get_mut(0) {
1304                    let is_debit = line.debit_amount > Decimal::ZERO;
1305                    let original_amount = if is_debit {
1306                        line.debit_amount
1307                    } else {
1308                        line.credit_amount
1309                    };
1310
1311                    let new_amount =
1312                        (original_amount / Decimal::new(100, 0)).round() * Decimal::new(100, 0);
1313                    let impact = new_amount - original_amount;
1314
1315                    // Apply to the modified line
1316                    if is_debit {
1317                        entry.lines[0].debit_amount = new_amount;
1318                    } else {
1319                        entry.lines[0].credit_amount = new_amount;
1320                    }
1321
1322                    // Rebalance the entry
1323                    Self::rebalance_entry(entry, is_debit, impact);
1324
1325                    entry.header.header_text = Some(
1326                        entry.header.header_text.clone().unwrap_or_default()
1327                            + " [HUMAN_ERROR:ROUNDED]",
1328                    );
1329                }
1330            }
1331            4 => {
1332                // Late posting marker (document date much earlier than posting date)
1333                // This doesn't create an imbalance
1334                if entry.header.document_date == entry.header.posting_date {
1335                    let days_late = self.rng.gen_range(5..15);
1336                    entry.header.document_date =
1337                        entry.header.posting_date - chrono::Duration::days(days_late);
1338                    entry.header.header_text = Some(
1339                        entry.header.header_text.clone().unwrap_or_default()
1340                            + " [HUMAN_ERROR:LATE_POSTING]",
1341                    );
1342                }
1343            }
1344            _ => {}
1345        }
1346    }
1347
1348    /// Apply approval workflow for high-value transactions.
1349    ///
1350    /// If the entry amount exceeds the approval threshold, simulate an
1351    /// approval workflow with appropriate approvers based on amount.
1352    fn maybe_apply_approval_workflow(
1353        &mut self,
1354        entry: &mut JournalEntry,
1355        _posting_date: NaiveDate,
1356    ) {
1357        use rust_decimal::Decimal;
1358
1359        let amount = entry.total_debit();
1360
1361        // Skip if amount is below threshold
1362        if amount <= self.approval_threshold {
1363            // Auto-approved below threshold
1364            let workflow = ApprovalWorkflow::auto_approved(
1365                entry.header.created_by.clone(),
1366                entry.header.user_persona.clone(),
1367                amount,
1368                entry.header.created_at,
1369            );
1370            entry.header.approval_workflow = Some(workflow);
1371            return;
1372        }
1373
1374        // Mark as SOX relevant for high-value transactions
1375        entry.header.sox_relevant = true;
1376
1377        // Determine required approval levels based on amount
1378        let required_levels = if amount > Decimal::new(100000, 0) {
1379            3 // Executive approval required
1380        } else if amount > Decimal::new(50000, 0) {
1381            2 // Senior management approval
1382        } else {
1383            1 // Manager approval
1384        };
1385
1386        // Create the approval workflow
1387        let mut workflow = ApprovalWorkflow::new(
1388            entry.header.created_by.clone(),
1389            entry.header.user_persona.clone(),
1390            amount,
1391        );
1392        workflow.required_levels = required_levels;
1393
1394        // Simulate submission
1395        let submit_time = entry.header.created_at;
1396        let submit_action = ApprovalAction::new(
1397            entry.header.created_by.clone(),
1398            entry.header.user_persona.clone(),
1399            self.parse_persona(&entry.header.user_persona),
1400            ApprovalActionType::Submit,
1401            0,
1402        )
1403        .with_timestamp(submit_time);
1404
1405        workflow.actions.push(submit_action);
1406        workflow.status = ApprovalStatus::Pending;
1407        workflow.submitted_at = Some(submit_time);
1408
1409        // Simulate approvals with realistic delays
1410        let mut current_time = submit_time;
1411        for level in 1..=required_levels {
1412            // Add delay for approval (1-3 business hours per level)
1413            let delay_hours = self.rng.gen_range(1..4);
1414            current_time += chrono::Duration::hours(delay_hours);
1415
1416            // Skip weekends
1417            while current_time.weekday() == chrono::Weekday::Sat
1418                || current_time.weekday() == chrono::Weekday::Sun
1419            {
1420                current_time += chrono::Duration::days(1);
1421            }
1422
1423            // Generate approver based on level
1424            let (approver_id, approver_role) = self.select_approver(level);
1425
1426            let approve_action = ApprovalAction::new(
1427                approver_id.clone(),
1428                format!("{:?}", approver_role),
1429                approver_role,
1430                ApprovalActionType::Approve,
1431                level,
1432            )
1433            .with_timestamp(current_time);
1434
1435            workflow.actions.push(approve_action);
1436            workflow.current_level = level;
1437        }
1438
1439        // Mark as approved
1440        workflow.status = ApprovalStatus::Approved;
1441        workflow.approved_at = Some(current_time);
1442
1443        entry.header.approval_workflow = Some(workflow);
1444    }
1445
1446    /// Select an approver based on the required level.
1447    fn select_approver(&mut self, level: u8) -> (String, UserPersona) {
1448        let persona = match level {
1449            1 => UserPersona::Manager,
1450            2 => UserPersona::Controller,
1451            _ => UserPersona::Executive,
1452        };
1453
1454        // Try to get from user pool first
1455        if let Some(ref pool) = self.user_pool {
1456            if let Some(user) = pool.get_random_user(persona, &mut self.rng) {
1457                return (user.user_id.clone(), persona);
1458            }
1459        }
1460
1461        // Fallback to generated approver
1462        let approver_id = match persona {
1463            UserPersona::Manager => format!("MGR{:04}", self.rng.gen_range(1..100)),
1464            UserPersona::Controller => format!("CTRL{:04}", self.rng.gen_range(1..20)),
1465            UserPersona::Executive => format!("EXEC{:04}", self.rng.gen_range(1..10)),
1466            _ => format!("USR{:04}", self.rng.gen_range(1..1000)),
1467        };
1468
1469        (approver_id, persona)
1470    }
1471
1472    /// Parse user persona from string.
1473    fn parse_persona(&self, persona_str: &str) -> UserPersona {
1474        match persona_str.to_lowercase().as_str() {
1475            s if s.contains("junior") => UserPersona::JuniorAccountant,
1476            s if s.contains("senior") => UserPersona::SeniorAccountant,
1477            s if s.contains("controller") => UserPersona::Controller,
1478            s if s.contains("manager") => UserPersona::Manager,
1479            s if s.contains("executive") => UserPersona::Executive,
1480            s if s.contains("automated") || s.contains("system") => UserPersona::AutomatedSystem,
1481            _ => UserPersona::JuniorAccountant, // Default
1482        }
1483    }
1484
1485    /// Enable or disable approval workflow.
1486    pub fn with_approval(mut self, enabled: bool) -> Self {
1487        self.approval_enabled = enabled;
1488        self
1489    }
1490
1491    /// Set the approval threshold amount.
1492    pub fn with_approval_threshold(mut self, threshold: rust_decimal::Decimal) -> Self {
1493        self.approval_threshold = threshold;
1494        self
1495    }
1496
1497    /// Set the temporal drift controller for simulating distribution changes over time.
1498    ///
1499    /// When drift is enabled, amounts and other distributions will shift based on
1500    /// the period (month) to simulate realistic temporal evolution like inflation
1501    /// or increasing fraud rates.
1502    pub fn with_drift_controller(mut self, controller: DriftController) -> Self {
1503        self.drift_controller = Some(controller);
1504        self
1505    }
1506
1507    /// Set drift configuration directly.
1508    ///
1509    /// Creates a drift controller from the config. Total periods is calculated
1510    /// from the date range.
1511    pub fn with_drift_config(mut self, config: DriftConfig, seed: u64) -> Self {
1512        if config.enabled {
1513            let total_periods = self.calculate_total_periods();
1514            self.drift_controller = Some(DriftController::new(config, seed, total_periods));
1515        }
1516        self
1517    }
1518
1519    /// Calculate total periods (months) in the date range.
1520    fn calculate_total_periods(&self) -> u32 {
1521        let start_year = self.start_date.year();
1522        let start_month = self.start_date.month();
1523        let end_year = self.end_date.year();
1524        let end_month = self.end_date.month();
1525
1526        ((end_year - start_year) * 12 + (end_month as i32 - start_month as i32) + 1).max(1) as u32
1527    }
1528
1529    /// Calculate the period number (0-indexed) for a given date.
1530    fn date_to_period(&self, date: NaiveDate) -> u32 {
1531        let start_year = self.start_date.year();
1532        let start_month = self.start_date.month() as i32;
1533        let date_year = date.year();
1534        let date_month = date.month() as i32;
1535
1536        ((date_year - start_year) * 12 + (date_month - start_month)).max(0) as u32
1537    }
1538
1539    /// Get drift adjustments for a given date.
1540    fn get_drift_adjustments(&self, date: NaiveDate) -> DriftAdjustments {
1541        if let Some(ref controller) = self.drift_controller {
1542            let period = self.date_to_period(date);
1543            controller.compute_adjustments(period)
1544        } else {
1545            DriftAdjustments::none()
1546        }
1547    }
1548
1549    /// Select a user from the pool or generate a generic user ID.
1550    fn select_user(&mut self, is_automated: bool) -> (String, String) {
1551        if let Some(ref pool) = self.user_pool {
1552            let persona = if is_automated {
1553                UserPersona::AutomatedSystem
1554            } else {
1555                // Random distribution among human personas
1556                let roll: f64 = self.rng.gen();
1557                if roll < 0.4 {
1558                    UserPersona::JuniorAccountant
1559                } else if roll < 0.7 {
1560                    UserPersona::SeniorAccountant
1561                } else if roll < 0.85 {
1562                    UserPersona::Controller
1563                } else {
1564                    UserPersona::Manager
1565                }
1566            };
1567
1568            if let Some(user) = pool.get_random_user(persona, &mut self.rng) {
1569                return (
1570                    user.user_id.clone(),
1571                    format!("{:?}", user.persona).to_lowercase(),
1572                );
1573            }
1574        }
1575
1576        // Fallback to generic format
1577        if is_automated {
1578            (
1579                format!("BATCH{:04}", self.rng.gen_range(1..=20)),
1580                "automated_system".to_string(),
1581            )
1582        } else {
1583            (
1584                format!("USER{:04}", self.rng.gen_range(1..=40)),
1585                "senior_accountant".to_string(),
1586            )
1587        }
1588    }
1589
1590    /// Select transaction source based on configuration weights.
1591    fn select_source(&mut self) -> TransactionSource {
1592        let roll: f64 = self.rng.gen();
1593        let dist = &self.config.source_distribution;
1594
1595        if roll < dist.manual {
1596            TransactionSource::Manual
1597        } else if roll < dist.manual + dist.automated {
1598            TransactionSource::Automated
1599        } else if roll < dist.manual + dist.automated + dist.recurring {
1600            TransactionSource::Recurring
1601        } else {
1602            TransactionSource::Adjustment
1603        }
1604    }
1605
1606    /// Select a business process based on configuration weights.
1607    fn select_business_process(&mut self) -> BusinessProcess {
1608        let roll: f64 = self.rng.gen();
1609
1610        // Default weights: O2C=35%, P2P=30%, R2R=20%, H2R=10%, A2R=5%
1611        if roll < 0.35 {
1612            BusinessProcess::O2C
1613        } else if roll < 0.65 {
1614            BusinessProcess::P2P
1615        } else if roll < 0.85 {
1616            BusinessProcess::R2R
1617        } else if roll < 0.95 {
1618            BusinessProcess::H2R
1619        } else {
1620            BusinessProcess::A2R
1621        }
1622    }
1623
1624    fn select_debit_account(&mut self) -> &GLAccount {
1625        let accounts = self.coa.get_accounts_by_type(AccountType::Asset);
1626        let expense_accounts = self.coa.get_accounts_by_type(AccountType::Expense);
1627
1628        // 60% asset, 40% expense for debits
1629        let all: Vec<_> = if self.rng.gen::<f64>() < 0.6 {
1630            accounts
1631        } else {
1632            expense_accounts
1633        };
1634
1635        all.choose(&mut self.rng)
1636            .copied()
1637            .unwrap_or_else(|| &self.coa.accounts[0])
1638    }
1639
1640    fn select_credit_account(&mut self) -> &GLAccount {
1641        let liability_accounts = self.coa.get_accounts_by_type(AccountType::Liability);
1642        let revenue_accounts = self.coa.get_accounts_by_type(AccountType::Revenue);
1643
1644        // 60% liability, 40% revenue for credits
1645        let all: Vec<_> = if self.rng.gen::<f64>() < 0.6 {
1646            liability_accounts
1647        } else {
1648            revenue_accounts
1649        };
1650
1651        all.choose(&mut self.rng)
1652            .copied()
1653            .unwrap_or_else(|| &self.coa.accounts[0])
1654    }
1655}
1656
1657impl Generator for JournalEntryGenerator {
1658    type Item = JournalEntry;
1659    type Config = (
1660        TransactionConfig,
1661        Arc<ChartOfAccounts>,
1662        Vec<String>,
1663        NaiveDate,
1664        NaiveDate,
1665    );
1666
1667    fn new(config: Self::Config, seed: u64) -> Self {
1668        Self::new_with_params(config.0, config.1, config.2, config.3, config.4, seed)
1669    }
1670
1671    fn generate_one(&mut self) -> Self::Item {
1672        self.generate()
1673    }
1674
1675    fn reset(&mut self) {
1676        self.rng = ChaCha8Rng::seed_from_u64(self.seed);
1677        self.line_sampler.reset(self.seed + 1);
1678        self.amount_sampler.reset(self.seed + 2);
1679        self.temporal_sampler.reset(self.seed + 3);
1680        self.count = 0;
1681        self.uuid_factory.reset();
1682
1683        // Reset reference generator by recreating it
1684        let mut ref_gen = ReferenceGenerator::new(
1685            self.start_date.year(),
1686            self.companies.first().map(|s| s.as_str()).unwrap_or("1000"),
1687        );
1688        ref_gen.set_prefix(
1689            ReferenceType::Invoice,
1690            &self.template_config.references.invoice_prefix,
1691        );
1692        ref_gen.set_prefix(
1693            ReferenceType::PurchaseOrder,
1694            &self.template_config.references.po_prefix,
1695        );
1696        ref_gen.set_prefix(
1697            ReferenceType::SalesOrder,
1698            &self.template_config.references.so_prefix,
1699        );
1700        self.reference_generator = ref_gen;
1701    }
1702
1703    fn count(&self) -> u64 {
1704        self.count
1705    }
1706
1707    fn seed(&self) -> u64 {
1708        self.seed
1709    }
1710}
1711
1712#[cfg(test)]
1713mod tests {
1714    use super::*;
1715    use crate::ChartOfAccountsGenerator;
1716
1717    #[test]
1718    fn test_generate_balanced_entries() {
1719        let mut coa_gen =
1720            ChartOfAccountsGenerator::new(CoAComplexity::Small, IndustrySector::Manufacturing, 42);
1721        let coa = Arc::new(coa_gen.generate());
1722
1723        let mut je_gen = JournalEntryGenerator::new_with_params(
1724            TransactionConfig::default(),
1725            coa,
1726            vec!["1000".to_string()],
1727            NaiveDate::from_ymd_opt(2024, 1, 1).unwrap(),
1728            NaiveDate::from_ymd_opt(2024, 12, 31).unwrap(),
1729            42,
1730        );
1731
1732        let mut balanced_count = 0;
1733        for _ in 0..100 {
1734            let entry = je_gen.generate();
1735
1736            // Skip entries with human errors as they may be intentionally unbalanced
1737            let has_human_error = entry
1738                .header
1739                .header_text
1740                .as_ref()
1741                .map(|t| t.contains("[HUMAN_ERROR:"))
1742                .unwrap_or(false);
1743
1744            if !has_human_error {
1745                assert!(
1746                    entry.is_balanced(),
1747                    "Entry {:?} is not balanced",
1748                    entry.header.document_id
1749                );
1750                balanced_count += 1;
1751            }
1752            assert!(entry.line_count() >= 2, "Entry has fewer than 2 lines");
1753        }
1754
1755        // Ensure most entries are balanced (human errors are rare)
1756        assert!(
1757            balanced_count >= 80,
1758            "Expected at least 80 balanced entries, got {}",
1759            balanced_count
1760        );
1761    }
1762
1763    #[test]
1764    fn test_deterministic_generation() {
1765        let mut coa_gen =
1766            ChartOfAccountsGenerator::new(CoAComplexity::Small, IndustrySector::Manufacturing, 42);
1767        let coa = Arc::new(coa_gen.generate());
1768
1769        let mut gen1 = JournalEntryGenerator::new_with_params(
1770            TransactionConfig::default(),
1771            Arc::clone(&coa),
1772            vec!["1000".to_string()],
1773            NaiveDate::from_ymd_opt(2024, 1, 1).unwrap(),
1774            NaiveDate::from_ymd_opt(2024, 12, 31).unwrap(),
1775            42,
1776        );
1777
1778        let mut gen2 = JournalEntryGenerator::new_with_params(
1779            TransactionConfig::default(),
1780            coa,
1781            vec!["1000".to_string()],
1782            NaiveDate::from_ymd_opt(2024, 1, 1).unwrap(),
1783            NaiveDate::from_ymd_opt(2024, 12, 31).unwrap(),
1784            42,
1785        );
1786
1787        for _ in 0..50 {
1788            let e1 = gen1.generate();
1789            let e2 = gen2.generate();
1790            assert_eq!(e1.header.document_id, e2.header.document_id);
1791            assert_eq!(e1.total_debit(), e2.total_debit());
1792        }
1793    }
1794
1795    #[test]
1796    fn test_templates_generate_descriptions() {
1797        let mut coa_gen =
1798            ChartOfAccountsGenerator::new(CoAComplexity::Small, IndustrySector::Manufacturing, 42);
1799        let coa = Arc::new(coa_gen.generate());
1800
1801        // Enable all template features
1802        let template_config = TemplateConfig {
1803            names: datasynth_config::schema::NameTemplateConfig {
1804                generate_realistic_names: true,
1805                email_domain: "test.com".to_string(),
1806                culture_distribution: datasynth_config::schema::CultureDistribution::default(),
1807            },
1808            descriptions: datasynth_config::schema::DescriptionTemplateConfig {
1809                generate_header_text: true,
1810                generate_line_text: true,
1811            },
1812            references: datasynth_config::schema::ReferenceTemplateConfig {
1813                generate_references: true,
1814                invoice_prefix: "TEST-INV".to_string(),
1815                po_prefix: "TEST-PO".to_string(),
1816                so_prefix: "TEST-SO".to_string(),
1817            },
1818        };
1819
1820        let mut je_gen = JournalEntryGenerator::new_with_full_config(
1821            TransactionConfig::default(),
1822            coa,
1823            vec!["1000".to_string()],
1824            NaiveDate::from_ymd_opt(2024, 1, 1).unwrap(),
1825            NaiveDate::from_ymd_opt(2024, 12, 31).unwrap(),
1826            42,
1827            template_config,
1828            None,
1829        )
1830        .with_persona_errors(false); // Disable for template testing
1831
1832        for _ in 0..10 {
1833            let entry = je_gen.generate();
1834
1835            // Verify header text is populated
1836            assert!(
1837                entry.header.header_text.is_some(),
1838                "Header text should be populated"
1839            );
1840
1841            // Verify reference is populated
1842            assert!(
1843                entry.header.reference.is_some(),
1844                "Reference should be populated"
1845            );
1846
1847            // Verify business process is set
1848            assert!(
1849                entry.header.business_process.is_some(),
1850                "Business process should be set"
1851            );
1852
1853            // Verify line text is populated
1854            for line in &entry.lines {
1855                assert!(line.line_text.is_some(), "Line text should be populated");
1856            }
1857
1858            // Entry should still be balanced
1859            assert!(entry.is_balanced());
1860        }
1861    }
1862
1863    #[test]
1864    fn test_user_pool_integration() {
1865        let mut coa_gen =
1866            ChartOfAccountsGenerator::new(CoAComplexity::Small, IndustrySector::Manufacturing, 42);
1867        let coa = Arc::new(coa_gen.generate());
1868
1869        let companies = vec!["1000".to_string()];
1870
1871        // Generate user pool
1872        let mut user_gen = crate::UserGenerator::new(42);
1873        let user_pool = user_gen.generate_standard(&companies);
1874
1875        let mut je_gen = JournalEntryGenerator::new_with_full_config(
1876            TransactionConfig::default(),
1877            coa,
1878            companies,
1879            NaiveDate::from_ymd_opt(2024, 1, 1).unwrap(),
1880            NaiveDate::from_ymd_opt(2024, 12, 31).unwrap(),
1881            42,
1882            TemplateConfig::default(),
1883            Some(user_pool),
1884        );
1885
1886        // Generate entries and verify user IDs are from pool
1887        for _ in 0..20 {
1888            let entry = je_gen.generate();
1889
1890            // User ID should not be generic BATCH/USER format when pool is used
1891            // (though it may still fall back if random selection misses)
1892            assert!(!entry.header.created_by.is_empty());
1893        }
1894    }
1895
1896    #[test]
1897    fn test_master_data_connection() {
1898        let mut coa_gen =
1899            ChartOfAccountsGenerator::new(CoAComplexity::Small, IndustrySector::Manufacturing, 42);
1900        let coa = Arc::new(coa_gen.generate());
1901
1902        // Create test vendors
1903        let vendors = vec![
1904            Vendor::new("V-TEST-001", "Test Vendor Alpha", VendorType::Supplier),
1905            Vendor::new("V-TEST-002", "Test Vendor Beta", VendorType::Technology),
1906        ];
1907
1908        // Create test customers
1909        let customers = vec![
1910            Customer::new("C-TEST-001", "Test Customer One", CustomerType::Corporate),
1911            Customer::new(
1912                "C-TEST-002",
1913                "Test Customer Two",
1914                CustomerType::SmallBusiness,
1915            ),
1916        ];
1917
1918        // Create test materials
1919        let materials = vec![Material::new(
1920            "MAT-TEST-001",
1921            "Test Material A",
1922            MaterialType::RawMaterial,
1923        )];
1924
1925        // Create generator with master data
1926        let generator = JournalEntryGenerator::new_with_params(
1927            TransactionConfig::default(),
1928            coa,
1929            vec!["1000".to_string()],
1930            NaiveDate::from_ymd_opt(2024, 1, 1).unwrap(),
1931            NaiveDate::from_ymd_opt(2024, 12, 31).unwrap(),
1932            42,
1933        );
1934
1935        // Without master data
1936        assert!(!generator.is_using_real_master_data());
1937
1938        // Connect master data
1939        let generator_with_data = generator
1940            .with_vendors(&vendors)
1941            .with_customers(&customers)
1942            .with_materials(&materials);
1943
1944        // Should now be using real master data
1945        assert!(generator_with_data.is_using_real_master_data());
1946    }
1947
1948    #[test]
1949    fn test_with_master_data_convenience_method() {
1950        let mut coa_gen =
1951            ChartOfAccountsGenerator::new(CoAComplexity::Small, IndustrySector::Manufacturing, 42);
1952        let coa = Arc::new(coa_gen.generate());
1953
1954        let vendors = vec![Vendor::new("V-001", "Vendor One", VendorType::Supplier)];
1955        let customers = vec![Customer::new(
1956            "C-001",
1957            "Customer One",
1958            CustomerType::Corporate,
1959        )];
1960        let materials = vec![Material::new(
1961            "MAT-001",
1962            "Material One",
1963            MaterialType::RawMaterial,
1964        )];
1965
1966        let generator = JournalEntryGenerator::new_with_params(
1967            TransactionConfig::default(),
1968            coa,
1969            vec!["1000".to_string()],
1970            NaiveDate::from_ymd_opt(2024, 1, 1).unwrap(),
1971            NaiveDate::from_ymd_opt(2024, 12, 31).unwrap(),
1972            42,
1973        )
1974        .with_master_data(&vendors, &customers, &materials);
1975
1976        assert!(generator.is_using_real_master_data());
1977    }
1978
1979    #[test]
1980    fn test_stress_factors_increase_error_rate() {
1981        let mut coa_gen =
1982            ChartOfAccountsGenerator::new(CoAComplexity::Small, IndustrySector::Manufacturing, 42);
1983        let coa = Arc::new(coa_gen.generate());
1984
1985        let generator = JournalEntryGenerator::new_with_params(
1986            TransactionConfig::default(),
1987            coa,
1988            vec!["1000".to_string()],
1989            NaiveDate::from_ymd_opt(2024, 1, 1).unwrap(),
1990            NaiveDate::from_ymd_opt(2024, 12, 31).unwrap(),
1991            42,
1992        );
1993
1994        let base_rate = 0.1;
1995
1996        // Regular day - no stress factors
1997        let regular_day = NaiveDate::from_ymd_opt(2024, 6, 15).unwrap(); // Mid-June Wednesday
1998        let regular_rate = generator.apply_stress_factors(base_rate, regular_day);
1999        assert!(
2000            (regular_rate - base_rate).abs() < 0.01,
2001            "Regular day should have minimal stress factor adjustment"
2002        );
2003
2004        // Month end - 50% more errors
2005        let month_end = NaiveDate::from_ymd_opt(2024, 6, 29).unwrap(); // June 29 (Saturday)
2006        let month_end_rate = generator.apply_stress_factors(base_rate, month_end);
2007        assert!(
2008            month_end_rate > regular_rate,
2009            "Month end should have higher error rate than regular day"
2010        );
2011
2012        // Year end - double the error rate
2013        let year_end = NaiveDate::from_ymd_opt(2024, 12, 30).unwrap(); // December 30
2014        let year_end_rate = generator.apply_stress_factors(base_rate, year_end);
2015        assert!(
2016            year_end_rate > month_end_rate,
2017            "Year end should have highest error rate"
2018        );
2019
2020        // Friday stress
2021        let friday = NaiveDate::from_ymd_opt(2024, 6, 14).unwrap(); // Friday
2022        let friday_rate = generator.apply_stress_factors(base_rate, friday);
2023        assert!(
2024            friday_rate > regular_rate,
2025            "Friday should have higher error rate than mid-week"
2026        );
2027
2028        // Monday stress
2029        let monday = NaiveDate::from_ymd_opt(2024, 6, 17).unwrap(); // Monday
2030        let monday_rate = generator.apply_stress_factors(base_rate, monday);
2031        assert!(
2032            monday_rate > regular_rate,
2033            "Monday should have higher error rate than mid-week"
2034        );
2035    }
2036
2037    #[test]
2038    fn test_batching_produces_similar_entries() {
2039        let mut coa_gen =
2040            ChartOfAccountsGenerator::new(CoAComplexity::Small, IndustrySector::Manufacturing, 42);
2041        let coa = Arc::new(coa_gen.generate());
2042
2043        // Use seed 123 which is more likely to trigger batching
2044        let mut je_gen = JournalEntryGenerator::new_with_params(
2045            TransactionConfig::default(),
2046            coa,
2047            vec!["1000".to_string()],
2048            NaiveDate::from_ymd_opt(2024, 1, 1).unwrap(),
2049            NaiveDate::from_ymd_opt(2024, 12, 31).unwrap(),
2050            123,
2051        )
2052        .with_persona_errors(false); // Disable to ensure balanced entries
2053
2054        // Generate many entries - at 15% batch rate, should see some batches
2055        let entries: Vec<JournalEntry> = (0..200).map(|_| je_gen.generate()).collect();
2056
2057        // Check that all entries are balanced (batched or not)
2058        for entry in &entries {
2059            assert!(
2060                entry.is_balanced(),
2061                "All entries including batched should be balanced"
2062            );
2063        }
2064
2065        // Count entries with same-day posting dates (batch indicator)
2066        let mut date_counts: std::collections::HashMap<NaiveDate, usize> =
2067            std::collections::HashMap::new();
2068        for entry in &entries {
2069            *date_counts.entry(entry.header.posting_date).or_insert(0) += 1;
2070        }
2071
2072        // With batching, some dates should have multiple entries
2073        let dates_with_multiple = date_counts.values().filter(|&&c| c > 1).count();
2074        assert!(
2075            dates_with_multiple > 0,
2076            "With batching, should see some dates with multiple entries"
2077        );
2078    }
2079
2080    #[test]
2081    fn test_temporal_patterns_business_days() {
2082        use datasynth_config::schema::{
2083            BusinessDaySchemaConfig, CalendarSchemaConfig, TemporalPatternsConfig,
2084        };
2085
2086        let mut coa_gen =
2087            ChartOfAccountsGenerator::new(CoAComplexity::Small, IndustrySector::Manufacturing, 42);
2088        let coa = Arc::new(coa_gen.generate());
2089
2090        // Create temporal patterns config with business days enabled
2091        let temporal_config = TemporalPatternsConfig {
2092            enabled: true,
2093            business_days: BusinessDaySchemaConfig {
2094                enabled: true,
2095                ..Default::default()
2096            },
2097            calendars: CalendarSchemaConfig {
2098                regions: vec!["US".to_string()],
2099                custom_holidays: vec![],
2100            },
2101            ..Default::default()
2102        };
2103
2104        let mut je_gen = JournalEntryGenerator::new_with_params(
2105            TransactionConfig::default(),
2106            coa,
2107            vec!["1000".to_string()],
2108            NaiveDate::from_ymd_opt(2024, 1, 1).unwrap(),
2109            NaiveDate::from_ymd_opt(2024, 3, 31).unwrap(), // Q1 2024
2110            42,
2111        )
2112        .with_temporal_patterns(temporal_config, 42)
2113        .with_persona_errors(false);
2114
2115        // Generate entries and verify none fall on weekends
2116        let entries: Vec<JournalEntry> = (0..100).map(|_| je_gen.generate()).collect();
2117
2118        for entry in &entries {
2119            let weekday = entry.header.posting_date.weekday();
2120            assert!(
2121                weekday != chrono::Weekday::Sat && weekday != chrono::Weekday::Sun,
2122                "Posting date {:?} should not be a weekend",
2123                entry.header.posting_date
2124            );
2125        }
2126    }
2127}