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)]
1565#[allow(clippy::unwrap_used)]
1566mod tests {
1567    use super::*;
1568    use datasynth_core::models::{CustomerPaymentBehavior, MaterialType};
1569
1570    fn create_test_customer() -> Customer {
1571        let mut customer = Customer::new(
1572            "C-000001",
1573            "Test Customer Inc.",
1574            datasynth_core::models::CustomerType::Corporate,
1575        );
1576        customer.credit_rating = CreditRating::A;
1577        customer.credit_limit = Decimal::from(1_000_000);
1578        customer.payment_behavior = CustomerPaymentBehavior::OnTime;
1579        customer
1580    }
1581
1582    fn create_test_materials() -> Vec<Material> {
1583        let mut mat1 = Material::new("MAT-001", "Test Product 1", MaterialType::FinishedGood);
1584        mat1.list_price = Decimal::from(100);
1585        mat1.standard_cost = Decimal::from(60);
1586
1587        let mut mat2 = Material::new("MAT-002", "Test Product 2", MaterialType::FinishedGood);
1588        mat2.list_price = Decimal::from(200);
1589        mat2.standard_cost = Decimal::from(120);
1590
1591        vec![mat1, mat2]
1592    }
1593
1594    #[test]
1595    fn test_o2c_chain_generation() {
1596        let mut gen = O2CGenerator::new(42);
1597        let customer = create_test_customer();
1598        let materials = create_test_materials();
1599        let material_refs: Vec<&Material> = materials.iter().collect();
1600
1601        let chain = gen.generate_chain(
1602            "1000",
1603            &customer,
1604            &material_refs,
1605            NaiveDate::from_ymd_opt(2024, 1, 15).unwrap(),
1606            2024,
1607            1,
1608            "JSMITH",
1609        );
1610
1611        assert!(!chain.sales_order.items.is_empty());
1612        assert!(chain.credit_check_passed);
1613        assert!(!chain.deliveries.is_empty());
1614        assert!(chain.customer_invoice.is_some());
1615    }
1616
1617    #[test]
1618    fn test_sales_order_generation() {
1619        let mut gen = O2CGenerator::new(42);
1620        let customer = create_test_customer();
1621        let materials = create_test_materials();
1622        let material_refs: Vec<&Material> = materials.iter().collect();
1623
1624        let so = gen.generate_sales_order(
1625            "1000",
1626            &customer,
1627            &material_refs,
1628            NaiveDate::from_ymd_opt(2024, 1, 15).unwrap(),
1629            2024,
1630            1,
1631            "JSMITH",
1632        );
1633
1634        assert_eq!(so.customer_id, "C-000001");
1635        assert_eq!(so.items.len(), 2);
1636        assert!(so.total_net_amount > Decimal::ZERO);
1637    }
1638
1639    #[test]
1640    fn test_credit_check_failure() {
1641        let config = O2CGeneratorConfig {
1642            credit_check_failure_rate: 1.0, // Force failure
1643            ..Default::default()
1644        };
1645
1646        let mut gen = O2CGenerator::with_config(42, config);
1647        let customer = create_test_customer();
1648        let materials = create_test_materials();
1649        let material_refs: Vec<&Material> = materials.iter().collect();
1650
1651        let chain = gen.generate_chain(
1652            "1000",
1653            &customer,
1654            &material_refs,
1655            NaiveDate::from_ymd_opt(2024, 1, 15).unwrap(),
1656            2024,
1657            1,
1658            "JSMITH",
1659        );
1660
1661        assert!(!chain.credit_check_passed);
1662        assert!(chain.deliveries.is_empty());
1663        assert!(chain.customer_invoice.is_none());
1664    }
1665
1666    #[test]
1667    fn test_document_references() {
1668        let mut gen = O2CGenerator::new(42);
1669        let customer = create_test_customer();
1670        let materials = create_test_materials();
1671        let material_refs: Vec<&Material> = materials.iter().collect();
1672
1673        let chain = gen.generate_chain(
1674            "1000",
1675            &customer,
1676            &material_refs,
1677            NaiveDate::from_ymd_opt(2024, 1, 15).unwrap(),
1678            2024,
1679            1,
1680            "JSMITH",
1681        );
1682
1683        // Delivery should reference SO
1684        if let Some(dlv) = chain.deliveries.first() {
1685            assert!(!dlv.header.document_references.is_empty());
1686        }
1687
1688        // Invoice should reference SO and Delivery
1689        if let Some(invoice) = &chain.customer_invoice {
1690            assert!(invoice.header.document_references.len() >= 2);
1691        }
1692    }
1693
1694    #[test]
1695    fn test_deterministic_generation() {
1696        let customer = create_test_customer();
1697        let materials = create_test_materials();
1698        let material_refs: Vec<&Material> = materials.iter().collect();
1699
1700        let mut gen1 = O2CGenerator::new(42);
1701        let mut gen2 = O2CGenerator::new(42);
1702
1703        let chain1 = gen1.generate_chain(
1704            "1000",
1705            &customer,
1706            &material_refs,
1707            NaiveDate::from_ymd_opt(2024, 1, 15).unwrap(),
1708            2024,
1709            1,
1710            "JSMITH",
1711        );
1712        let chain2 = gen2.generate_chain(
1713            "1000",
1714            &customer,
1715            &material_refs,
1716            NaiveDate::from_ymd_opt(2024, 1, 15).unwrap(),
1717            2024,
1718            1,
1719            "JSMITH",
1720        );
1721
1722        assert_eq!(
1723            chain1.sales_order.header.document_id,
1724            chain2.sales_order.header.document_id
1725        );
1726        assert_eq!(
1727            chain1.sales_order.total_net_amount,
1728            chain2.sales_order.total_net_amount
1729        );
1730    }
1731
1732    #[test]
1733    fn test_partial_shipment_config() {
1734        let config = O2CGeneratorConfig {
1735            partial_shipment_rate: 1.0, // Force partial shipment
1736            ..Default::default()
1737        };
1738
1739        let mut gen = O2CGenerator::with_config(42, config);
1740        let customer = create_test_customer();
1741        let materials = create_test_materials();
1742        let material_refs: Vec<&Material> = materials.iter().collect();
1743
1744        let chain = gen.generate_chain(
1745            "1000",
1746            &customer,
1747            &material_refs,
1748            NaiveDate::from_ymd_opt(2024, 1, 15).unwrap(),
1749            2024,
1750            1,
1751            "JSMITH",
1752        );
1753
1754        // Should have multiple deliveries due to partial shipment
1755        assert!(chain.deliveries.len() >= 2);
1756    }
1757
1758    #[test]
1759    fn test_gross_margin() {
1760        let mut gen = O2CGenerator::new(42);
1761        let customer = create_test_customer();
1762        let materials = create_test_materials();
1763        let material_refs: Vec<&Material> = materials.iter().collect();
1764
1765        let chain = gen.generate_chain(
1766            "1000",
1767            &customer,
1768            &material_refs,
1769            NaiveDate::from_ymd_opt(2024, 1, 15).unwrap(),
1770            2024,
1771            1,
1772            "JSMITH",
1773        );
1774
1775        if let Some(invoice) = &chain.customer_invoice {
1776            // Gross margin should be positive (revenue > COGS)
1777            let margin = invoice.gross_margin();
1778            assert!(margin > Decimal::ZERO, "Gross margin should be positive");
1779        }
1780    }
1781
1782    #[test]
1783    fn test_partial_payment_generates_remainder() {
1784        let config = O2CGeneratorConfig {
1785            bad_debt_rate: 0.0, // Ensure payment happens
1786            payment_behavior: O2CPaymentBehavior {
1787                partial_payment_rate: 1.0, // Force partial payment
1788                short_payment_rate: 0.0,
1789                on_account_rate: 0.0,
1790                payment_correction_rate: 0.0,
1791                ..Default::default()
1792            },
1793            ..Default::default()
1794        };
1795
1796        let mut gen = O2CGenerator::with_config(42, config);
1797        let customer = create_test_customer();
1798        let materials = create_test_materials();
1799        let material_refs: Vec<&Material> = materials.iter().collect();
1800
1801        let chain = gen.generate_chain(
1802            "1000",
1803            &customer,
1804            &material_refs,
1805            NaiveDate::from_ymd_opt(2024, 1, 15).unwrap(),
1806            2024,
1807            1,
1808            "JSMITH",
1809        );
1810
1811        // Should have both PartialPayment and RemainderPayment events
1812        let has_partial = chain
1813            .payment_events
1814            .iter()
1815            .any(|e| matches!(e, PaymentEvent::PartialPayment { .. }));
1816        let has_remainder = chain
1817            .payment_events
1818            .iter()
1819            .any(|e| matches!(e, PaymentEvent::RemainderPayment(_)));
1820
1821        assert!(has_partial, "Should have a PartialPayment event");
1822        assert!(has_remainder, "Should have a RemainderPayment event");
1823        assert!(
1824            chain.payment_events.len() >= 2,
1825            "Should have at least 2 payment events (partial + remainder)"
1826        );
1827    }
1828
1829    #[test]
1830    fn test_partial_plus_remainder_equals_invoice_total() {
1831        let config = O2CGeneratorConfig {
1832            bad_debt_rate: 0.0,
1833            payment_behavior: O2CPaymentBehavior {
1834                partial_payment_rate: 1.0,
1835                short_payment_rate: 0.0,
1836                on_account_rate: 0.0,
1837                payment_correction_rate: 0.0,
1838                ..Default::default()
1839            },
1840            ..Default::default()
1841        };
1842
1843        let mut gen = O2CGenerator::with_config(42, config);
1844        let customer = create_test_customer();
1845        let materials = create_test_materials();
1846        let material_refs: Vec<&Material> = materials.iter().collect();
1847
1848        let chain = gen.generate_chain(
1849            "1000",
1850            &customer,
1851            &material_refs,
1852            NaiveDate::from_ymd_opt(2024, 1, 15).unwrap(),
1853            2024,
1854            1,
1855            "JSMITH",
1856        );
1857
1858        let invoice = chain
1859            .customer_invoice
1860            .as_ref()
1861            .expect("Should have an invoice");
1862
1863        // Extract partial payment amount
1864        let partial_amount = chain
1865            .payment_events
1866            .iter()
1867            .find_map(|e| {
1868                if let PaymentEvent::PartialPayment { payment, .. } = e {
1869                    Some(payment.amount)
1870                } else {
1871                    None
1872                }
1873            })
1874            .expect("Should have a partial payment");
1875
1876        // Extract remainder payment amount
1877        let remainder_amount = chain
1878            .payment_events
1879            .iter()
1880            .find_map(|e| {
1881                if let PaymentEvent::RemainderPayment(payment) = e {
1882                    Some(payment.amount)
1883                } else {
1884                    None
1885                }
1886            })
1887            .expect("Should have a remainder payment");
1888
1889        // partial + remainder should equal invoice total
1890        let total_paid = partial_amount + remainder_amount;
1891        assert_eq!(
1892            total_paid, invoice.total_gross_amount,
1893            "Partial ({}) + remainder ({}) = {} should equal invoice total ({})",
1894            partial_amount, remainder_amount, total_paid, invoice.total_gross_amount
1895        );
1896    }
1897
1898    #[test]
1899    fn test_remainder_receipts_vec_populated() {
1900        let config = O2CGeneratorConfig {
1901            bad_debt_rate: 0.0,
1902            payment_behavior: O2CPaymentBehavior {
1903                partial_payment_rate: 1.0,
1904                short_payment_rate: 0.0,
1905                on_account_rate: 0.0,
1906                payment_correction_rate: 0.0,
1907                ..Default::default()
1908            },
1909            ..Default::default()
1910        };
1911
1912        let mut gen = O2CGenerator::with_config(42, config);
1913        let customer = create_test_customer();
1914        let materials = create_test_materials();
1915        let material_refs: Vec<&Material> = materials.iter().collect();
1916
1917        let chain = gen.generate_chain(
1918            "1000",
1919            &customer,
1920            &material_refs,
1921            NaiveDate::from_ymd_opt(2024, 1, 15).unwrap(),
1922            2024,
1923            1,
1924            "JSMITH",
1925        );
1926
1927        assert!(
1928            !chain.remainder_receipts.is_empty(),
1929            "remainder_receipts should be populated for partial payment chains"
1930        );
1931        assert_eq!(
1932            chain.remainder_receipts.len(),
1933            1,
1934            "Should have exactly one remainder receipt"
1935        );
1936    }
1937
1938    #[test]
1939    fn test_remainder_date_after_partial_date() {
1940        let config = O2CGeneratorConfig {
1941            bad_debt_rate: 0.0,
1942            payment_behavior: O2CPaymentBehavior {
1943                partial_payment_rate: 1.0,
1944                short_payment_rate: 0.0,
1945                max_short_percent: 0.0,
1946                on_account_rate: 0.0,
1947                payment_correction_rate: 0.0,
1948                avg_days_until_remainder: 30,
1949            },
1950            ..Default::default()
1951        };
1952
1953        let mut gen = O2CGenerator::with_config(42, config);
1954        let customer = create_test_customer();
1955        let materials = create_test_materials();
1956        let material_refs: Vec<&Material> = materials.iter().collect();
1957
1958        let chain = gen.generate_chain(
1959            "1000",
1960            &customer,
1961            &material_refs,
1962            NaiveDate::from_ymd_opt(2024, 1, 15).unwrap(),
1963            2024,
1964            1,
1965            "JSMITH",
1966        );
1967
1968        // Get partial payment date (use value_date which is always set)
1969        let partial_date = chain
1970            .payment_events
1971            .iter()
1972            .find_map(|e| {
1973                if let PaymentEvent::PartialPayment { payment, .. } = e {
1974                    Some(payment.value_date)
1975                } else {
1976                    None
1977                }
1978            })
1979            .expect("Should have a partial payment");
1980
1981        // Get remainder payment date
1982        let remainder_date = chain
1983            .payment_events
1984            .iter()
1985            .find_map(|e| {
1986                if let PaymentEvent::RemainderPayment(payment) = e {
1987                    Some(payment.value_date)
1988                } else {
1989                    None
1990                }
1991            })
1992            .expect("Should have a remainder payment");
1993
1994        assert!(
1995            remainder_date > partial_date,
1996            "Remainder date ({}) should be after partial payment date ({})",
1997            remainder_date,
1998            partial_date
1999        );
2000    }
2001
2002    #[test]
2003    fn test_partial_payment_chain_is_complete() {
2004        let config = O2CGeneratorConfig {
2005            bad_debt_rate: 0.0,
2006            payment_behavior: O2CPaymentBehavior {
2007                partial_payment_rate: 1.0,
2008                short_payment_rate: 0.0,
2009                on_account_rate: 0.0,
2010                payment_correction_rate: 0.0,
2011                ..Default::default()
2012            },
2013            ..Default::default()
2014        };
2015
2016        let mut gen = O2CGenerator::with_config(42, config);
2017        let customer = create_test_customer();
2018        let materials = create_test_materials();
2019        let material_refs: Vec<&Material> = materials.iter().collect();
2020
2021        let chain = gen.generate_chain(
2022            "1000",
2023            &customer,
2024            &material_refs,
2025            NaiveDate::from_ymd_opt(2024, 1, 15).unwrap(),
2026            2024,
2027            1,
2028            "JSMITH",
2029        );
2030
2031        // With both partial and remainder, chain should be complete
2032        assert!(
2033            chain.is_complete,
2034            "Chain with partial + remainder payment should be marked complete"
2035        );
2036    }
2037
2038    #[test]
2039    fn test_non_partial_chain_has_empty_remainder_receipts() {
2040        let config = O2CGeneratorConfig {
2041            bad_debt_rate: 0.0,
2042            payment_behavior: O2CPaymentBehavior {
2043                partial_payment_rate: 0.0, // No partial payments
2044                short_payment_rate: 0.0,
2045                on_account_rate: 0.0,
2046                payment_correction_rate: 0.0,
2047                ..Default::default()
2048            },
2049            ..Default::default()
2050        };
2051
2052        let mut gen = O2CGenerator::with_config(42, config);
2053        let customer = create_test_customer();
2054        let materials = create_test_materials();
2055        let material_refs: Vec<&Material> = materials.iter().collect();
2056
2057        let chain = gen.generate_chain(
2058            "1000",
2059            &customer,
2060            &material_refs,
2061            NaiveDate::from_ymd_opt(2024, 1, 15).unwrap(),
2062            2024,
2063            1,
2064            "JSMITH",
2065        );
2066
2067        assert!(
2068            chain.remainder_receipts.is_empty(),
2069            "Non-partial payment chains should have empty remainder_receipts"
2070        );
2071    }
2072
2073    #[test]
2074    fn test_o2c_returns_rate_generates_credit_memos() {
2075        let config = O2CGeneratorConfig {
2076            returns_rate: 1.0, // Force all chains to have returns
2077            ..Default::default()
2078        };
2079        let mut gen = O2CGenerator::with_config(42, config);
2080        let customer = create_test_customer();
2081        let materials = create_test_materials();
2082        let material_refs: Vec<&Material> = materials.iter().collect();
2083
2084        let chain = gen.generate_chain(
2085            "1000",
2086            &customer,
2087            &material_refs,
2088            NaiveDate::from_ymd_opt(2024, 1, 15).unwrap(),
2089            2024,
2090            1,
2091            "JSMITH",
2092        );
2093
2094        assert!(chain.credit_check_passed);
2095        assert!(chain.is_return);
2096        assert!(chain.credit_memo.is_some());
2097    }
2098
2099    #[test]
2100    fn test_credit_memo_references_invoice() {
2101        let config = O2CGeneratorConfig {
2102            returns_rate: 1.0,
2103            ..Default::default()
2104        };
2105        let mut gen = O2CGenerator::with_config(42, config);
2106        let customer = create_test_customer();
2107        let materials = create_test_materials();
2108        let material_refs: Vec<&Material> = materials.iter().collect();
2109
2110        let chain = gen.generate_chain(
2111            "1000",
2112            &customer,
2113            &material_refs,
2114            NaiveDate::from_ymd_opt(2024, 1, 15).unwrap(),
2115            2024,
2116            1,
2117            "JSMITH",
2118        );
2119
2120        let memo = chain.credit_memo.as_ref().unwrap();
2121        let invoice = chain.customer_invoice.as_ref().unwrap();
2122        assert_eq!(
2123            memo.reference_invoice.as_deref(),
2124            Some(invoice.header.document_id.as_str())
2125        );
2126    }
2127
2128    #[test]
2129    fn test_credit_memo_amount_bounded() {
2130        let config = O2CGeneratorConfig {
2131            returns_rate: 1.0,
2132            ..Default::default()
2133        };
2134        let _ = O2CGenerator::with_config(42, config);
2135        let customer = create_test_customer();
2136        let materials = create_test_materials();
2137        let material_refs: Vec<&Material> = materials.iter().collect();
2138
2139        for seed in 0..10 {
2140            let mut gen = O2CGenerator::with_config(
2141                seed,
2142                O2CGeneratorConfig {
2143                    returns_rate: 1.0,
2144                    ..Default::default()
2145                },
2146            );
2147            let chain = gen.generate_chain(
2148                "1000",
2149                &customer,
2150                &material_refs,
2151                NaiveDate::from_ymd_opt(2024, 1, 15).unwrap(),
2152                2024,
2153                1,
2154                "JSMITH",
2155            );
2156            if let (Some(memo), Some(invoice)) = (&chain.credit_memo, &chain.customer_invoice) {
2157                assert!(
2158                    memo.gross_amount.document_amount <= invoice.total_gross_amount,
2159                    "Credit memo gross {:?} exceeds invoice gross {}",
2160                    memo.gross_amount.document_amount,
2161                    invoice.total_gross_amount
2162                );
2163            }
2164        }
2165    }
2166
2167    #[test]
2168    fn test_zero_returns_rate() {
2169        let customer = create_test_customer();
2170        let materials = create_test_materials();
2171        let material_refs: Vec<&Material> = materials.iter().collect();
2172
2173        for seed in 0..20 {
2174            let mut gen = O2CGenerator::with_config(
2175                seed,
2176                O2CGeneratorConfig {
2177                    returns_rate: 0.0,
2178                    ..Default::default()
2179                },
2180            );
2181            let chain = gen.generate_chain(
2182                "1000",
2183                &customer,
2184                &material_refs,
2185                NaiveDate::from_ymd_opt(2024, 1, 15).unwrap(),
2186                2024,
2187                1,
2188                "JSMITH",
2189            );
2190            assert!(
2191                chain.credit_memo.is_none(),
2192                "No credit memos with returns_rate=0"
2193            );
2194            assert!(!chain.is_return);
2195        }
2196    }
2197}