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