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