Skip to main content

datasynth_generators/document_flow/
o2c_generator.rs

1//! Order-to-Cash (O2C) flow generator.
2//!
3//! Generates complete O2C document chains:
4//! SalesOrder → Delivery → CustomerInvoice → CustomerReceipt (Payment)
5
6use chrono::{Datelike, NaiveDate};
7use datasynth_core::distributions::TemporalContext;
8use datasynth_core::models::{
9    documents::{
10        CustomerInvoice, CustomerInvoiceItem, Delivery, DeliveryItem, DocumentReference,
11        DocumentType, Payment, PaymentMethod, ReferenceType, SalesOrder, SalesOrderItem,
12    },
13    subledger::ar::{
14        ARCreditMemo, ARCreditMemoLine, CreditMemoReason, OnAccountPayment, OnAccountReason,
15        PaymentCorrection, PaymentCorrectionType, ShortPayment, ShortPaymentReasonCode,
16    },
17    CreditRating, Customer, CustomerPool, Material, MaterialPool, PaymentTerms,
18};
19use datasynth_core::utils::seeded_rng;
20use datasynth_core::CountryPack;
21use rand::prelude::*;
22use rand_chacha::ChaCha8Rng;
23use rust_decimal::Decimal;
24use std::sync::Arc;
25
26/// Configuration for O2C flow generation.
27#[derive(Debug, Clone)]
28pub struct O2CGeneratorConfig {
29    /// Credit check failure rate
30    pub credit_check_failure_rate: f64,
31    /// Rate of partial shipments
32    pub partial_shipment_rate: f64,
33    /// Average days between SO and Delivery
34    pub avg_days_so_to_delivery: u32,
35    /// Average days between Delivery and Invoice
36    pub avg_days_delivery_to_invoice: u32,
37    /// Average days between Invoice and Payment (customer payment)
38    pub avg_days_invoice_to_payment: u32,
39    /// Late payment rate
40    pub late_payment_rate: f64,
41    /// Bad debt rate (no payment)
42    pub bad_debt_rate: f64,
43    /// Rate of sales returns
44    pub returns_rate: f64,
45    /// Cash discount take rate
46    pub cash_discount_take_rate: f64,
47    /// Payment method distribution for AR receipts
48    pub payment_method_distribution: Vec<(PaymentMethod, f64)>,
49    /// Payment behavior configuration
50    pub payment_behavior: O2CPaymentBehavior,
51}
52
53/// Payment behavior configuration for O2C.
54#[derive(Debug, Clone)]
55pub struct O2CPaymentBehavior {
56    /// Rate of partial payments
57    pub partial_payment_rate: f64,
58    /// Rate of short payments (unauthorized deductions)
59    pub short_payment_rate: f64,
60    /// Maximum short payment percentage
61    pub max_short_percent: f64,
62    /// Rate of on-account payments (unapplied)
63    pub on_account_rate: f64,
64    /// Rate of payment corrections (NSF, chargebacks)
65    pub payment_correction_rate: f64,
66    /// Average days until partial payment remainder
67    pub avg_days_until_remainder: u32,
68}
69
70impl Default for O2CPaymentBehavior {
71    fn default() -> Self {
72        Self {
73            partial_payment_rate: 0.08,
74            short_payment_rate: 0.03,
75            max_short_percent: 0.10,
76            on_account_rate: 0.02,
77            payment_correction_rate: 0.02,
78            avg_days_until_remainder: 30,
79        }
80    }
81}
82
83impl Default for O2CGeneratorConfig {
84    fn default() -> Self {
85        Self {
86            credit_check_failure_rate: 0.02,
87            partial_shipment_rate: 0.08,
88            avg_days_so_to_delivery: 5,
89            avg_days_delivery_to_invoice: 1,
90            avg_days_invoice_to_payment: 30,
91            late_payment_rate: 0.15,
92            bad_debt_rate: 0.02,
93            returns_rate: 0.03,
94            cash_discount_take_rate: 0.25,
95            payment_method_distribution: vec![
96                (PaymentMethod::BankTransfer, 0.50),
97                (PaymentMethod::Check, 0.30),
98                (PaymentMethod::Wire, 0.15),
99                (PaymentMethod::CreditCard, 0.05),
100            ],
101            payment_behavior: O2CPaymentBehavior::default(),
102        }
103    }
104}
105
106/// A complete O2C document chain.
107#[derive(Debug, Clone)]
108pub struct O2CDocumentChain {
109    /// Sales Order
110    pub sales_order: SalesOrder,
111    /// Deliveries (may be multiple for partial shipments)
112    pub deliveries: Vec<Delivery>,
113    /// Customer Invoice
114    pub customer_invoice: Option<CustomerInvoice>,
115    /// Customer Receipt (Payment)
116    pub customer_receipt: Option<Payment>,
117    /// Credit memo (if return or adjustment)
118    pub credit_memo: Option<ARCreditMemo>,
119    /// Chain completion status
120    pub is_complete: bool,
121    /// Credit check passed
122    pub credit_check_passed: bool,
123    /// Is this a return/credit memo chain
124    pub is_return: bool,
125    /// Payment events (partial, short, corrections, etc.)
126    pub payment_events: Vec<PaymentEvent>,
127    /// Remainder payment receipts (follow-up to partial payments)
128    pub remainder_receipts: Vec<Payment>,
129}
130
131/// Payment event in an O2C chain.
132#[derive(Debug, Clone)]
133pub enum PaymentEvent {
134    /// Full payment received
135    FullPayment(Payment),
136    /// Partial payment received
137    PartialPayment {
138        payment: Payment,
139        remaining_amount: Decimal,
140        expected_remainder_date: Option<NaiveDate>,
141    },
142    /// Short payment (deduction)
143    ShortPayment {
144        payment: Payment,
145        short_payment: ShortPayment,
146    },
147    /// On-account payment (unapplied)
148    OnAccountPayment(OnAccountPayment),
149    /// Payment correction (NSF, chargeback)
150    PaymentCorrection {
151        original_payment: Payment,
152        correction: PaymentCorrection,
153    },
154    /// Remainder payment (follow-up to partial)
155    RemainderPayment(Payment),
156}
157
158/// Generator for O2C document flows.
159pub struct O2CGenerator {
160    rng: ChaCha8Rng,
161    seed: u64,
162    config: O2CGeneratorConfig,
163    so_counter: usize,
164    dlv_counter: usize,
165    ci_counter: usize,
166    rec_counter: usize,
167    credit_memo_counter: usize,
168    short_payment_counter: usize,
169    on_account_counter: usize,
170    correction_counter: usize,
171    country_pack: Option<CountryPack>,
172    /// v3.4.1+ temporal context for business-day-aware date selection.
173    /// `None` preserves the legacy raw-rng behavior (byte-identical).
174    temporal_context: Option<Arc<TemporalContext>>,
175}
176
177impl O2CGenerator {
178    /// Create a new O2C generator.
179    pub fn new(seed: u64) -> Self {
180        Self::with_config(seed, O2CGeneratorConfig::default())
181    }
182
183    /// Create a new O2C generator with custom configuration.
184    pub fn with_config(seed: u64, config: O2CGeneratorConfig) -> Self {
185        Self {
186            rng: seeded_rng(seed, 0),
187            seed,
188            config,
189            so_counter: 0,
190            dlv_counter: 0,
191            ci_counter: 0,
192            rec_counter: 0,
193            credit_memo_counter: 0,
194            short_payment_counter: 0,
195            on_account_counter: 0,
196            correction_counter: 0,
197            country_pack: None,
198            temporal_context: None,
199        }
200    }
201
202    /// Set the country pack for locale-aware document texts.
203    pub fn set_country_pack(&mut self, pack: CountryPack) {
204        self.country_pack = Some(pack);
205    }
206
207    /// Set the shared [`TemporalContext`] so SO/delivery/invoice/receipt
208    /// dates snap to business days. `None` restores pre-v3.4.1 behavior
209    /// (byte-identical to v3.4.0 for the same seed).
210    pub fn set_temporal_context(&mut self, ctx: Arc<TemporalContext>) {
211        self.temporal_context = Some(ctx);
212    }
213
214    /// Snap a date to the next business day when a [`TemporalContext`] is
215    /// configured; otherwise return the date unchanged.
216    fn snap_to_business_day(&self, date: NaiveDate) -> NaiveDate {
217        match &self.temporal_context {
218            Some(ctx) => ctx.adjust_to_business_day(date),
219            None => date,
220        }
221    }
222
223    /// Build a document ID, preferring the country pack `reference_prefix` when set.
224    fn make_doc_id(
225        &self,
226        default_prefix: &str,
227        pack_key: &str,
228        company_code: &str,
229        counter: usize,
230    ) -> String {
231        let prefix = self
232            .country_pack
233            .as_ref()
234            .map(|p| {
235                let grp = match pack_key {
236                    "sales_order" => &p.document_texts.sales_order,
237                    "delivery" => &p.document_texts.delivery,
238                    "customer_invoice" => &p.document_texts.customer_invoice,
239                    "customer_receipt" => &p.document_texts.customer_receipt,
240                    _ => return default_prefix.to_string(),
241                };
242                if grp.reference_prefix.is_empty() {
243                    default_prefix.to_string()
244                } else {
245                    grp.reference_prefix.clone()
246                }
247            })
248            .unwrap_or_else(|| default_prefix.to_string());
249        format!("{prefix}-{company_code}-{counter:010}")
250    }
251
252    /// Pick a random line description from the country pack for the given
253    /// document type, falling back to the provided default.
254    fn pick_line_description(&mut self, pack_key: &str, default: &str) -> String {
255        if let Some(pack) = &self.country_pack {
256            let descriptions = match pack_key {
257                "sales_order" => &pack.document_texts.sales_order.line_descriptions,
258                "delivery" => &pack.document_texts.delivery.line_descriptions,
259                "customer_invoice" => &pack.document_texts.customer_invoice.line_descriptions,
260                "customer_receipt" => &pack.document_texts.customer_receipt.line_descriptions,
261                _ => return default.to_string(),
262            };
263            if !descriptions.is_empty() {
264                let idx = self.rng.random_range(0..descriptions.len());
265                return descriptions[idx].clone();
266            }
267        }
268        default.to_string()
269    }
270
271    /// Generate a complete O2C chain.
272    pub fn generate_chain(
273        &mut self,
274        company_code: &str,
275        customer: &Customer,
276        materials: &[&Material],
277        so_date: NaiveDate,
278        fiscal_year: u16,
279        fiscal_period: u8,
280        created_by: &str,
281    ) -> O2CDocumentChain {
282        // v3.4.1: snap the incoming so_date to a business day when a
283        // `TemporalContext` is configured (mirrors the P2P pattern).
284        let so_date = self.snap_to_business_day(so_date);
285        // Generate SO
286        let mut so = self.generate_sales_order(
287            company_code,
288            customer,
289            materials,
290            so_date,
291            fiscal_year,
292            fiscal_period,
293            created_by,
294        );
295
296        // Perform credit check
297        let credit_check_passed = self.perform_credit_check(customer, so.total_gross_amount);
298        so.check_credit(
299            credit_check_passed,
300            if !credit_check_passed {
301                Some("Credit limit exceeded".to_string())
302            } else {
303                None
304            },
305        );
306
307        // If credit check fails, the chain may be blocked
308        if !credit_check_passed {
309            return O2CDocumentChain {
310                sales_order: so,
311                deliveries: Vec::new(),
312                customer_invoice: None,
313                customer_receipt: None,
314                credit_memo: None,
315                is_complete: false,
316                credit_check_passed: false,
317                is_return: false,
318                payment_events: Vec::new(),
319                remainder_receipts: Vec::new(),
320            };
321        }
322
323        // Release for delivery
324        so.release_for_delivery();
325
326        // Calculate delivery date
327        let delivery_date = self.calculate_delivery_date(so_date);
328        let delivery_fiscal_period = self.get_fiscal_period(delivery_date);
329
330        // Generate delivery(s)
331        let deliveries = self.generate_deliveries(
332            &so,
333            company_code,
334            customer,
335            delivery_date,
336            fiscal_year,
337            delivery_fiscal_period,
338            created_by,
339        );
340
341        // Calculate invoice date
342        let invoice_date = self.calculate_invoice_date(delivery_date);
343        let invoice_fiscal_period = self.get_fiscal_period(invoice_date);
344
345        // Release for billing
346        so.release_for_billing();
347
348        // Generate customer invoice
349        let customer_invoice = if !deliveries.is_empty() {
350            Some(self.generate_customer_invoice(
351                &so,
352                &deliveries,
353                company_code,
354                customer,
355                invoice_date,
356                fiscal_year,
357                invoice_fiscal_period,
358                created_by,
359            ))
360        } else {
361            None
362        };
363
364        // Determine if customer pays
365        let will_pay = self.rng.random::<f64>() >= self.config.bad_debt_rate;
366
367        // Calculate payment date and determine payment type
368        let mut payment_events = Vec::new();
369        let mut customer_receipt = None;
370        let mut remainder_receipts = Vec::new();
371
372        if will_pay {
373            if let Some(ref invoice) = customer_invoice {
374                let payment_date =
375                    self.calculate_payment_date(invoice_date, &customer.payment_terms, customer);
376                let payment_fiscal_period = self.get_fiscal_period(payment_date);
377
378                let payment_type = self.determine_payment_type();
379
380                match payment_type {
381                    PaymentType::Partial => {
382                        let payment_percent = self.determine_partial_payment_percent();
383                        let (payment, remaining, expected_date) = self.generate_partial_payment(
384                            invoice,
385                            company_code,
386                            customer,
387                            payment_date,
388                            fiscal_year,
389                            payment_fiscal_period,
390                            created_by,
391                            payment_percent,
392                        );
393
394                        payment_events.push(PaymentEvent::PartialPayment {
395                            payment: payment.clone(),
396                            remaining_amount: remaining,
397                            expected_remainder_date: expected_date,
398                        });
399                        customer_receipt = Some(payment);
400
401                        // Generate remainder payment
402                        if remaining > Decimal::ZERO {
403                            let remainder_date = expected_date.unwrap_or(
404                                payment_date
405                                    + chrono::Duration::days(
406                                        self.config.payment_behavior.avg_days_until_remainder
407                                            as i64,
408                                    ),
409                            );
410                            let remainder_period = self.get_fiscal_period(remainder_date);
411                            let remainder_payment = self.generate_remainder_payment(
412                                invoice,
413                                company_code,
414                                customer,
415                                remainder_date,
416                                fiscal_year,
417                                remainder_period,
418                                created_by,
419                                remaining,
420                            );
421                            payment_events
422                                .push(PaymentEvent::RemainderPayment(remainder_payment.clone()));
423                            remainder_receipts.push(remainder_payment);
424                        }
425                    }
426                    PaymentType::Short => {
427                        let (payment, short) = self.generate_short_payment(
428                            invoice,
429                            company_code,
430                            customer,
431                            payment_date,
432                            fiscal_year,
433                            payment_fiscal_period,
434                            created_by,
435                        );
436
437                        payment_events.push(PaymentEvent::ShortPayment {
438                            payment: payment.clone(),
439                            short_payment: short,
440                        });
441                        customer_receipt = Some(payment);
442                    }
443                    PaymentType::OnAccount => {
444                        // On-account payment - not tied to this specific invoice
445                        let amount = invoice.total_gross_amount
446                            * Decimal::from_f64_retain(0.8 + self.rng.random::<f64>() * 0.4)
447                                .unwrap_or(Decimal::ONE);
448                        let (payment, on_account) = self.generate_on_account_payment(
449                            company_code,
450                            customer,
451                            payment_date,
452                            fiscal_year,
453                            payment_fiscal_period,
454                            created_by,
455                            &invoice.header.currency,
456                            amount.round_dp(2),
457                        );
458
459                        payment_events.push(PaymentEvent::OnAccountPayment(on_account));
460                        customer_receipt = Some(payment);
461                    }
462                    PaymentType::Full => {
463                        let payment = self.generate_customer_receipt(
464                            invoice,
465                            company_code,
466                            customer,
467                            payment_date,
468                            fiscal_year,
469                            payment_fiscal_period,
470                            created_by,
471                        );
472
473                        // Check if this payment will have a correction
474                        if self.rng.random::<f64>()
475                            < self.config.payment_behavior.payment_correction_rate
476                        {
477                            let correction_date = payment_date
478                                + chrono::Duration::days(self.rng.random_range(3..14) as i64);
479
480                            let correction = self.generate_payment_correction(
481                                &payment,
482                                company_code,
483                                &customer.customer_id,
484                                correction_date,
485                                &invoice.header.currency,
486                            );
487
488                            payment_events.push(PaymentEvent::PaymentCorrection {
489                                original_payment: payment.clone(),
490                                correction,
491                            });
492                        } else {
493                            payment_events.push(PaymentEvent::FullPayment(payment.clone()));
494                        }
495
496                        customer_receipt = Some(payment);
497                    }
498                }
499            }
500        }
501
502        let has_partial = payment_events
503            .iter()
504            .any(|e| matches!(e, PaymentEvent::PartialPayment { .. }));
505        let has_remainder = payment_events
506            .iter()
507            .any(|e| matches!(e, PaymentEvent::RemainderPayment(_)));
508        let has_correction = payment_events
509            .iter()
510            .any(|e| matches!(e, PaymentEvent::PaymentCorrection { .. }));
511
512        let is_complete =
513            customer_receipt.is_some() && !has_correction && (!has_partial || has_remainder);
514
515        // Generate credit memo for returns based on returns_rate
516        let credit_memo = if let Some(ref invoice) = customer_invoice {
517            if self.rng.random_bool(self.config.returns_rate) {
518                let return_days = self.rng.random_range(5u32..=30);
519                let return_date =
520                    invoice.header.document_date + chrono::Duration::days(return_days as i64);
521                Some(self.generate_return_credit_memo(invoice, customer, company_code, return_date))
522            } else {
523                None
524            }
525        } else {
526            None
527        };
528        let is_return = credit_memo.is_some();
529
530        O2CDocumentChain {
531            sales_order: so,
532            deliveries,
533            customer_invoice,
534            customer_receipt,
535            credit_memo,
536            is_complete,
537            credit_check_passed: true,
538            is_return,
539            payment_events,
540            remainder_receipts,
541        }
542    }
543
544    /// Generate an AR credit memo for a return against a customer invoice.
545    fn generate_return_credit_memo(
546        &mut self,
547        invoice: &CustomerInvoice,
548        customer: &Customer,
549        company_code: &str,
550        return_date: NaiveDate,
551    ) -> ARCreditMemo {
552        self.credit_memo_counter += 1;
553        let cm_number = format!("CM-{}-{:010}", company_code, self.credit_memo_counter);
554
555        let reason = match self.rng.random_range(0u8..=3) {
556            0 => CreditMemoReason::Return,
557            1 => CreditMemoReason::Damaged,
558            2 => CreditMemoReason::QualityIssue,
559            _ => CreditMemoReason::PriceError,
560        };
561
562        let reason_desc = match reason {
563            CreditMemoReason::Return => "Goods returned by customer",
564            CreditMemoReason::Damaged => "Goods damaged in transit",
565            CreditMemoReason::QualityIssue => "Quality issue reported",
566            CreditMemoReason::PriceError => "Invoice price correction",
567            _ => "Credit adjustment",
568        };
569
570        let currency = invoice.header.currency.clone();
571        let mut memo = ARCreditMemo::for_invoice(
572            cm_number,
573            company_code.to_string(),
574            customer.customer_id.clone(),
575            customer.name.clone(),
576            return_date,
577            invoice.header.document_id.clone(),
578            reason,
579            reason_desc.to_string(),
580            currency.clone(),
581        );
582
583        // Credit 10-100% of invoice amount
584        let credit_pct = self.rng.random_range(0.10f64..=1.0);
585        let credit_amount = (invoice.total_gross_amount
586            * Decimal::from_f64_retain(credit_pct).unwrap_or(Decimal::ONE))
587        .round_dp(2);
588
589        memo.add_line(ARCreditMemoLine {
590            line_number: 1,
591            material_id: None,
592            description: format!("{reason:?} - {reason_desc}"),
593            quantity: Decimal::ONE,
594            unit: "EA".to_string(),
595            unit_price: credit_amount,
596            net_amount: credit_amount,
597            tax_code: None,
598            tax_rate: Decimal::ZERO,
599            tax_amount: Decimal::ZERO,
600            gross_amount: credit_amount,
601            revenue_account: "4000".to_string(),
602            reference_invoice_line: Some(1),
603            cost_center: None,
604            profit_center: None,
605        });
606
607        // Auto-approve if under threshold (e.g., 10,000)
608        let threshold = Decimal::from(10_000);
609        if !memo.requires_approval(threshold) {
610            memo.approve("SYSTEM".to_string(), return_date);
611        }
612
613        memo
614    }
615
616    /// Generate a sales order.
617    pub fn generate_sales_order(
618        &mut self,
619        company_code: &str,
620        customer: &Customer,
621        materials: &[&Material],
622        so_date: NaiveDate,
623        fiscal_year: u16,
624        fiscal_period: u8,
625        created_by: &str,
626    ) -> SalesOrder {
627        self.so_counter += 1;
628
629        let so_id = self.make_doc_id("SO", "sales_order", company_code, self.so_counter);
630
631        let requested_delivery =
632            so_date + chrono::Duration::days(self.config.avg_days_so_to_delivery as i64);
633
634        let mut so = SalesOrder::new(
635            so_id,
636            company_code,
637            &customer.customer_id,
638            fiscal_year,
639            fiscal_period,
640            so_date,
641            created_by,
642        )
643        .with_requested_delivery_date(requested_delivery);
644
645        // Denormalize customer name (DS-011)
646        so.customer_name = Some(customer.name.clone());
647
648        // Add line items
649        for (idx, material) in materials.iter().enumerate() {
650            let quantity = Decimal::from(self.rng.random_range(1..50));
651            let unit_price = material.list_price;
652
653            let description = self.pick_line_description("sales_order", &material.description);
654            let mut item =
655                SalesOrderItem::new((idx + 1) as u16 * 10, &description, quantity, unit_price)
656                    .with_material(&material.material_id);
657
658            // Add schedule line
659            item.add_schedule_line(requested_delivery, quantity);
660
661            so.add_item(item);
662        }
663
664        so
665    }
666
667    /// Generate deliveries for a sales order.
668    fn generate_deliveries(
669        &mut self,
670        so: &SalesOrder,
671        company_code: &str,
672        customer: &Customer,
673        delivery_date: NaiveDate,
674        fiscal_year: u16,
675        fiscal_period: u8,
676        created_by: &str,
677    ) -> Vec<Delivery> {
678        let mut deliveries = Vec::new();
679
680        // Determine if partial shipment
681        let is_partial = self.rng.random::<f64>() < self.config.partial_shipment_rate;
682
683        if is_partial {
684            // First shipment (60-80%)
685            let first_pct = 0.6 + self.rng.random::<f64>() * 0.2;
686            let dlv1 = self.create_delivery(
687                so,
688                company_code,
689                customer,
690                delivery_date,
691                fiscal_year,
692                fiscal_period,
693                created_by,
694                first_pct,
695            );
696            deliveries.push(dlv1);
697
698            // Second shipment
699            let second_date =
700                delivery_date + chrono::Duration::days(self.rng.random_range(3..7) as i64);
701            let second_period = self.get_fiscal_period(second_date);
702            let dlv2 = self.create_delivery(
703                so,
704                company_code,
705                customer,
706                second_date,
707                fiscal_year,
708                second_period,
709                created_by,
710                1.0 - first_pct,
711            );
712            deliveries.push(dlv2);
713        } else {
714            // Full shipment
715            let dlv = self.create_delivery(
716                so,
717                company_code,
718                customer,
719                delivery_date,
720                fiscal_year,
721                fiscal_period,
722                created_by,
723                1.0,
724            );
725            deliveries.push(dlv);
726        }
727
728        deliveries
729    }
730
731    /// Create a single delivery.
732    fn create_delivery(
733        &mut self,
734        so: &SalesOrder,
735        company_code: &str,
736        customer: &Customer,
737        delivery_date: NaiveDate,
738        fiscal_year: u16,
739        fiscal_period: u8,
740        created_by: &str,
741        quantity_pct: f64,
742    ) -> Delivery {
743        self.dlv_counter += 1;
744
745        let dlv_id = self.make_doc_id("DLV", "delivery", company_code, self.dlv_counter);
746
747        let mut delivery = Delivery::from_sales_order(
748            dlv_id,
749            company_code,
750            &so.header.document_id,
751            &customer.customer_id,
752            format!("SP{company_code}"),
753            fiscal_year,
754            fiscal_period,
755            delivery_date,
756            created_by,
757        );
758
759        // Add items based on SO items
760        for so_item in &so.items {
761            let ship_qty = (so_item.base.quantity
762                * Decimal::from_f64_retain(quantity_pct).unwrap_or(Decimal::ONE))
763            .round_dp(0);
764
765            if ship_qty > Decimal::ZERO {
766                // Calculate COGS (assume 60-70% of sales price)
767                let cogs_pct = 0.60 + self.rng.random::<f64>() * 0.10;
768                let cogs = (so_item.base.unit_price
769                    * ship_qty
770                    * Decimal::from_f64_retain(cogs_pct)
771                        .unwrap_or(Decimal::from_f64_retain(0.65).expect("valid decimal literal")))
772                .round_dp(2);
773
774                let dlv_description =
775                    self.pick_line_description("delivery", &so_item.base.description);
776                let mut item = DeliveryItem::from_sales_order(
777                    so_item.base.line_number,
778                    &dlv_description,
779                    ship_qty,
780                    so_item.base.unit_price,
781                    &so.header.document_id,
782                    so_item.base.line_number,
783                )
784                .with_cogs(cogs);
785
786                if let Some(material_id) = &so_item.base.material_id {
787                    item = item.with_material(material_id);
788                }
789
790                // Mark as picked
791                item.record_pick(ship_qty);
792
793                delivery.add_item(item);
794            }
795        }
796
797        // Process delivery workflow
798        delivery.release_for_picking(created_by);
799        delivery.confirm_pick();
800        delivery.confirm_pack(self.rng.random_range(1..10));
801        delivery.post_goods_issue(created_by, delivery_date);
802
803        delivery
804    }
805
806    /// Generate customer invoice.
807    fn generate_customer_invoice(
808        &mut self,
809        so: &SalesOrder,
810        deliveries: &[Delivery],
811        company_code: &str,
812        customer: &Customer,
813        invoice_date: NaiveDate,
814        fiscal_year: u16,
815        fiscal_period: u8,
816        created_by: &str,
817    ) -> CustomerInvoice {
818        self.ci_counter += 1;
819
820        let invoice_id = self.make_doc_id("CI", "customer_invoice", company_code, self.ci_counter);
821
822        // Calculate due date based on payment terms
823        let due_date = self.calculate_due_date(invoice_date, &customer.payment_terms);
824
825        let mut invoice = CustomerInvoice::from_delivery(
826            invoice_id,
827            company_code,
828            &deliveries[0].header.document_id,
829            &customer.customer_id,
830            fiscal_year,
831            fiscal_period,
832            invoice_date,
833            due_date,
834            created_by,
835        )
836        .with_payment_terms(
837            customer.payment_terms.code(),
838            customer.payment_terms.discount_days(),
839            customer.payment_terms.discount_percent(),
840        );
841
842        // Populate top-level sales_order_id FK (DS-GEP-004)
843        invoice.sales_order_id = Some(so.header.document_id.clone());
844
845        // Denormalize customer name (DS-011)
846        invoice.customer_name = Some(customer.name.clone());
847
848        // Calculate total delivered quantity per item
849        let mut delivered_quantities: std::collections::HashMap<u16, (Decimal, Decimal)> =
850            std::collections::HashMap::new();
851
852        for dlv in deliveries {
853            for dlv_item in &dlv.items {
854                let entry = delivered_quantities
855                    .entry(dlv_item.base.line_number)
856                    .or_insert((Decimal::ZERO, Decimal::ZERO));
857                entry.0 += dlv_item.base.quantity;
858                entry.1 += dlv_item.cogs_amount;
859            }
860        }
861
862        // Add invoice items based on delivered quantities
863        for so_item in &so.items {
864            if let Some(&(qty, cogs)) = delivered_quantities.get(&so_item.base.line_number) {
865                let ci_description =
866                    self.pick_line_description("customer_invoice", &so_item.base.description);
867                let item = CustomerInvoiceItem::from_delivery(
868                    so_item.base.line_number,
869                    &ci_description,
870                    qty,
871                    so_item.base.unit_price,
872                    &deliveries[0].header.document_id,
873                    so_item.base.line_number,
874                )
875                .with_cogs(cogs)
876                .with_sales_order(&so.header.document_id, so_item.base.line_number);
877
878                invoice.add_item(item);
879            }
880        }
881
882        // Link to SO
883        invoice.header.add_reference(DocumentReference::new(
884            DocumentType::SalesOrder,
885            &so.header.document_id,
886            DocumentType::CustomerInvoice,
887            &invoice.header.document_id,
888            ReferenceType::FollowOn,
889            company_code,
890            invoice_date,
891        ));
892
893        // Link to all deliveries
894        for dlv in deliveries {
895            invoice.header.add_reference(DocumentReference::new(
896                DocumentType::Delivery,
897                &dlv.header.document_id,
898                DocumentType::CustomerInvoice,
899                &invoice.header.document_id,
900                ReferenceType::FollowOn,
901                company_code,
902                invoice_date,
903            ));
904        }
905
906        // Post the invoice
907        invoice.post(created_by, invoice_date);
908
909        invoice
910    }
911
912    /// Generate customer receipt (AR payment).
913    fn generate_customer_receipt(
914        &mut self,
915        invoice: &CustomerInvoice,
916        company_code: &str,
917        customer: &Customer,
918        payment_date: NaiveDate,
919        fiscal_year: u16,
920        fiscal_period: u8,
921        created_by: &str,
922    ) -> Payment {
923        self.rec_counter += 1;
924
925        let receipt_id =
926            self.make_doc_id("REC", "customer_receipt", company_code, self.rec_counter);
927
928        // Determine if cash discount taken
929        let take_discount = invoice.discount_date_1.is_some_and(|disc_date| {
930            payment_date <= disc_date
931                && self.rng.random::<f64>() < self.config.cash_discount_take_rate
932        });
933
934        let discount_amount = if take_discount {
935            invoice.cash_discount_available(payment_date)
936        } else {
937            Decimal::ZERO
938        };
939
940        let payment_amount = invoice.amount_open - discount_amount;
941
942        let mut receipt = Payment::new_ar_receipt(
943            receipt_id,
944            company_code,
945            &customer.customer_id,
946            payment_amount,
947            fiscal_year,
948            fiscal_period,
949            payment_date,
950            created_by,
951        )
952        .with_payment_method(self.select_payment_method())
953        .with_value_date(payment_date);
954
955        // Allocate to invoice
956        receipt.allocate_to_invoice(
957            &invoice.header.document_id,
958            DocumentType::CustomerInvoice,
959            payment_amount,
960            discount_amount,
961        );
962
963        // Add document reference linking receipt to invoice
964        receipt.header.add_reference(DocumentReference::new(
965            DocumentType::CustomerReceipt,
966            &receipt.header.document_id,
967            DocumentType::CustomerInvoice,
968            &invoice.header.document_id,
969            ReferenceType::Payment,
970            &receipt.header.company_code,
971            payment_date,
972        ));
973
974        // Post the receipt
975        receipt.post(created_by, payment_date);
976
977        receipt
978    }
979
980    /// Generate multiple O2C chains.
981    pub fn generate_chains(
982        &mut self,
983        count: usize,
984        company_code: &str,
985        customers: &CustomerPool,
986        materials: &MaterialPool,
987        date_range: (NaiveDate, NaiveDate),
988        fiscal_year: u16,
989        created_by: &str,
990    ) -> Vec<O2CDocumentChain> {
991        tracing::debug!(count, company_code, "Generating O2C document chains");
992        let mut chains = Vec::new();
993
994        let (start_date, end_date) = date_range;
995        let days_range = (end_date - start_date).num_days() as u64;
996
997        for _ in 0..count {
998            // Select random customer
999            let customer_idx = self.rng.random_range(0..customers.customers.len());
1000            let customer = &customers.customers[customer_idx];
1001
1002            // Select random materials (1-5 items per SO)
1003            let num_items = self.rng.random_range(1..=5).min(materials.materials.len());
1004            let selected_materials: Vec<&Material> = materials
1005                .materials
1006                .iter()
1007                .sample(&mut self.rng, num_items)
1008                .into_iter()
1009                .collect();
1010
1011            // Select random SO date. Snap to a business day when a
1012            // `TemporalContext` is present so weekend/holiday sales orders
1013            // don't leak through.
1014            let raw_offset = self.rng.random_range(0..=days_range) as i64;
1015            let raw_date = start_date + chrono::Duration::days(raw_offset);
1016            let so_date = match &self.temporal_context {
1017                Some(ctx) => {
1018                    let end = start_date + chrono::Duration::days(days_range as i64);
1019                    let snapped = ctx.adjust_to_business_day(raw_date);
1020                    if snapped > end {
1021                        ctx.adjust_to_previous_business_day(end)
1022                    } else {
1023                        snapped
1024                    }
1025                }
1026                None => raw_date,
1027            };
1028            let fiscal_period = self.get_fiscal_period(so_date);
1029
1030            let chain = self.generate_chain(
1031                company_code,
1032                customer,
1033                &selected_materials,
1034                so_date,
1035                fiscal_year,
1036                fiscal_period,
1037                created_by,
1038            );
1039
1040            chains.push(chain);
1041        }
1042
1043        chains
1044    }
1045
1046    /// Perform credit check for customer.
1047    fn perform_credit_check(&mut self, customer: &Customer, order_amount: Decimal) -> bool {
1048        // Check credit limit
1049        if !customer.can_place_order(order_amount) {
1050            return false;
1051        }
1052
1053        // Additional random failure based on config
1054        let fail_roll = self.rng.random::<f64>();
1055        if fail_roll < self.config.credit_check_failure_rate {
1056            return false;
1057        }
1058
1059        // Higher risk customers have higher failure rate
1060        let additional_fail_rate = match customer.credit_rating {
1061            CreditRating::CCC | CreditRating::D => 0.20,
1062            CreditRating::B | CreditRating::BB => 0.05,
1063            _ => 0.0,
1064        };
1065
1066        self.rng.random::<f64>() >= additional_fail_rate
1067    }
1068
1069    /// Calculate delivery date from SO date.
1070    fn calculate_delivery_date(&mut self, so_date: NaiveDate) -> NaiveDate {
1071        let variance = self.rng.random_range(0..3) as i64;
1072        let raw =
1073            so_date + chrono::Duration::days(self.config.avg_days_so_to_delivery as i64 + variance);
1074        self.snap_to_business_day(raw)
1075    }
1076
1077    /// Calculate invoice date from delivery date.
1078    fn calculate_invoice_date(&mut self, delivery_date: NaiveDate) -> NaiveDate {
1079        let variance = self.rng.random_range(0..2) as i64;
1080        let raw = delivery_date
1081            + chrono::Duration::days(self.config.avg_days_delivery_to_invoice as i64 + variance);
1082        self.snap_to_business_day(raw)
1083    }
1084
1085    /// Calculate payment date based on customer behavior.
1086    fn calculate_payment_date(
1087        &mut self,
1088        invoice_date: NaiveDate,
1089        payment_terms: &PaymentTerms,
1090        customer: &Customer,
1091    ) -> NaiveDate {
1092        let base_days = payment_terms.net_days() as i64;
1093
1094        // Adjust based on customer payment behavior
1095        let behavior_adjustment = match customer.payment_behavior {
1096            datasynth_core::models::CustomerPaymentBehavior::Excellent
1097            | datasynth_core::models::CustomerPaymentBehavior::EarlyPayer => {
1098                -self.rng.random_range(5..15) as i64
1099            }
1100            datasynth_core::models::CustomerPaymentBehavior::Good
1101            | datasynth_core::models::CustomerPaymentBehavior::OnTime => {
1102                self.rng.random_range(-2..3) as i64
1103            }
1104            datasynth_core::models::CustomerPaymentBehavior::Fair
1105            | datasynth_core::models::CustomerPaymentBehavior::SlightlyLate => {
1106                self.rng.random_range(5..15) as i64
1107            }
1108            datasynth_core::models::CustomerPaymentBehavior::Poor
1109            | datasynth_core::models::CustomerPaymentBehavior::OftenLate => {
1110                self.rng.random_range(15..45) as i64
1111            }
1112            datasynth_core::models::CustomerPaymentBehavior::VeryPoor
1113            | datasynth_core::models::CustomerPaymentBehavior::HighRisk => {
1114                self.rng.random_range(30..90) as i64
1115            }
1116        };
1117
1118        // Additional random late payment
1119        let late_adjustment = if self.rng.random::<f64>() < self.config.late_payment_rate {
1120            self.rng.random_range(10..30) as i64
1121        } else {
1122            0
1123        };
1124
1125        let raw = invoice_date
1126            + chrono::Duration::days(base_days + behavior_adjustment + late_adjustment);
1127        self.snap_to_business_day(raw)
1128    }
1129
1130    /// Calculate due date based on payment terms.
1131    fn calculate_due_date(
1132        &self,
1133        invoice_date: NaiveDate,
1134        payment_terms: &PaymentTerms,
1135    ) -> NaiveDate {
1136        let raw = invoice_date + chrono::Duration::days(payment_terms.net_days() as i64);
1137        self.snap_to_business_day(raw)
1138    }
1139
1140    /// Select payment method based on distribution.
1141    fn select_payment_method(&mut self) -> PaymentMethod {
1142        let roll: f64 = self.rng.random();
1143        let mut cumulative = 0.0;
1144
1145        for (method, prob) in &self.config.payment_method_distribution {
1146            cumulative += prob;
1147            if roll < cumulative {
1148                return *method;
1149            }
1150        }
1151
1152        PaymentMethod::BankTransfer
1153    }
1154
1155    /// Get fiscal period from date.
1156    fn get_fiscal_period(&self, date: NaiveDate) -> u8 {
1157        date.month() as u8
1158    }
1159
1160    /// Reset the generator.
1161    pub fn reset(&mut self) {
1162        self.rng = seeded_rng(self.seed, 0);
1163        self.so_counter = 0;
1164        self.dlv_counter = 0;
1165        self.ci_counter = 0;
1166        self.rec_counter = 0;
1167        self.short_payment_counter = 0;
1168        self.on_account_counter = 0;
1169        self.correction_counter = 0;
1170    }
1171
1172    /// Generate a partial payment for an invoice.
1173    pub fn generate_partial_payment(
1174        &mut self,
1175        invoice: &CustomerInvoice,
1176        company_code: &str,
1177        customer: &Customer,
1178        payment_date: NaiveDate,
1179        fiscal_year: u16,
1180        fiscal_period: u8,
1181        created_by: &str,
1182        payment_percent: f64,
1183    ) -> (Payment, Decimal, Option<NaiveDate>) {
1184        self.rec_counter += 1;
1185
1186        let receipt_id =
1187            self.make_doc_id("REC", "customer_receipt", company_code, self.rec_counter);
1188
1189        let full_amount = invoice.amount_open;
1190        let payment_amount = (full_amount
1191            * Decimal::from_f64_retain(payment_percent).unwrap_or(Decimal::ONE))
1192        .round_dp(2);
1193        let remaining_amount = full_amount - payment_amount;
1194
1195        let mut receipt = Payment::new_ar_receipt(
1196            receipt_id,
1197            company_code,
1198            &customer.customer_id,
1199            payment_amount,
1200            fiscal_year,
1201            fiscal_period,
1202            payment_date,
1203            created_by,
1204        )
1205        .with_payment_method(self.select_payment_method())
1206        .with_value_date(payment_date);
1207
1208        // Allocate partial amount to invoice
1209        receipt.allocate_to_invoice(
1210            &invoice.header.document_id,
1211            DocumentType::CustomerInvoice,
1212            payment_amount,
1213            Decimal::ZERO, // No discount on partial payments
1214        );
1215
1216        // Add document reference
1217        receipt.header.add_reference(DocumentReference::new(
1218            DocumentType::CustomerReceipt,
1219            &receipt.header.document_id,
1220            DocumentType::CustomerInvoice,
1221            &invoice.header.document_id,
1222            ReferenceType::Payment,
1223            &receipt.header.company_code,
1224            payment_date,
1225        ));
1226
1227        receipt.post(created_by, payment_date);
1228
1229        // Calculate expected remainder date
1230        let expected_remainder_date = Some(
1231            payment_date
1232                + chrono::Duration::days(
1233                    self.config.payment_behavior.avg_days_until_remainder as i64,
1234                )
1235                + chrono::Duration::days(self.rng.random_range(-7..7) as i64),
1236        );
1237
1238        (receipt, remaining_amount, expected_remainder_date)
1239    }
1240
1241    /// Generate a remainder payment for a partial payment.
1242    pub fn generate_remainder_payment(
1243        &mut self,
1244        invoice: &CustomerInvoice,
1245        company_code: &str,
1246        customer: &Customer,
1247        payment_date: NaiveDate,
1248        fiscal_year: u16,
1249        fiscal_period: u8,
1250        created_by: &str,
1251        amount: Decimal,
1252    ) -> Payment {
1253        self.rec_counter += 1;
1254
1255        let receipt_id =
1256            self.make_doc_id("REC", "customer_receipt", company_code, self.rec_counter);
1257
1258        let mut receipt = Payment::new_ar_receipt(
1259            receipt_id,
1260            company_code,
1261            &customer.customer_id,
1262            amount,
1263            fiscal_year,
1264            fiscal_period,
1265            payment_date,
1266            created_by,
1267        )
1268        .with_payment_method(self.select_payment_method())
1269        .with_value_date(payment_date);
1270
1271        // Allocate remainder amount to invoice
1272        receipt.allocate_to_invoice(
1273            &invoice.header.document_id,
1274            DocumentType::CustomerInvoice,
1275            amount,
1276            Decimal::ZERO, // No discount on remainder payments
1277        );
1278
1279        // Add document reference linking receipt to invoice
1280        receipt.header.add_reference(DocumentReference::new(
1281            DocumentType::CustomerReceipt,
1282            &receipt.header.document_id,
1283            DocumentType::CustomerInvoice,
1284            &invoice.header.document_id,
1285            ReferenceType::Payment,
1286            &receipt.header.company_code,
1287            payment_date,
1288        ));
1289
1290        // Post the receipt
1291        receipt.post(created_by, payment_date);
1292
1293        receipt
1294    }
1295
1296    /// Generate a short payment for an invoice.
1297    pub fn generate_short_payment(
1298        &mut self,
1299        invoice: &CustomerInvoice,
1300        company_code: &str,
1301        customer: &Customer,
1302        payment_date: NaiveDate,
1303        fiscal_year: u16,
1304        fiscal_period: u8,
1305        created_by: &str,
1306    ) -> (Payment, ShortPayment) {
1307        self.rec_counter += 1;
1308        self.short_payment_counter += 1;
1309
1310        let receipt_id =
1311            self.make_doc_id("REC", "customer_receipt", company_code, self.rec_counter);
1312        let short_id = format!("SHORT-{}-{:06}", company_code, self.short_payment_counter);
1313
1314        let full_amount = invoice.amount_open;
1315
1316        // Calculate short amount (1-10% of invoice)
1317        let short_percent =
1318            self.rng.random::<f64>() * self.config.payment_behavior.max_short_percent;
1319        let short_amount = (full_amount
1320            * Decimal::from_f64_retain(short_percent).unwrap_or(Decimal::ZERO))
1321        .round_dp(2)
1322        .max(Decimal::ONE); // At least $1 short
1323
1324        let payment_amount = full_amount - short_amount;
1325
1326        let mut receipt = Payment::new_ar_receipt(
1327            receipt_id.clone(),
1328            company_code,
1329            &customer.customer_id,
1330            payment_amount,
1331            fiscal_year,
1332            fiscal_period,
1333            payment_date,
1334            created_by,
1335        )
1336        .with_payment_method(self.select_payment_method())
1337        .with_value_date(payment_date);
1338
1339        // Allocate to invoice
1340        receipt.allocate_to_invoice(
1341            &invoice.header.document_id,
1342            DocumentType::CustomerInvoice,
1343            payment_amount,
1344            Decimal::ZERO,
1345        );
1346
1347        receipt.header.add_reference(DocumentReference::new(
1348            DocumentType::CustomerReceipt,
1349            &receipt.header.document_id,
1350            DocumentType::CustomerInvoice,
1351            &invoice.header.document_id,
1352            ReferenceType::Payment,
1353            &receipt.header.company_code,
1354            payment_date,
1355        ));
1356
1357        receipt.post(created_by, payment_date);
1358
1359        // Create short payment record
1360        let reason_code = self.select_short_payment_reason();
1361        let short_payment = ShortPayment::new(
1362            short_id,
1363            company_code.to_string(),
1364            customer.customer_id.clone(),
1365            receipt_id,
1366            invoice.header.document_id.clone(),
1367            full_amount,
1368            payment_amount,
1369            invoice.header.currency.clone(),
1370            payment_date,
1371            reason_code,
1372        );
1373
1374        (receipt, short_payment)
1375    }
1376
1377    /// Generate an on-account payment.
1378    pub fn generate_on_account_payment(
1379        &mut self,
1380        company_code: &str,
1381        customer: &Customer,
1382        payment_date: NaiveDate,
1383        fiscal_year: u16,
1384        fiscal_period: u8,
1385        created_by: &str,
1386        currency: &str,
1387        amount: Decimal,
1388    ) -> (Payment, OnAccountPayment) {
1389        self.rec_counter += 1;
1390        self.on_account_counter += 1;
1391
1392        let receipt_id =
1393            self.make_doc_id("REC", "customer_receipt", company_code, self.rec_counter);
1394        let on_account_id = format!("OA-{}-{:06}", company_code, self.on_account_counter);
1395
1396        let mut receipt = Payment::new_ar_receipt(
1397            receipt_id.clone(),
1398            company_code,
1399            &customer.customer_id,
1400            amount,
1401            fiscal_year,
1402            fiscal_period,
1403            payment_date,
1404            created_by,
1405        )
1406        .with_payment_method(self.select_payment_method())
1407        .with_value_date(payment_date);
1408
1409        // On-account payments are not allocated to any invoice
1410        receipt.post(created_by, payment_date);
1411
1412        // Create on-account payment record
1413        let reason = self.select_on_account_reason();
1414        let on_account = OnAccountPayment::new(
1415            on_account_id,
1416            company_code.to_string(),
1417            customer.customer_id.clone(),
1418            receipt_id,
1419            amount,
1420            currency.to_string(),
1421            payment_date,
1422        )
1423        .with_reason(reason);
1424
1425        (receipt, on_account)
1426    }
1427
1428    /// Generate a payment correction (NSF or chargeback).
1429    pub fn generate_payment_correction(
1430        &mut self,
1431        original_payment: &Payment,
1432        company_code: &str,
1433        customer_id: &str,
1434        correction_date: NaiveDate,
1435        currency: &str,
1436    ) -> PaymentCorrection {
1437        self.correction_counter += 1;
1438
1439        let correction_id = format!("CORR-{}-{:06}", company_code, self.correction_counter);
1440
1441        let correction_type = if self.rng.random::<f64>() < 0.6 {
1442            PaymentCorrectionType::NSF
1443        } else {
1444            PaymentCorrectionType::Chargeback
1445        };
1446
1447        let mut correction = PaymentCorrection::new(
1448            correction_id,
1449            company_code.to_string(),
1450            customer_id.to_string(),
1451            original_payment.header.document_id.clone(),
1452            correction_type,
1453            original_payment.amount,
1454            original_payment.amount, // Full reversal
1455            currency.to_string(),
1456            correction_date,
1457        );
1458
1459        // Set appropriate details based on type
1460        match correction_type {
1461            PaymentCorrectionType::NSF => {
1462                correction.bank_reference = Some(format!("NSF-{}", self.rng.random::<u32>()));
1463                correction.fee_amount = Decimal::from(35); // Standard NSF fee
1464                correction.reason = Some("Payment returned - Insufficient funds".to_string());
1465            }
1466            PaymentCorrectionType::Chargeback => {
1467                correction.chargeback_code =
1468                    Some(format!("CB{:04}", self.rng.random_range(1000..9999)));
1469                correction.reason = Some("Credit card chargeback".to_string());
1470            }
1471            _ => {}
1472        }
1473
1474        // Add affected invoice
1475        if let Some(allocation) = original_payment.allocations.first() {
1476            correction.add_affected_invoice(allocation.invoice_id.clone());
1477        }
1478
1479        correction
1480    }
1481
1482    /// Select a random short payment reason code.
1483    fn select_short_payment_reason(&mut self) -> ShortPaymentReasonCode {
1484        let roll: f64 = self.rng.random();
1485        if roll < 0.30 {
1486            ShortPaymentReasonCode::PricingDispute
1487        } else if roll < 0.50 {
1488            ShortPaymentReasonCode::QualityIssue
1489        } else if roll < 0.70 {
1490            ShortPaymentReasonCode::QuantityDiscrepancy
1491        } else if roll < 0.85 {
1492            ShortPaymentReasonCode::UnauthorizedDeduction
1493        } else {
1494            ShortPaymentReasonCode::IncorrectDiscount
1495        }
1496    }
1497
1498    /// Select a random on-account reason.
1499    fn select_on_account_reason(&mut self) -> OnAccountReason {
1500        let roll: f64 = self.rng.random();
1501        if roll < 0.40 {
1502            OnAccountReason::NoInvoiceReference
1503        } else if roll < 0.60 {
1504            OnAccountReason::Overpayment
1505        } else if roll < 0.75 {
1506            OnAccountReason::Prepayment
1507        } else if roll < 0.90 {
1508            OnAccountReason::UnclearRemittance
1509        } else {
1510            OnAccountReason::Other
1511        }
1512    }
1513
1514    /// Determine the payment type based on configuration.
1515    fn determine_payment_type(&mut self) -> PaymentType {
1516        let roll: f64 = self.rng.random();
1517        let pb = &self.config.payment_behavior;
1518
1519        let mut cumulative = 0.0;
1520
1521        cumulative += pb.partial_payment_rate;
1522        if roll < cumulative {
1523            return PaymentType::Partial;
1524        }
1525
1526        cumulative += pb.short_payment_rate;
1527        if roll < cumulative {
1528            return PaymentType::Short;
1529        }
1530
1531        cumulative += pb.on_account_rate;
1532        if roll < cumulative {
1533            return PaymentType::OnAccount;
1534        }
1535
1536        PaymentType::Full
1537    }
1538
1539    /// Determine partial payment percentage.
1540    fn determine_partial_payment_percent(&mut self) -> f64 {
1541        let roll: f64 = self.rng.random();
1542        if roll < 0.15 {
1543            0.25
1544        } else if roll < 0.65 {
1545            0.50
1546        } else if roll < 0.90 {
1547            0.75
1548        } else {
1549            // Random between 30-80%
1550            0.30 + self.rng.random::<f64>() * 0.50
1551        }
1552    }
1553}
1554
1555/// Type of payment to generate.
1556#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1557enum PaymentType {
1558    Full,
1559    Partial,
1560    Short,
1561    OnAccount,
1562}
1563
1564#[cfg(test)]
1565mod tests {
1566    use super::*;
1567    use datasynth_core::models::{CustomerPaymentBehavior, MaterialType};
1568
1569    fn create_test_customer() -> Customer {
1570        let mut customer = Customer::new(
1571            "C-000001",
1572            "Test Customer Inc.",
1573            datasynth_core::models::CustomerType::Corporate,
1574        );
1575        customer.credit_rating = CreditRating::A;
1576        customer.credit_limit = Decimal::from(1_000_000);
1577        customer.payment_behavior = CustomerPaymentBehavior::OnTime;
1578        customer
1579    }
1580
1581    fn create_test_materials() -> Vec<Material> {
1582        let mut mat1 = Material::new("MAT-001", "Test Product 1", MaterialType::FinishedGood);
1583        mat1.list_price = Decimal::from(100);
1584        mat1.standard_cost = Decimal::from(60);
1585
1586        let mut mat2 = Material::new("MAT-002", "Test Product 2", MaterialType::FinishedGood);
1587        mat2.list_price = Decimal::from(200);
1588        mat2.standard_cost = Decimal::from(120);
1589
1590        vec![mat1, mat2]
1591    }
1592
1593    #[test]
1594    fn test_o2c_chain_generation() {
1595        let mut gen = O2CGenerator::new(42);
1596        let customer = create_test_customer();
1597        let materials = create_test_materials();
1598        let material_refs: Vec<&Material> = materials.iter().collect();
1599
1600        let chain = gen.generate_chain(
1601            "1000",
1602            &customer,
1603            &material_refs,
1604            NaiveDate::from_ymd_opt(2024, 1, 15).unwrap(),
1605            2024,
1606            1,
1607            "JSMITH",
1608        );
1609
1610        assert!(!chain.sales_order.items.is_empty());
1611        assert!(chain.credit_check_passed);
1612        assert!(!chain.deliveries.is_empty());
1613        assert!(chain.customer_invoice.is_some());
1614    }
1615
1616    #[test]
1617    fn test_sales_order_generation() {
1618        let mut gen = O2CGenerator::new(42);
1619        let customer = create_test_customer();
1620        let materials = create_test_materials();
1621        let material_refs: Vec<&Material> = materials.iter().collect();
1622
1623        let so = gen.generate_sales_order(
1624            "1000",
1625            &customer,
1626            &material_refs,
1627            NaiveDate::from_ymd_opt(2024, 1, 15).unwrap(),
1628            2024,
1629            1,
1630            "JSMITH",
1631        );
1632
1633        assert_eq!(so.customer_id, "C-000001");
1634        assert_eq!(so.items.len(), 2);
1635        assert!(so.total_net_amount > Decimal::ZERO);
1636    }
1637
1638    #[test]
1639    fn test_credit_check_failure() {
1640        let config = O2CGeneratorConfig {
1641            credit_check_failure_rate: 1.0, // Force failure
1642            ..Default::default()
1643        };
1644
1645        let mut gen = O2CGenerator::with_config(42, config);
1646        let customer = create_test_customer();
1647        let materials = create_test_materials();
1648        let material_refs: Vec<&Material> = materials.iter().collect();
1649
1650        let chain = gen.generate_chain(
1651            "1000",
1652            &customer,
1653            &material_refs,
1654            NaiveDate::from_ymd_opt(2024, 1, 15).unwrap(),
1655            2024,
1656            1,
1657            "JSMITH",
1658        );
1659
1660        assert!(!chain.credit_check_passed);
1661        assert!(chain.deliveries.is_empty());
1662        assert!(chain.customer_invoice.is_none());
1663    }
1664
1665    #[test]
1666    fn test_document_references() {
1667        let mut gen = O2CGenerator::new(42);
1668        let customer = create_test_customer();
1669        let materials = create_test_materials();
1670        let material_refs: Vec<&Material> = materials.iter().collect();
1671
1672        let chain = gen.generate_chain(
1673            "1000",
1674            &customer,
1675            &material_refs,
1676            NaiveDate::from_ymd_opt(2024, 1, 15).unwrap(),
1677            2024,
1678            1,
1679            "JSMITH",
1680        );
1681
1682        // Delivery should reference SO
1683        if let Some(dlv) = chain.deliveries.first() {
1684            assert!(!dlv.header.document_references.is_empty());
1685        }
1686
1687        // Invoice should reference SO and Delivery
1688        if let Some(invoice) = &chain.customer_invoice {
1689            assert!(invoice.header.document_references.len() >= 2);
1690        }
1691    }
1692
1693    #[test]
1694    fn test_deterministic_generation() {
1695        let customer = create_test_customer();
1696        let materials = create_test_materials();
1697        let material_refs: Vec<&Material> = materials.iter().collect();
1698
1699        let mut gen1 = O2CGenerator::new(42);
1700        let mut gen2 = O2CGenerator::new(42);
1701
1702        let chain1 = gen1.generate_chain(
1703            "1000",
1704            &customer,
1705            &material_refs,
1706            NaiveDate::from_ymd_opt(2024, 1, 15).unwrap(),
1707            2024,
1708            1,
1709            "JSMITH",
1710        );
1711        let chain2 = gen2.generate_chain(
1712            "1000",
1713            &customer,
1714            &material_refs,
1715            NaiveDate::from_ymd_opt(2024, 1, 15).unwrap(),
1716            2024,
1717            1,
1718            "JSMITH",
1719        );
1720
1721        assert_eq!(
1722            chain1.sales_order.header.document_id,
1723            chain2.sales_order.header.document_id
1724        );
1725        assert_eq!(
1726            chain1.sales_order.total_net_amount,
1727            chain2.sales_order.total_net_amount
1728        );
1729    }
1730
1731    #[test]
1732    fn test_partial_shipment_config() {
1733        let config = O2CGeneratorConfig {
1734            partial_shipment_rate: 1.0, // Force partial shipment
1735            ..Default::default()
1736        };
1737
1738        let mut gen = O2CGenerator::with_config(42, config);
1739        let customer = create_test_customer();
1740        let materials = create_test_materials();
1741        let material_refs: Vec<&Material> = materials.iter().collect();
1742
1743        let chain = gen.generate_chain(
1744            "1000",
1745            &customer,
1746            &material_refs,
1747            NaiveDate::from_ymd_opt(2024, 1, 15).unwrap(),
1748            2024,
1749            1,
1750            "JSMITH",
1751        );
1752
1753        // Should have multiple deliveries due to partial shipment
1754        assert!(chain.deliveries.len() >= 2);
1755    }
1756
1757    #[test]
1758    fn test_gross_margin() {
1759        let mut gen = O2CGenerator::new(42);
1760        let customer = create_test_customer();
1761        let materials = create_test_materials();
1762        let material_refs: Vec<&Material> = materials.iter().collect();
1763
1764        let chain = gen.generate_chain(
1765            "1000",
1766            &customer,
1767            &material_refs,
1768            NaiveDate::from_ymd_opt(2024, 1, 15).unwrap(),
1769            2024,
1770            1,
1771            "JSMITH",
1772        );
1773
1774        if let Some(invoice) = &chain.customer_invoice {
1775            // Gross margin should be positive (revenue > COGS)
1776            let margin = invoice.gross_margin();
1777            assert!(margin > Decimal::ZERO, "Gross margin should be positive");
1778        }
1779    }
1780
1781    #[test]
1782    fn test_partial_payment_generates_remainder() {
1783        let config = O2CGeneratorConfig {
1784            bad_debt_rate: 0.0, // Ensure payment happens
1785            payment_behavior: O2CPaymentBehavior {
1786                partial_payment_rate: 1.0, // Force partial payment
1787                short_payment_rate: 0.0,
1788                on_account_rate: 0.0,
1789                payment_correction_rate: 0.0,
1790                ..Default::default()
1791            },
1792            ..Default::default()
1793        };
1794
1795        let mut gen = O2CGenerator::with_config(42, config);
1796        let customer = create_test_customer();
1797        let materials = create_test_materials();
1798        let material_refs: Vec<&Material> = materials.iter().collect();
1799
1800        let chain = gen.generate_chain(
1801            "1000",
1802            &customer,
1803            &material_refs,
1804            NaiveDate::from_ymd_opt(2024, 1, 15).unwrap(),
1805            2024,
1806            1,
1807            "JSMITH",
1808        );
1809
1810        // Should have both PartialPayment and RemainderPayment events
1811        let has_partial = chain
1812            .payment_events
1813            .iter()
1814            .any(|e| matches!(e, PaymentEvent::PartialPayment { .. }));
1815        let has_remainder = chain
1816            .payment_events
1817            .iter()
1818            .any(|e| matches!(e, PaymentEvent::RemainderPayment(_)));
1819
1820        assert!(has_partial, "Should have a PartialPayment event");
1821        assert!(has_remainder, "Should have a RemainderPayment event");
1822        assert!(
1823            chain.payment_events.len() >= 2,
1824            "Should have at least 2 payment events (partial + remainder)"
1825        );
1826    }
1827
1828    #[test]
1829    fn test_partial_plus_remainder_equals_invoice_total() {
1830        let config = O2CGeneratorConfig {
1831            bad_debt_rate: 0.0,
1832            payment_behavior: O2CPaymentBehavior {
1833                partial_payment_rate: 1.0,
1834                short_payment_rate: 0.0,
1835                on_account_rate: 0.0,
1836                payment_correction_rate: 0.0,
1837                ..Default::default()
1838            },
1839            ..Default::default()
1840        };
1841
1842        let mut gen = O2CGenerator::with_config(42, config);
1843        let customer = create_test_customer();
1844        let materials = create_test_materials();
1845        let material_refs: Vec<&Material> = materials.iter().collect();
1846
1847        let chain = gen.generate_chain(
1848            "1000",
1849            &customer,
1850            &material_refs,
1851            NaiveDate::from_ymd_opt(2024, 1, 15).unwrap(),
1852            2024,
1853            1,
1854            "JSMITH",
1855        );
1856
1857        let invoice = chain
1858            .customer_invoice
1859            .as_ref()
1860            .expect("Should have an invoice");
1861
1862        // Extract partial payment amount
1863        let partial_amount = chain
1864            .payment_events
1865            .iter()
1866            .find_map(|e| {
1867                if let PaymentEvent::PartialPayment { payment, .. } = e {
1868                    Some(payment.amount)
1869                } else {
1870                    None
1871                }
1872            })
1873            .expect("Should have a partial payment");
1874
1875        // Extract remainder payment amount
1876        let remainder_amount = chain
1877            .payment_events
1878            .iter()
1879            .find_map(|e| {
1880                if let PaymentEvent::RemainderPayment(payment) = e {
1881                    Some(payment.amount)
1882                } else {
1883                    None
1884                }
1885            })
1886            .expect("Should have a remainder payment");
1887
1888        // partial + remainder should equal invoice total
1889        let total_paid = partial_amount + remainder_amount;
1890        assert_eq!(
1891            total_paid, invoice.total_gross_amount,
1892            "Partial ({}) + remainder ({}) = {} should equal invoice total ({})",
1893            partial_amount, remainder_amount, total_paid, invoice.total_gross_amount
1894        );
1895    }
1896
1897    #[test]
1898    fn test_remainder_receipts_vec_populated() {
1899        let config = O2CGeneratorConfig {
1900            bad_debt_rate: 0.0,
1901            payment_behavior: O2CPaymentBehavior {
1902                partial_payment_rate: 1.0,
1903                short_payment_rate: 0.0,
1904                on_account_rate: 0.0,
1905                payment_correction_rate: 0.0,
1906                ..Default::default()
1907            },
1908            ..Default::default()
1909        };
1910
1911        let mut gen = O2CGenerator::with_config(42, config);
1912        let customer = create_test_customer();
1913        let materials = create_test_materials();
1914        let material_refs: Vec<&Material> = materials.iter().collect();
1915
1916        let chain = gen.generate_chain(
1917            "1000",
1918            &customer,
1919            &material_refs,
1920            NaiveDate::from_ymd_opt(2024, 1, 15).unwrap(),
1921            2024,
1922            1,
1923            "JSMITH",
1924        );
1925
1926        assert!(
1927            !chain.remainder_receipts.is_empty(),
1928            "remainder_receipts should be populated for partial payment chains"
1929        );
1930        assert_eq!(
1931            chain.remainder_receipts.len(),
1932            1,
1933            "Should have exactly one remainder receipt"
1934        );
1935    }
1936
1937    #[test]
1938    fn test_remainder_date_after_partial_date() {
1939        let config = O2CGeneratorConfig {
1940            bad_debt_rate: 0.0,
1941            payment_behavior: O2CPaymentBehavior {
1942                partial_payment_rate: 1.0,
1943                short_payment_rate: 0.0,
1944                max_short_percent: 0.0,
1945                on_account_rate: 0.0,
1946                payment_correction_rate: 0.0,
1947                avg_days_until_remainder: 30,
1948            },
1949            ..Default::default()
1950        };
1951
1952        let mut gen = O2CGenerator::with_config(42, config);
1953        let customer = create_test_customer();
1954        let materials = create_test_materials();
1955        let material_refs: Vec<&Material> = materials.iter().collect();
1956
1957        let chain = gen.generate_chain(
1958            "1000",
1959            &customer,
1960            &material_refs,
1961            NaiveDate::from_ymd_opt(2024, 1, 15).unwrap(),
1962            2024,
1963            1,
1964            "JSMITH",
1965        );
1966
1967        // Get partial payment date (use value_date which is always set)
1968        let partial_date = chain
1969            .payment_events
1970            .iter()
1971            .find_map(|e| {
1972                if let PaymentEvent::PartialPayment { payment, .. } = e {
1973                    Some(payment.value_date)
1974                } else {
1975                    None
1976                }
1977            })
1978            .expect("Should have a partial payment");
1979
1980        // Get remainder payment date
1981        let remainder_date = chain
1982            .payment_events
1983            .iter()
1984            .find_map(|e| {
1985                if let PaymentEvent::RemainderPayment(payment) = e {
1986                    Some(payment.value_date)
1987                } else {
1988                    None
1989                }
1990            })
1991            .expect("Should have a remainder payment");
1992
1993        assert!(
1994            remainder_date > partial_date,
1995            "Remainder date ({}) should be after partial payment date ({})",
1996            remainder_date,
1997            partial_date
1998        );
1999    }
2000
2001    #[test]
2002    fn test_partial_payment_chain_is_complete() {
2003        let config = O2CGeneratorConfig {
2004            bad_debt_rate: 0.0,
2005            payment_behavior: O2CPaymentBehavior {
2006                partial_payment_rate: 1.0,
2007                short_payment_rate: 0.0,
2008                on_account_rate: 0.0,
2009                payment_correction_rate: 0.0,
2010                ..Default::default()
2011            },
2012            ..Default::default()
2013        };
2014
2015        let mut gen = O2CGenerator::with_config(42, config);
2016        let customer = create_test_customer();
2017        let materials = create_test_materials();
2018        let material_refs: Vec<&Material> = materials.iter().collect();
2019
2020        let chain = gen.generate_chain(
2021            "1000",
2022            &customer,
2023            &material_refs,
2024            NaiveDate::from_ymd_opt(2024, 1, 15).unwrap(),
2025            2024,
2026            1,
2027            "JSMITH",
2028        );
2029
2030        // With both partial and remainder, chain should be complete
2031        assert!(
2032            chain.is_complete,
2033            "Chain with partial + remainder payment should be marked complete"
2034        );
2035    }
2036
2037    #[test]
2038    fn test_non_partial_chain_has_empty_remainder_receipts() {
2039        let config = O2CGeneratorConfig {
2040            bad_debt_rate: 0.0,
2041            payment_behavior: O2CPaymentBehavior {
2042                partial_payment_rate: 0.0, // No partial payments
2043                short_payment_rate: 0.0,
2044                on_account_rate: 0.0,
2045                payment_correction_rate: 0.0,
2046                ..Default::default()
2047            },
2048            ..Default::default()
2049        };
2050
2051        let mut gen = O2CGenerator::with_config(42, config);
2052        let customer = create_test_customer();
2053        let materials = create_test_materials();
2054        let material_refs: Vec<&Material> = materials.iter().collect();
2055
2056        let chain = gen.generate_chain(
2057            "1000",
2058            &customer,
2059            &material_refs,
2060            NaiveDate::from_ymd_opt(2024, 1, 15).unwrap(),
2061            2024,
2062            1,
2063            "JSMITH",
2064        );
2065
2066        assert!(
2067            chain.remainder_receipts.is_empty(),
2068            "Non-partial payment chains should have empty remainder_receipts"
2069        );
2070    }
2071
2072    #[test]
2073    fn test_o2c_returns_rate_generates_credit_memos() {
2074        let config = O2CGeneratorConfig {
2075            returns_rate: 1.0, // Force all chains to have returns
2076            ..Default::default()
2077        };
2078        let mut gen = O2CGenerator::with_config(42, config);
2079        let customer = create_test_customer();
2080        let materials = create_test_materials();
2081        let material_refs: Vec<&Material> = materials.iter().collect();
2082
2083        let chain = gen.generate_chain(
2084            "1000",
2085            &customer,
2086            &material_refs,
2087            NaiveDate::from_ymd_opt(2024, 1, 15).unwrap(),
2088            2024,
2089            1,
2090            "JSMITH",
2091        );
2092
2093        assert!(chain.credit_check_passed);
2094        assert!(chain.is_return);
2095        assert!(chain.credit_memo.is_some());
2096    }
2097
2098    #[test]
2099    fn test_credit_memo_references_invoice() {
2100        let config = O2CGeneratorConfig {
2101            returns_rate: 1.0,
2102            ..Default::default()
2103        };
2104        let mut gen = O2CGenerator::with_config(42, config);
2105        let customer = create_test_customer();
2106        let materials = create_test_materials();
2107        let material_refs: Vec<&Material> = materials.iter().collect();
2108
2109        let chain = gen.generate_chain(
2110            "1000",
2111            &customer,
2112            &material_refs,
2113            NaiveDate::from_ymd_opt(2024, 1, 15).unwrap(),
2114            2024,
2115            1,
2116            "JSMITH",
2117        );
2118
2119        let memo = chain.credit_memo.as_ref().unwrap();
2120        let invoice = chain.customer_invoice.as_ref().unwrap();
2121        assert_eq!(
2122            memo.reference_invoice.as_deref(),
2123            Some(invoice.header.document_id.as_str())
2124        );
2125    }
2126
2127    #[test]
2128    fn test_credit_memo_amount_bounded() {
2129        let config = O2CGeneratorConfig {
2130            returns_rate: 1.0,
2131            ..Default::default()
2132        };
2133        let _ = O2CGenerator::with_config(42, config);
2134        let customer = create_test_customer();
2135        let materials = create_test_materials();
2136        let material_refs: Vec<&Material> = materials.iter().collect();
2137
2138        for seed in 0..10 {
2139            let mut gen = O2CGenerator::with_config(
2140                seed,
2141                O2CGeneratorConfig {
2142                    returns_rate: 1.0,
2143                    ..Default::default()
2144                },
2145            );
2146            let chain = gen.generate_chain(
2147                "1000",
2148                &customer,
2149                &material_refs,
2150                NaiveDate::from_ymd_opt(2024, 1, 15).unwrap(),
2151                2024,
2152                1,
2153                "JSMITH",
2154            );
2155            if let (Some(memo), Some(invoice)) = (&chain.credit_memo, &chain.customer_invoice) {
2156                assert!(
2157                    memo.gross_amount.document_amount <= invoice.total_gross_amount,
2158                    "Credit memo gross {:?} exceeds invoice gross {}",
2159                    memo.gross_amount.document_amount,
2160                    invoice.total_gross_amount
2161                );
2162            }
2163        }
2164    }
2165
2166    #[test]
2167    fn test_zero_returns_rate() {
2168        let customer = create_test_customer();
2169        let materials = create_test_materials();
2170        let material_refs: Vec<&Material> = materials.iter().collect();
2171
2172        for seed in 0..20 {
2173            let mut gen = O2CGenerator::with_config(
2174                seed,
2175                O2CGeneratorConfig {
2176                    returns_rate: 0.0,
2177                    ..Default::default()
2178                },
2179            );
2180            let chain = gen.generate_chain(
2181                "1000",
2182                &customer,
2183                &material_refs,
2184                NaiveDate::from_ymd_opt(2024, 1, 15).unwrap(),
2185                2024,
2186                1,
2187                "JSMITH",
2188            );
2189            assert!(
2190                chain.credit_memo.is_none(),
2191                "No credit memos with returns_rate=0"
2192            );
2193            assert!(!chain.is_return);
2194        }
2195    }
2196}