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