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        // Denormalize customer name (DS-011)
528        so.customer_name = Some(customer.name.clone());
529
530        // Add line items
531        for (idx, material) in materials.iter().enumerate() {
532            let quantity = Decimal::from(self.rng.random_range(1..50));
533            let unit_price = material.list_price;
534
535            let description = self.pick_line_description("sales_order", &material.description);
536            let mut item =
537                SalesOrderItem::new((idx + 1) as u16 * 10, &description, quantity, unit_price)
538                    .with_material(&material.material_id);
539
540            // Add schedule line
541            item.add_schedule_line(requested_delivery, quantity);
542
543            so.add_item(item);
544        }
545
546        so
547    }
548
549    /// Generate deliveries for a sales order.
550    fn generate_deliveries(
551        &mut self,
552        so: &SalesOrder,
553        company_code: &str,
554        customer: &Customer,
555        delivery_date: NaiveDate,
556        fiscal_year: u16,
557        fiscal_period: u8,
558        created_by: &str,
559    ) -> Vec<Delivery> {
560        let mut deliveries = Vec::new();
561
562        // Determine if partial shipment
563        let is_partial = self.rng.random::<f64>() < self.config.partial_shipment_rate;
564
565        if is_partial {
566            // First shipment (60-80%)
567            let first_pct = 0.6 + self.rng.random::<f64>() * 0.2;
568            let dlv1 = self.create_delivery(
569                so,
570                company_code,
571                customer,
572                delivery_date,
573                fiscal_year,
574                fiscal_period,
575                created_by,
576                first_pct,
577            );
578            deliveries.push(dlv1);
579
580            // Second shipment
581            let second_date =
582                delivery_date + chrono::Duration::days(self.rng.random_range(3..7) as i64);
583            let second_period = self.get_fiscal_period(second_date);
584            let dlv2 = self.create_delivery(
585                so,
586                company_code,
587                customer,
588                second_date,
589                fiscal_year,
590                second_period,
591                created_by,
592                1.0 - first_pct,
593            );
594            deliveries.push(dlv2);
595        } else {
596            // Full shipment
597            let dlv = self.create_delivery(
598                so,
599                company_code,
600                customer,
601                delivery_date,
602                fiscal_year,
603                fiscal_period,
604                created_by,
605                1.0,
606            );
607            deliveries.push(dlv);
608        }
609
610        deliveries
611    }
612
613    /// Create a single delivery.
614    fn create_delivery(
615        &mut self,
616        so: &SalesOrder,
617        company_code: &str,
618        customer: &Customer,
619        delivery_date: NaiveDate,
620        fiscal_year: u16,
621        fiscal_period: u8,
622        created_by: &str,
623        quantity_pct: f64,
624    ) -> Delivery {
625        self.dlv_counter += 1;
626
627        let dlv_id = self.make_doc_id("DLV", "delivery", company_code, self.dlv_counter);
628
629        let mut delivery = Delivery::from_sales_order(
630            dlv_id,
631            company_code,
632            &so.header.document_id,
633            &customer.customer_id,
634            format!("SP{}", company_code),
635            fiscal_year,
636            fiscal_period,
637            delivery_date,
638            created_by,
639        );
640
641        // Add items based on SO items
642        for so_item in &so.items {
643            let ship_qty = (so_item.base.quantity
644                * Decimal::from_f64_retain(quantity_pct).unwrap_or(Decimal::ONE))
645            .round_dp(0);
646
647            if ship_qty > Decimal::ZERO {
648                // Calculate COGS (assume 60-70% of sales price)
649                let cogs_pct = 0.60 + self.rng.random::<f64>() * 0.10;
650                let cogs = (so_item.base.unit_price
651                    * ship_qty
652                    * Decimal::from_f64_retain(cogs_pct)
653                        .unwrap_or(Decimal::from_f64_retain(0.65).expect("valid decimal literal")))
654                .round_dp(2);
655
656                let dlv_description =
657                    self.pick_line_description("delivery", &so_item.base.description);
658                let mut item = DeliveryItem::from_sales_order(
659                    so_item.base.line_number,
660                    &dlv_description,
661                    ship_qty,
662                    so_item.base.unit_price,
663                    &so.header.document_id,
664                    so_item.base.line_number,
665                )
666                .with_cogs(cogs);
667
668                if let Some(material_id) = &so_item.base.material_id {
669                    item = item.with_material(material_id);
670                }
671
672                // Mark as picked
673                item.record_pick(ship_qty);
674
675                delivery.add_item(item);
676            }
677        }
678
679        // Process delivery workflow
680        delivery.release_for_picking(created_by);
681        delivery.confirm_pick();
682        delivery.confirm_pack(self.rng.random_range(1..10));
683        delivery.post_goods_issue(created_by, delivery_date);
684
685        delivery
686    }
687
688    /// Generate customer invoice.
689    fn generate_customer_invoice(
690        &mut self,
691        so: &SalesOrder,
692        deliveries: &[Delivery],
693        company_code: &str,
694        customer: &Customer,
695        invoice_date: NaiveDate,
696        fiscal_year: u16,
697        fiscal_period: u8,
698        created_by: &str,
699    ) -> CustomerInvoice {
700        self.ci_counter += 1;
701
702        let invoice_id = self.make_doc_id("CI", "customer_invoice", company_code, self.ci_counter);
703
704        // Calculate due date based on payment terms
705        let due_date = self.calculate_due_date(invoice_date, &customer.payment_terms);
706
707        let mut invoice = CustomerInvoice::from_delivery(
708            invoice_id,
709            company_code,
710            &deliveries[0].header.document_id,
711            &customer.customer_id,
712            fiscal_year,
713            fiscal_period,
714            invoice_date,
715            due_date,
716            created_by,
717        )
718        .with_payment_terms(
719            customer.payment_terms.code(),
720            customer.payment_terms.discount_days(),
721            customer.payment_terms.discount_percent(),
722        );
723
724        // Denormalize customer name (DS-011)
725        invoice.customer_name = Some(customer.name.clone());
726
727        // Calculate total delivered quantity per item
728        let mut delivered_quantities: std::collections::HashMap<u16, (Decimal, Decimal)> =
729            std::collections::HashMap::new();
730
731        for dlv in deliveries {
732            for dlv_item in &dlv.items {
733                let entry = delivered_quantities
734                    .entry(dlv_item.base.line_number)
735                    .or_insert((Decimal::ZERO, Decimal::ZERO));
736                entry.0 += dlv_item.base.quantity;
737                entry.1 += dlv_item.cogs_amount;
738            }
739        }
740
741        // Add invoice items based on delivered quantities
742        for so_item in &so.items {
743            if let Some(&(qty, cogs)) = delivered_quantities.get(&so_item.base.line_number) {
744                let ci_description =
745                    self.pick_line_description("customer_invoice", &so_item.base.description);
746                let item = CustomerInvoiceItem::from_delivery(
747                    so_item.base.line_number,
748                    &ci_description,
749                    qty,
750                    so_item.base.unit_price,
751                    &deliveries[0].header.document_id,
752                    so_item.base.line_number,
753                )
754                .with_cogs(cogs)
755                .with_sales_order(&so.header.document_id, so_item.base.line_number);
756
757                invoice.add_item(item);
758            }
759        }
760
761        // Link to SO
762        invoice.header.add_reference(DocumentReference::new(
763            DocumentType::SalesOrder,
764            &so.header.document_id,
765            DocumentType::CustomerInvoice,
766            &invoice.header.document_id,
767            ReferenceType::FollowOn,
768            company_code,
769            invoice_date,
770        ));
771
772        // Link to all deliveries
773        for dlv in deliveries {
774            invoice.header.add_reference(DocumentReference::new(
775                DocumentType::Delivery,
776                &dlv.header.document_id,
777                DocumentType::CustomerInvoice,
778                &invoice.header.document_id,
779                ReferenceType::FollowOn,
780                company_code,
781                invoice_date,
782            ));
783        }
784
785        // Post the invoice
786        invoice.post(created_by, invoice_date);
787
788        invoice
789    }
790
791    /// Generate customer receipt (AR payment).
792    fn generate_customer_receipt(
793        &mut self,
794        invoice: &CustomerInvoice,
795        company_code: &str,
796        customer: &Customer,
797        payment_date: NaiveDate,
798        fiscal_year: u16,
799        fiscal_period: u8,
800        created_by: &str,
801    ) -> Payment {
802        self.rec_counter += 1;
803
804        let receipt_id =
805            self.make_doc_id("REC", "customer_receipt", company_code, self.rec_counter);
806
807        // Determine if cash discount taken
808        let take_discount = invoice.discount_date_1.is_some_and(|disc_date| {
809            payment_date <= disc_date
810                && self.rng.random::<f64>() < self.config.cash_discount_take_rate
811        });
812
813        let discount_amount = if take_discount {
814            invoice.cash_discount_available(payment_date)
815        } else {
816            Decimal::ZERO
817        };
818
819        let payment_amount = invoice.amount_open - discount_amount;
820
821        let mut receipt = Payment::new_ar_receipt(
822            receipt_id,
823            company_code,
824            &customer.customer_id,
825            payment_amount,
826            fiscal_year,
827            fiscal_period,
828            payment_date,
829            created_by,
830        )
831        .with_payment_method(self.select_payment_method())
832        .with_value_date(payment_date);
833
834        // Allocate to invoice
835        receipt.allocate_to_invoice(
836            &invoice.header.document_id,
837            DocumentType::CustomerInvoice,
838            payment_amount,
839            discount_amount,
840        );
841
842        // Add document reference linking receipt to invoice
843        receipt.header.add_reference(DocumentReference::new(
844            DocumentType::CustomerReceipt,
845            &receipt.header.document_id,
846            DocumentType::CustomerInvoice,
847            &invoice.header.document_id,
848            ReferenceType::Payment,
849            &receipt.header.company_code,
850            payment_date,
851        ));
852
853        // Post the receipt
854        receipt.post(created_by, payment_date);
855
856        receipt
857    }
858
859    /// Generate multiple O2C chains.
860    pub fn generate_chains(
861        &mut self,
862        count: usize,
863        company_code: &str,
864        customers: &CustomerPool,
865        materials: &MaterialPool,
866        date_range: (NaiveDate, NaiveDate),
867        fiscal_year: u16,
868        created_by: &str,
869    ) -> Vec<O2CDocumentChain> {
870        tracing::debug!(count, company_code, "Generating O2C document chains");
871        let mut chains = Vec::new();
872
873        let (start_date, end_date) = date_range;
874        let days_range = (end_date - start_date).num_days() as u64;
875
876        for _ in 0..count {
877            // Select random customer
878            let customer_idx = self.rng.random_range(0..customers.customers.len());
879            let customer = &customers.customers[customer_idx];
880
881            // Select random materials (1-5 items per SO)
882            let num_items = self.rng.random_range(1..=5).min(materials.materials.len());
883            let selected_materials: Vec<&Material> = materials
884                .materials
885                .iter()
886                .choose_multiple(&mut self.rng, num_items)
887                .into_iter()
888                .collect();
889
890            // Select random SO date
891            let so_date =
892                start_date + chrono::Duration::days(self.rng.random_range(0..=days_range) as i64);
893            let fiscal_period = self.get_fiscal_period(so_date);
894
895            let chain = self.generate_chain(
896                company_code,
897                customer,
898                &selected_materials,
899                so_date,
900                fiscal_year,
901                fiscal_period,
902                created_by,
903            );
904
905            chains.push(chain);
906        }
907
908        chains
909    }
910
911    /// Perform credit check for customer.
912    fn perform_credit_check(&mut self, customer: &Customer, order_amount: Decimal) -> bool {
913        // Check credit limit
914        if !customer.can_place_order(order_amount) {
915            return false;
916        }
917
918        // Additional random failure based on config
919        let fail_roll = self.rng.random::<f64>();
920        if fail_roll < self.config.credit_check_failure_rate {
921            return false;
922        }
923
924        // Higher risk customers have higher failure rate
925        let additional_fail_rate = match customer.credit_rating {
926            CreditRating::CCC | CreditRating::D => 0.20,
927            CreditRating::B | CreditRating::BB => 0.05,
928            _ => 0.0,
929        };
930
931        self.rng.random::<f64>() >= additional_fail_rate
932    }
933
934    /// Calculate delivery date from SO date.
935    fn calculate_delivery_date(&mut self, so_date: NaiveDate) -> NaiveDate {
936        let variance = self.rng.random_range(0..3) as i64;
937        so_date + chrono::Duration::days(self.config.avg_days_so_to_delivery as i64 + variance)
938    }
939
940    /// Calculate invoice date from delivery date.
941    fn calculate_invoice_date(&mut self, delivery_date: NaiveDate) -> NaiveDate {
942        let variance = self.rng.random_range(0..2) as i64;
943        delivery_date
944            + chrono::Duration::days(self.config.avg_days_delivery_to_invoice as i64 + variance)
945    }
946
947    /// Calculate payment date based on customer behavior.
948    fn calculate_payment_date(
949        &mut self,
950        invoice_date: NaiveDate,
951        payment_terms: &PaymentTerms,
952        customer: &Customer,
953    ) -> NaiveDate {
954        let base_days = payment_terms.net_days() as i64;
955
956        // Adjust based on customer payment behavior
957        let behavior_adjustment = match customer.payment_behavior {
958            datasynth_core::models::CustomerPaymentBehavior::Excellent
959            | datasynth_core::models::CustomerPaymentBehavior::EarlyPayer => {
960                -self.rng.random_range(5..15) as i64
961            }
962            datasynth_core::models::CustomerPaymentBehavior::Good
963            | datasynth_core::models::CustomerPaymentBehavior::OnTime => {
964                self.rng.random_range(-2..3) as i64
965            }
966            datasynth_core::models::CustomerPaymentBehavior::Fair
967            | datasynth_core::models::CustomerPaymentBehavior::SlightlyLate => {
968                self.rng.random_range(5..15) as i64
969            }
970            datasynth_core::models::CustomerPaymentBehavior::Poor
971            | datasynth_core::models::CustomerPaymentBehavior::OftenLate => {
972                self.rng.random_range(15..45) as i64
973            }
974            datasynth_core::models::CustomerPaymentBehavior::VeryPoor
975            | datasynth_core::models::CustomerPaymentBehavior::HighRisk => {
976                self.rng.random_range(30..90) as i64
977            }
978        };
979
980        // Additional random late payment
981        let late_adjustment = if self.rng.random::<f64>() < self.config.late_payment_rate {
982            self.rng.random_range(10..30) as i64
983        } else {
984            0
985        };
986
987        invoice_date + chrono::Duration::days(base_days + behavior_adjustment + late_adjustment)
988    }
989
990    /// Calculate due date based on payment terms.
991    fn calculate_due_date(
992        &self,
993        invoice_date: NaiveDate,
994        payment_terms: &PaymentTerms,
995    ) -> NaiveDate {
996        invoice_date + chrono::Duration::days(payment_terms.net_days() as i64)
997    }
998
999    /// Select payment method based on distribution.
1000    fn select_payment_method(&mut self) -> PaymentMethod {
1001        let roll: f64 = self.rng.random();
1002        let mut cumulative = 0.0;
1003
1004        for (method, prob) in &self.config.payment_method_distribution {
1005            cumulative += prob;
1006            if roll < cumulative {
1007                return *method;
1008            }
1009        }
1010
1011        PaymentMethod::BankTransfer
1012    }
1013
1014    /// Get fiscal period from date.
1015    fn get_fiscal_period(&self, date: NaiveDate) -> u8 {
1016        date.month() as u8
1017    }
1018
1019    /// Reset the generator.
1020    pub fn reset(&mut self) {
1021        self.rng = seeded_rng(self.seed, 0);
1022        self.so_counter = 0;
1023        self.dlv_counter = 0;
1024        self.ci_counter = 0;
1025        self.rec_counter = 0;
1026        self.short_payment_counter = 0;
1027        self.on_account_counter = 0;
1028        self.correction_counter = 0;
1029    }
1030
1031    /// Generate a partial payment for an invoice.
1032    pub fn generate_partial_payment(
1033        &mut self,
1034        invoice: &CustomerInvoice,
1035        company_code: &str,
1036        customer: &Customer,
1037        payment_date: NaiveDate,
1038        fiscal_year: u16,
1039        fiscal_period: u8,
1040        created_by: &str,
1041        payment_percent: f64,
1042    ) -> (Payment, Decimal, Option<NaiveDate>) {
1043        self.rec_counter += 1;
1044
1045        let receipt_id =
1046            self.make_doc_id("REC", "customer_receipt", company_code, self.rec_counter);
1047
1048        let full_amount = invoice.amount_open;
1049        let payment_amount = (full_amount
1050            * Decimal::from_f64_retain(payment_percent).unwrap_or(Decimal::ONE))
1051        .round_dp(2);
1052        let remaining_amount = full_amount - payment_amount;
1053
1054        let mut receipt = Payment::new_ar_receipt(
1055            receipt_id,
1056            company_code,
1057            &customer.customer_id,
1058            payment_amount,
1059            fiscal_year,
1060            fiscal_period,
1061            payment_date,
1062            created_by,
1063        )
1064        .with_payment_method(self.select_payment_method())
1065        .with_value_date(payment_date);
1066
1067        // Allocate partial amount to invoice
1068        receipt.allocate_to_invoice(
1069            &invoice.header.document_id,
1070            DocumentType::CustomerInvoice,
1071            payment_amount,
1072            Decimal::ZERO, // No discount on partial payments
1073        );
1074
1075        // Add document reference
1076        receipt.header.add_reference(DocumentReference::new(
1077            DocumentType::CustomerReceipt,
1078            &receipt.header.document_id,
1079            DocumentType::CustomerInvoice,
1080            &invoice.header.document_id,
1081            ReferenceType::Payment,
1082            &receipt.header.company_code,
1083            payment_date,
1084        ));
1085
1086        receipt.post(created_by, payment_date);
1087
1088        // Calculate expected remainder date
1089        let expected_remainder_date = Some(
1090            payment_date
1091                + chrono::Duration::days(
1092                    self.config.payment_behavior.avg_days_until_remainder as i64,
1093                )
1094                + chrono::Duration::days(self.rng.random_range(-7..7) as i64),
1095        );
1096
1097        (receipt, remaining_amount, expected_remainder_date)
1098    }
1099
1100    /// Generate a remainder payment for a partial payment.
1101    pub fn generate_remainder_payment(
1102        &mut self,
1103        invoice: &CustomerInvoice,
1104        company_code: &str,
1105        customer: &Customer,
1106        payment_date: NaiveDate,
1107        fiscal_year: u16,
1108        fiscal_period: u8,
1109        created_by: &str,
1110        amount: Decimal,
1111    ) -> Payment {
1112        self.rec_counter += 1;
1113
1114        let receipt_id =
1115            self.make_doc_id("REC", "customer_receipt", company_code, self.rec_counter);
1116
1117        let mut receipt = Payment::new_ar_receipt(
1118            receipt_id,
1119            company_code,
1120            &customer.customer_id,
1121            amount,
1122            fiscal_year,
1123            fiscal_period,
1124            payment_date,
1125            created_by,
1126        )
1127        .with_payment_method(self.select_payment_method())
1128        .with_value_date(payment_date);
1129
1130        // Allocate remainder amount to invoice
1131        receipt.allocate_to_invoice(
1132            &invoice.header.document_id,
1133            DocumentType::CustomerInvoice,
1134            amount,
1135            Decimal::ZERO, // No discount on remainder payments
1136        );
1137
1138        // Add document reference linking receipt to invoice
1139        receipt.header.add_reference(DocumentReference::new(
1140            DocumentType::CustomerReceipt,
1141            &receipt.header.document_id,
1142            DocumentType::CustomerInvoice,
1143            &invoice.header.document_id,
1144            ReferenceType::Payment,
1145            &receipt.header.company_code,
1146            payment_date,
1147        ));
1148
1149        // Post the receipt
1150        receipt.post(created_by, payment_date);
1151
1152        receipt
1153    }
1154
1155    /// Generate a short payment for an invoice.
1156    pub fn generate_short_payment(
1157        &mut self,
1158        invoice: &CustomerInvoice,
1159        company_code: &str,
1160        customer: &Customer,
1161        payment_date: NaiveDate,
1162        fiscal_year: u16,
1163        fiscal_period: u8,
1164        created_by: &str,
1165    ) -> (Payment, ShortPayment) {
1166        self.rec_counter += 1;
1167        self.short_payment_counter += 1;
1168
1169        let receipt_id =
1170            self.make_doc_id("REC", "customer_receipt", company_code, self.rec_counter);
1171        let short_id = format!("SHORT-{}-{:06}", company_code, self.short_payment_counter);
1172
1173        let full_amount = invoice.amount_open;
1174
1175        // Calculate short amount (1-10% of invoice)
1176        let short_percent =
1177            self.rng.random::<f64>() * self.config.payment_behavior.max_short_percent;
1178        let short_amount = (full_amount
1179            * Decimal::from_f64_retain(short_percent).unwrap_or(Decimal::ZERO))
1180        .round_dp(2)
1181        .max(Decimal::ONE); // At least $1 short
1182
1183        let payment_amount = full_amount - short_amount;
1184
1185        let mut receipt = Payment::new_ar_receipt(
1186            receipt_id.clone(),
1187            company_code,
1188            &customer.customer_id,
1189            payment_amount,
1190            fiscal_year,
1191            fiscal_period,
1192            payment_date,
1193            created_by,
1194        )
1195        .with_payment_method(self.select_payment_method())
1196        .with_value_date(payment_date);
1197
1198        // Allocate to invoice
1199        receipt.allocate_to_invoice(
1200            &invoice.header.document_id,
1201            DocumentType::CustomerInvoice,
1202            payment_amount,
1203            Decimal::ZERO,
1204        );
1205
1206        receipt.header.add_reference(DocumentReference::new(
1207            DocumentType::CustomerReceipt,
1208            &receipt.header.document_id,
1209            DocumentType::CustomerInvoice,
1210            &invoice.header.document_id,
1211            ReferenceType::Payment,
1212            &receipt.header.company_code,
1213            payment_date,
1214        ));
1215
1216        receipt.post(created_by, payment_date);
1217
1218        // Create short payment record
1219        let reason_code = self.select_short_payment_reason();
1220        let short_payment = ShortPayment::new(
1221            short_id,
1222            company_code.to_string(),
1223            customer.customer_id.clone(),
1224            receipt_id,
1225            invoice.header.document_id.clone(),
1226            full_amount,
1227            payment_amount,
1228            invoice.header.currency.clone(),
1229            payment_date,
1230            reason_code,
1231        );
1232
1233        (receipt, short_payment)
1234    }
1235
1236    /// Generate an on-account payment.
1237    pub fn generate_on_account_payment(
1238        &mut self,
1239        company_code: &str,
1240        customer: &Customer,
1241        payment_date: NaiveDate,
1242        fiscal_year: u16,
1243        fiscal_period: u8,
1244        created_by: &str,
1245        currency: &str,
1246        amount: Decimal,
1247    ) -> (Payment, OnAccountPayment) {
1248        self.rec_counter += 1;
1249        self.on_account_counter += 1;
1250
1251        let receipt_id =
1252            self.make_doc_id("REC", "customer_receipt", company_code, self.rec_counter);
1253        let on_account_id = format!("OA-{}-{:06}", company_code, self.on_account_counter);
1254
1255        let mut receipt = Payment::new_ar_receipt(
1256            receipt_id.clone(),
1257            company_code,
1258            &customer.customer_id,
1259            amount,
1260            fiscal_year,
1261            fiscal_period,
1262            payment_date,
1263            created_by,
1264        )
1265        .with_payment_method(self.select_payment_method())
1266        .with_value_date(payment_date);
1267
1268        // On-account payments are not allocated to any invoice
1269        receipt.post(created_by, payment_date);
1270
1271        // Create on-account payment record
1272        let reason = self.select_on_account_reason();
1273        let on_account = OnAccountPayment::new(
1274            on_account_id,
1275            company_code.to_string(),
1276            customer.customer_id.clone(),
1277            receipt_id,
1278            amount,
1279            currency.to_string(),
1280            payment_date,
1281        )
1282        .with_reason(reason);
1283
1284        (receipt, on_account)
1285    }
1286
1287    /// Generate a payment correction (NSF or chargeback).
1288    pub fn generate_payment_correction(
1289        &mut self,
1290        original_payment: &Payment,
1291        company_code: &str,
1292        customer_id: &str,
1293        correction_date: NaiveDate,
1294        currency: &str,
1295    ) -> PaymentCorrection {
1296        self.correction_counter += 1;
1297
1298        let correction_id = format!("CORR-{}-{:06}", company_code, self.correction_counter);
1299
1300        let correction_type = if self.rng.random::<f64>() < 0.6 {
1301            PaymentCorrectionType::NSF
1302        } else {
1303            PaymentCorrectionType::Chargeback
1304        };
1305
1306        let mut correction = PaymentCorrection::new(
1307            correction_id,
1308            company_code.to_string(),
1309            customer_id.to_string(),
1310            original_payment.header.document_id.clone(),
1311            correction_type,
1312            original_payment.amount,
1313            original_payment.amount, // Full reversal
1314            currency.to_string(),
1315            correction_date,
1316        );
1317
1318        // Set appropriate details based on type
1319        match correction_type {
1320            PaymentCorrectionType::NSF => {
1321                correction.bank_reference = Some(format!("NSF-{}", self.rng.random::<u32>()));
1322                correction.fee_amount = Decimal::from(35); // Standard NSF fee
1323                correction.reason = Some("Payment returned - Insufficient funds".to_string());
1324            }
1325            PaymentCorrectionType::Chargeback => {
1326                correction.chargeback_code =
1327                    Some(format!("CB{:04}", self.rng.random_range(1000..9999)));
1328                correction.reason = Some("Credit card chargeback".to_string());
1329            }
1330            _ => {}
1331        }
1332
1333        // Add affected invoice
1334        if let Some(allocation) = original_payment.allocations.first() {
1335            correction.add_affected_invoice(allocation.invoice_id.clone());
1336        }
1337
1338        correction
1339    }
1340
1341    /// Select a random short payment reason code.
1342    fn select_short_payment_reason(&mut self) -> ShortPaymentReasonCode {
1343        let roll: f64 = self.rng.random();
1344        if roll < 0.30 {
1345            ShortPaymentReasonCode::PricingDispute
1346        } else if roll < 0.50 {
1347            ShortPaymentReasonCode::QualityIssue
1348        } else if roll < 0.70 {
1349            ShortPaymentReasonCode::QuantityDiscrepancy
1350        } else if roll < 0.85 {
1351            ShortPaymentReasonCode::UnauthorizedDeduction
1352        } else {
1353            ShortPaymentReasonCode::IncorrectDiscount
1354        }
1355    }
1356
1357    /// Select a random on-account reason.
1358    fn select_on_account_reason(&mut self) -> OnAccountReason {
1359        let roll: f64 = self.rng.random();
1360        if roll < 0.40 {
1361            OnAccountReason::NoInvoiceReference
1362        } else if roll < 0.60 {
1363            OnAccountReason::Overpayment
1364        } else if roll < 0.75 {
1365            OnAccountReason::Prepayment
1366        } else if roll < 0.90 {
1367            OnAccountReason::UnclearRemittance
1368        } else {
1369            OnAccountReason::Other
1370        }
1371    }
1372
1373    /// Determine the payment type based on configuration.
1374    fn determine_payment_type(&mut self) -> PaymentType {
1375        let roll: f64 = self.rng.random();
1376        let pb = &self.config.payment_behavior;
1377
1378        let mut cumulative = 0.0;
1379
1380        cumulative += pb.partial_payment_rate;
1381        if roll < cumulative {
1382            return PaymentType::Partial;
1383        }
1384
1385        cumulative += pb.short_payment_rate;
1386        if roll < cumulative {
1387            return PaymentType::Short;
1388        }
1389
1390        cumulative += pb.on_account_rate;
1391        if roll < cumulative {
1392            return PaymentType::OnAccount;
1393        }
1394
1395        PaymentType::Full
1396    }
1397
1398    /// Determine partial payment percentage.
1399    fn determine_partial_payment_percent(&mut self) -> f64 {
1400        let roll: f64 = self.rng.random();
1401        if roll < 0.15 {
1402            0.25
1403        } else if roll < 0.65 {
1404            0.50
1405        } else if roll < 0.90 {
1406            0.75
1407        } else {
1408            // Random between 30-80%
1409            0.30 + self.rng.random::<f64>() * 0.50
1410        }
1411    }
1412}
1413
1414/// Type of payment to generate.
1415#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1416enum PaymentType {
1417    Full,
1418    Partial,
1419    Short,
1420    OnAccount,
1421}
1422
1423#[cfg(test)]
1424#[allow(clippy::unwrap_used)]
1425mod tests {
1426    use super::*;
1427    use datasynth_core::models::{CustomerPaymentBehavior, MaterialType};
1428
1429    fn create_test_customer() -> Customer {
1430        let mut customer = Customer::new(
1431            "C-000001",
1432            "Test Customer Inc.",
1433            datasynth_core::models::CustomerType::Corporate,
1434        );
1435        customer.credit_rating = CreditRating::A;
1436        customer.credit_limit = Decimal::from(1_000_000);
1437        customer.payment_behavior = CustomerPaymentBehavior::OnTime;
1438        customer
1439    }
1440
1441    fn create_test_materials() -> Vec<Material> {
1442        let mut mat1 = Material::new("MAT-001", "Test Product 1", MaterialType::FinishedGood);
1443        mat1.list_price = Decimal::from(100);
1444        mat1.standard_cost = Decimal::from(60);
1445
1446        let mut mat2 = Material::new("MAT-002", "Test Product 2", MaterialType::FinishedGood);
1447        mat2.list_price = Decimal::from(200);
1448        mat2.standard_cost = Decimal::from(120);
1449
1450        vec![mat1, mat2]
1451    }
1452
1453    #[test]
1454    fn test_o2c_chain_generation() {
1455        let mut gen = O2CGenerator::new(42);
1456        let customer = create_test_customer();
1457        let materials = create_test_materials();
1458        let material_refs: Vec<&Material> = materials.iter().collect();
1459
1460        let chain = gen.generate_chain(
1461            "1000",
1462            &customer,
1463            &material_refs,
1464            NaiveDate::from_ymd_opt(2024, 1, 15).unwrap(),
1465            2024,
1466            1,
1467            "JSMITH",
1468        );
1469
1470        assert!(!chain.sales_order.items.is_empty());
1471        assert!(chain.credit_check_passed);
1472        assert!(!chain.deliveries.is_empty());
1473        assert!(chain.customer_invoice.is_some());
1474    }
1475
1476    #[test]
1477    fn test_sales_order_generation() {
1478        let mut gen = O2CGenerator::new(42);
1479        let customer = create_test_customer();
1480        let materials = create_test_materials();
1481        let material_refs: Vec<&Material> = materials.iter().collect();
1482
1483        let so = gen.generate_sales_order(
1484            "1000",
1485            &customer,
1486            &material_refs,
1487            NaiveDate::from_ymd_opt(2024, 1, 15).unwrap(),
1488            2024,
1489            1,
1490            "JSMITH",
1491        );
1492
1493        assert_eq!(so.customer_id, "C-000001");
1494        assert_eq!(so.items.len(), 2);
1495        assert!(so.total_net_amount > Decimal::ZERO);
1496    }
1497
1498    #[test]
1499    fn test_credit_check_failure() {
1500        let config = O2CGeneratorConfig {
1501            credit_check_failure_rate: 1.0, // Force failure
1502            ..Default::default()
1503        };
1504
1505        let mut gen = O2CGenerator::with_config(42, config);
1506        let customer = create_test_customer();
1507        let materials = create_test_materials();
1508        let material_refs: Vec<&Material> = materials.iter().collect();
1509
1510        let chain = gen.generate_chain(
1511            "1000",
1512            &customer,
1513            &material_refs,
1514            NaiveDate::from_ymd_opt(2024, 1, 15).unwrap(),
1515            2024,
1516            1,
1517            "JSMITH",
1518        );
1519
1520        assert!(!chain.credit_check_passed);
1521        assert!(chain.deliveries.is_empty());
1522        assert!(chain.customer_invoice.is_none());
1523    }
1524
1525    #[test]
1526    fn test_document_references() {
1527        let mut gen = O2CGenerator::new(42);
1528        let customer = create_test_customer();
1529        let materials = create_test_materials();
1530        let material_refs: Vec<&Material> = materials.iter().collect();
1531
1532        let chain = gen.generate_chain(
1533            "1000",
1534            &customer,
1535            &material_refs,
1536            NaiveDate::from_ymd_opt(2024, 1, 15).unwrap(),
1537            2024,
1538            1,
1539            "JSMITH",
1540        );
1541
1542        // Delivery should reference SO
1543        if let Some(dlv) = chain.deliveries.first() {
1544            assert!(!dlv.header.document_references.is_empty());
1545        }
1546
1547        // Invoice should reference SO and Delivery
1548        if let Some(invoice) = &chain.customer_invoice {
1549            assert!(invoice.header.document_references.len() >= 2);
1550        }
1551    }
1552
1553    #[test]
1554    fn test_deterministic_generation() {
1555        let customer = create_test_customer();
1556        let materials = create_test_materials();
1557        let material_refs: Vec<&Material> = materials.iter().collect();
1558
1559        let mut gen1 = O2CGenerator::new(42);
1560        let mut gen2 = O2CGenerator::new(42);
1561
1562        let chain1 = gen1.generate_chain(
1563            "1000",
1564            &customer,
1565            &material_refs,
1566            NaiveDate::from_ymd_opt(2024, 1, 15).unwrap(),
1567            2024,
1568            1,
1569            "JSMITH",
1570        );
1571        let chain2 = gen2.generate_chain(
1572            "1000",
1573            &customer,
1574            &material_refs,
1575            NaiveDate::from_ymd_opt(2024, 1, 15).unwrap(),
1576            2024,
1577            1,
1578            "JSMITH",
1579        );
1580
1581        assert_eq!(
1582            chain1.sales_order.header.document_id,
1583            chain2.sales_order.header.document_id
1584        );
1585        assert_eq!(
1586            chain1.sales_order.total_net_amount,
1587            chain2.sales_order.total_net_amount
1588        );
1589    }
1590
1591    #[test]
1592    fn test_partial_shipment_config() {
1593        let config = O2CGeneratorConfig {
1594            partial_shipment_rate: 1.0, // Force partial shipment
1595            ..Default::default()
1596        };
1597
1598        let mut gen = O2CGenerator::with_config(42, config);
1599        let customer = create_test_customer();
1600        let materials = create_test_materials();
1601        let material_refs: Vec<&Material> = materials.iter().collect();
1602
1603        let chain = gen.generate_chain(
1604            "1000",
1605            &customer,
1606            &material_refs,
1607            NaiveDate::from_ymd_opt(2024, 1, 15).unwrap(),
1608            2024,
1609            1,
1610            "JSMITH",
1611        );
1612
1613        // Should have multiple deliveries due to partial shipment
1614        assert!(chain.deliveries.len() >= 2);
1615    }
1616
1617    #[test]
1618    fn test_gross_margin() {
1619        let mut gen = O2CGenerator::new(42);
1620        let customer = create_test_customer();
1621        let materials = create_test_materials();
1622        let material_refs: Vec<&Material> = materials.iter().collect();
1623
1624        let chain = gen.generate_chain(
1625            "1000",
1626            &customer,
1627            &material_refs,
1628            NaiveDate::from_ymd_opt(2024, 1, 15).unwrap(),
1629            2024,
1630            1,
1631            "JSMITH",
1632        );
1633
1634        if let Some(invoice) = &chain.customer_invoice {
1635            // Gross margin should be positive (revenue > COGS)
1636            let margin = invoice.gross_margin();
1637            assert!(margin > Decimal::ZERO, "Gross margin should be positive");
1638        }
1639    }
1640
1641    #[test]
1642    fn test_partial_payment_generates_remainder() {
1643        let config = O2CGeneratorConfig {
1644            bad_debt_rate: 0.0, // Ensure payment happens
1645            payment_behavior: O2CPaymentBehavior {
1646                partial_payment_rate: 1.0, // Force partial payment
1647                short_payment_rate: 0.0,
1648                on_account_rate: 0.0,
1649                payment_correction_rate: 0.0,
1650                ..Default::default()
1651            },
1652            ..Default::default()
1653        };
1654
1655        let mut gen = O2CGenerator::with_config(42, config);
1656        let customer = create_test_customer();
1657        let materials = create_test_materials();
1658        let material_refs: Vec<&Material> = materials.iter().collect();
1659
1660        let chain = gen.generate_chain(
1661            "1000",
1662            &customer,
1663            &material_refs,
1664            NaiveDate::from_ymd_opt(2024, 1, 15).unwrap(),
1665            2024,
1666            1,
1667            "JSMITH",
1668        );
1669
1670        // Should have both PartialPayment and RemainderPayment events
1671        let has_partial = chain
1672            .payment_events
1673            .iter()
1674            .any(|e| matches!(e, PaymentEvent::PartialPayment { .. }));
1675        let has_remainder = chain
1676            .payment_events
1677            .iter()
1678            .any(|e| matches!(e, PaymentEvent::RemainderPayment(_)));
1679
1680        assert!(has_partial, "Should have a PartialPayment event");
1681        assert!(has_remainder, "Should have a RemainderPayment event");
1682        assert!(
1683            chain.payment_events.len() >= 2,
1684            "Should have at least 2 payment events (partial + remainder)"
1685        );
1686    }
1687
1688    #[test]
1689    fn test_partial_plus_remainder_equals_invoice_total() {
1690        let config = O2CGeneratorConfig {
1691            bad_debt_rate: 0.0,
1692            payment_behavior: O2CPaymentBehavior {
1693                partial_payment_rate: 1.0,
1694                short_payment_rate: 0.0,
1695                on_account_rate: 0.0,
1696                payment_correction_rate: 0.0,
1697                ..Default::default()
1698            },
1699            ..Default::default()
1700        };
1701
1702        let mut gen = O2CGenerator::with_config(42, config);
1703        let customer = create_test_customer();
1704        let materials = create_test_materials();
1705        let material_refs: Vec<&Material> = materials.iter().collect();
1706
1707        let chain = gen.generate_chain(
1708            "1000",
1709            &customer,
1710            &material_refs,
1711            NaiveDate::from_ymd_opt(2024, 1, 15).unwrap(),
1712            2024,
1713            1,
1714            "JSMITH",
1715        );
1716
1717        let invoice = chain
1718            .customer_invoice
1719            .as_ref()
1720            .expect("Should have an invoice");
1721
1722        // Extract partial payment amount
1723        let partial_amount = chain
1724            .payment_events
1725            .iter()
1726            .find_map(|e| {
1727                if let PaymentEvent::PartialPayment { payment, .. } = e {
1728                    Some(payment.amount)
1729                } else {
1730                    None
1731                }
1732            })
1733            .expect("Should have a partial payment");
1734
1735        // Extract remainder payment amount
1736        let remainder_amount = chain
1737            .payment_events
1738            .iter()
1739            .find_map(|e| {
1740                if let PaymentEvent::RemainderPayment(payment) = e {
1741                    Some(payment.amount)
1742                } else {
1743                    None
1744                }
1745            })
1746            .expect("Should have a remainder payment");
1747
1748        // partial + remainder should equal invoice total
1749        let total_paid = partial_amount + remainder_amount;
1750        assert_eq!(
1751            total_paid, invoice.total_gross_amount,
1752            "Partial ({}) + remainder ({}) = {} should equal invoice total ({})",
1753            partial_amount, remainder_amount, total_paid, invoice.total_gross_amount
1754        );
1755    }
1756
1757    #[test]
1758    fn test_remainder_receipts_vec_populated() {
1759        let config = O2CGeneratorConfig {
1760            bad_debt_rate: 0.0,
1761            payment_behavior: O2CPaymentBehavior {
1762                partial_payment_rate: 1.0,
1763                short_payment_rate: 0.0,
1764                on_account_rate: 0.0,
1765                payment_correction_rate: 0.0,
1766                ..Default::default()
1767            },
1768            ..Default::default()
1769        };
1770
1771        let mut gen = O2CGenerator::with_config(42, config);
1772        let customer = create_test_customer();
1773        let materials = create_test_materials();
1774        let material_refs: Vec<&Material> = materials.iter().collect();
1775
1776        let chain = gen.generate_chain(
1777            "1000",
1778            &customer,
1779            &material_refs,
1780            NaiveDate::from_ymd_opt(2024, 1, 15).unwrap(),
1781            2024,
1782            1,
1783            "JSMITH",
1784        );
1785
1786        assert!(
1787            !chain.remainder_receipts.is_empty(),
1788            "remainder_receipts should be populated for partial payment chains"
1789        );
1790        assert_eq!(
1791            chain.remainder_receipts.len(),
1792            1,
1793            "Should have exactly one remainder receipt"
1794        );
1795    }
1796
1797    #[test]
1798    fn test_remainder_date_after_partial_date() {
1799        let config = O2CGeneratorConfig {
1800            bad_debt_rate: 0.0,
1801            payment_behavior: O2CPaymentBehavior {
1802                partial_payment_rate: 1.0,
1803                short_payment_rate: 0.0,
1804                max_short_percent: 0.0,
1805                on_account_rate: 0.0,
1806                payment_correction_rate: 0.0,
1807                avg_days_until_remainder: 30,
1808            },
1809            ..Default::default()
1810        };
1811
1812        let mut gen = O2CGenerator::with_config(42, config);
1813        let customer = create_test_customer();
1814        let materials = create_test_materials();
1815        let material_refs: Vec<&Material> = materials.iter().collect();
1816
1817        let chain = gen.generate_chain(
1818            "1000",
1819            &customer,
1820            &material_refs,
1821            NaiveDate::from_ymd_opt(2024, 1, 15).unwrap(),
1822            2024,
1823            1,
1824            "JSMITH",
1825        );
1826
1827        // Get partial payment date (use value_date which is always set)
1828        let partial_date = chain
1829            .payment_events
1830            .iter()
1831            .find_map(|e| {
1832                if let PaymentEvent::PartialPayment { payment, .. } = e {
1833                    Some(payment.value_date)
1834                } else {
1835                    None
1836                }
1837            })
1838            .expect("Should have a partial payment");
1839
1840        // Get remainder payment date
1841        let remainder_date = chain
1842            .payment_events
1843            .iter()
1844            .find_map(|e| {
1845                if let PaymentEvent::RemainderPayment(payment) = e {
1846                    Some(payment.value_date)
1847                } else {
1848                    None
1849                }
1850            })
1851            .expect("Should have a remainder payment");
1852
1853        assert!(
1854            remainder_date > partial_date,
1855            "Remainder date ({}) should be after partial payment date ({})",
1856            remainder_date,
1857            partial_date
1858        );
1859    }
1860
1861    #[test]
1862    fn test_partial_payment_chain_is_complete() {
1863        let config = O2CGeneratorConfig {
1864            bad_debt_rate: 0.0,
1865            payment_behavior: O2CPaymentBehavior {
1866                partial_payment_rate: 1.0,
1867                short_payment_rate: 0.0,
1868                on_account_rate: 0.0,
1869                payment_correction_rate: 0.0,
1870                ..Default::default()
1871            },
1872            ..Default::default()
1873        };
1874
1875        let mut gen = O2CGenerator::with_config(42, config);
1876        let customer = create_test_customer();
1877        let materials = create_test_materials();
1878        let material_refs: Vec<&Material> = materials.iter().collect();
1879
1880        let chain = gen.generate_chain(
1881            "1000",
1882            &customer,
1883            &material_refs,
1884            NaiveDate::from_ymd_opt(2024, 1, 15).unwrap(),
1885            2024,
1886            1,
1887            "JSMITH",
1888        );
1889
1890        // With both partial and remainder, chain should be complete
1891        assert!(
1892            chain.is_complete,
1893            "Chain with partial + remainder payment should be marked complete"
1894        );
1895    }
1896
1897    #[test]
1898    fn test_non_partial_chain_has_empty_remainder_receipts() {
1899        let config = O2CGeneratorConfig {
1900            bad_debt_rate: 0.0,
1901            payment_behavior: O2CPaymentBehavior {
1902                partial_payment_rate: 0.0, // No partial payments
1903                short_payment_rate: 0.0,
1904                on_account_rate: 0.0,
1905                payment_correction_rate: 0.0,
1906                ..Default::default()
1907            },
1908            ..Default::default()
1909        };
1910
1911        let mut gen = O2CGenerator::with_config(42, config);
1912        let customer = create_test_customer();
1913        let materials = create_test_materials();
1914        let material_refs: Vec<&Material> = materials.iter().collect();
1915
1916        let chain = gen.generate_chain(
1917            "1000",
1918            &customer,
1919            &material_refs,
1920            NaiveDate::from_ymd_opt(2024, 1, 15).unwrap(),
1921            2024,
1922            1,
1923            "JSMITH",
1924        );
1925
1926        assert!(
1927            chain.remainder_receipts.is_empty(),
1928            "Non-partial payment chains should have empty remainder_receipts"
1929        );
1930    }
1931}