Skip to main content

datasynth_generators/document_flow/
o2c_generator.rs

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