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.gen_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.gen::<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.gen::<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.gen::<f64>()
416                            < self.config.payment_behavior.payment_correction_rate
417                        {
418                            let correction_date = payment_date
419                                + chrono::Duration::days(self.rng.gen_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.gen_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.gen::<f64>() < self.config.partial_shipment_rate;
526
527        if is_partial {
528            // First shipment (60-80%)
529            let first_pct = 0.6 + self.rng.gen::<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.gen_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.gen::<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.gen_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 && self.rng.gen::<f64>() < self.config.cash_discount_take_rate
769        });
770
771        let discount_amount = if take_discount {
772            invoice.cash_discount_available(payment_date)
773        } else {
774            Decimal::ZERO
775        };
776
777        let payment_amount = invoice.amount_open - discount_amount;
778
779        let mut receipt = Payment::new_ar_receipt(
780            receipt_id,
781            company_code,
782            &customer.customer_id,
783            payment_amount,
784            fiscal_year,
785            fiscal_period,
786            payment_date,
787            created_by,
788        )
789        .with_payment_method(self.select_payment_method())
790        .with_value_date(payment_date);
791
792        // Allocate to invoice
793        receipt.allocate_to_invoice(
794            &invoice.header.document_id,
795            DocumentType::CustomerInvoice,
796            payment_amount,
797            discount_amount,
798        );
799
800        // Add document reference linking receipt to invoice
801        receipt.header.add_reference(DocumentReference::new(
802            DocumentType::CustomerReceipt,
803            &receipt.header.document_id,
804            DocumentType::CustomerInvoice,
805            &invoice.header.document_id,
806            ReferenceType::Payment,
807            &receipt.header.company_code,
808            payment_date,
809        ));
810
811        // Post the receipt
812        receipt.post(created_by, payment_date);
813
814        receipt
815    }
816
817    /// Generate multiple O2C chains.
818    pub fn generate_chains(
819        &mut self,
820        count: usize,
821        company_code: &str,
822        customers: &CustomerPool,
823        materials: &MaterialPool,
824        date_range: (NaiveDate, NaiveDate),
825        fiscal_year: u16,
826        created_by: &str,
827    ) -> Vec<O2CDocumentChain> {
828        tracing::debug!(count, company_code, "Generating O2C document chains");
829        let mut chains = Vec::new();
830
831        let (start_date, end_date) = date_range;
832        let days_range = (end_date - start_date).num_days() as u64;
833
834        for _ in 0..count {
835            // Select random customer
836            let customer_idx = self.rng.gen_range(0..customers.customers.len());
837            let customer = &customers.customers[customer_idx];
838
839            // Select random materials (1-5 items per SO)
840            let num_items = self.rng.gen_range(1..=5).min(materials.materials.len());
841            let selected_materials: Vec<&Material> = materials
842                .materials
843                .iter()
844                .choose_multiple(&mut self.rng, num_items)
845                .into_iter()
846                .collect();
847
848            // Select random SO date
849            let so_date =
850                start_date + chrono::Duration::days(self.rng.gen_range(0..=days_range) as i64);
851            let fiscal_period = self.get_fiscal_period(so_date);
852
853            let chain = self.generate_chain(
854                company_code,
855                customer,
856                &selected_materials,
857                so_date,
858                fiscal_year,
859                fiscal_period,
860                created_by,
861            );
862
863            chains.push(chain);
864        }
865
866        chains
867    }
868
869    /// Perform credit check for customer.
870    fn perform_credit_check(&mut self, customer: &Customer, order_amount: Decimal) -> bool {
871        // Check credit limit
872        if !customer.can_place_order(order_amount) {
873            return false;
874        }
875
876        // Additional random failure based on config
877        let fail_roll = self.rng.gen::<f64>();
878        if fail_roll < self.config.credit_check_failure_rate {
879            return false;
880        }
881
882        // Higher risk customers have higher failure rate
883        let additional_fail_rate = match customer.credit_rating {
884            CreditRating::CCC | CreditRating::D => 0.20,
885            CreditRating::B | CreditRating::BB => 0.05,
886            _ => 0.0,
887        };
888
889        self.rng.gen::<f64>() >= additional_fail_rate
890    }
891
892    /// Calculate delivery date from SO date.
893    fn calculate_delivery_date(&mut self, so_date: NaiveDate) -> NaiveDate {
894        let variance = self.rng.gen_range(0..3) as i64;
895        so_date + chrono::Duration::days(self.config.avg_days_so_to_delivery as i64 + variance)
896    }
897
898    /// Calculate invoice date from delivery date.
899    fn calculate_invoice_date(&mut self, delivery_date: NaiveDate) -> NaiveDate {
900        let variance = self.rng.gen_range(0..2) as i64;
901        delivery_date
902            + chrono::Duration::days(self.config.avg_days_delivery_to_invoice as i64 + variance)
903    }
904
905    /// Calculate payment date based on customer behavior.
906    fn calculate_payment_date(
907        &mut self,
908        invoice_date: NaiveDate,
909        payment_terms: &PaymentTerms,
910        customer: &Customer,
911    ) -> NaiveDate {
912        let base_days = payment_terms.net_days() as i64;
913
914        // Adjust based on customer payment behavior
915        let behavior_adjustment = match customer.payment_behavior {
916            datasynth_core::models::CustomerPaymentBehavior::Excellent
917            | datasynth_core::models::CustomerPaymentBehavior::EarlyPayer => {
918                -self.rng.gen_range(5..15) as i64
919            }
920            datasynth_core::models::CustomerPaymentBehavior::Good
921            | datasynth_core::models::CustomerPaymentBehavior::OnTime => {
922                self.rng.gen_range(-2..3) as i64
923            }
924            datasynth_core::models::CustomerPaymentBehavior::Fair
925            | datasynth_core::models::CustomerPaymentBehavior::SlightlyLate => {
926                self.rng.gen_range(5..15) as i64
927            }
928            datasynth_core::models::CustomerPaymentBehavior::Poor
929            | datasynth_core::models::CustomerPaymentBehavior::OftenLate => {
930                self.rng.gen_range(15..45) as i64
931            }
932            datasynth_core::models::CustomerPaymentBehavior::VeryPoor
933            | datasynth_core::models::CustomerPaymentBehavior::HighRisk => {
934                self.rng.gen_range(30..90) as i64
935            }
936        };
937
938        // Additional random late payment
939        let late_adjustment = if self.rng.gen::<f64>() < self.config.late_payment_rate {
940            self.rng.gen_range(10..30) as i64
941        } else {
942            0
943        };
944
945        invoice_date + chrono::Duration::days(base_days + behavior_adjustment + late_adjustment)
946    }
947
948    /// Calculate due date based on payment terms.
949    fn calculate_due_date(
950        &self,
951        invoice_date: NaiveDate,
952        payment_terms: &PaymentTerms,
953    ) -> NaiveDate {
954        invoice_date + chrono::Duration::days(payment_terms.net_days() as i64)
955    }
956
957    /// Select payment method based on distribution.
958    fn select_payment_method(&mut self) -> PaymentMethod {
959        let roll: f64 = self.rng.gen();
960        let mut cumulative = 0.0;
961
962        for (method, prob) in &self.config.payment_method_distribution {
963            cumulative += prob;
964            if roll < cumulative {
965                return *method;
966            }
967        }
968
969        PaymentMethod::BankTransfer
970    }
971
972    /// Get fiscal period from date.
973    fn get_fiscal_period(&self, date: NaiveDate) -> u8 {
974        date.month() as u8
975    }
976
977    /// Reset the generator.
978    pub fn reset(&mut self) {
979        self.rng = seeded_rng(self.seed, 0);
980        self.so_counter = 0;
981        self.dlv_counter = 0;
982        self.ci_counter = 0;
983        self.rec_counter = 0;
984        self.short_payment_counter = 0;
985        self.on_account_counter = 0;
986        self.correction_counter = 0;
987    }
988
989    /// Generate a partial payment for an invoice.
990    pub fn generate_partial_payment(
991        &mut self,
992        invoice: &CustomerInvoice,
993        company_code: &str,
994        customer: &Customer,
995        payment_date: NaiveDate,
996        fiscal_year: u16,
997        fiscal_period: u8,
998        created_by: &str,
999        payment_percent: f64,
1000    ) -> (Payment, Decimal, Option<NaiveDate>) {
1001        self.rec_counter += 1;
1002
1003        let receipt_id =
1004            self.make_doc_id("REC", "customer_receipt", company_code, self.rec_counter);
1005
1006        let full_amount = invoice.amount_open;
1007        let payment_amount = (full_amount
1008            * Decimal::from_f64_retain(payment_percent).unwrap_or(Decimal::ONE))
1009        .round_dp(2);
1010        let remaining_amount = full_amount - payment_amount;
1011
1012        let mut receipt = Payment::new_ar_receipt(
1013            receipt_id,
1014            company_code,
1015            &customer.customer_id,
1016            payment_amount,
1017            fiscal_year,
1018            fiscal_period,
1019            payment_date,
1020            created_by,
1021        )
1022        .with_payment_method(self.select_payment_method())
1023        .with_value_date(payment_date);
1024
1025        // Allocate partial amount to invoice
1026        receipt.allocate_to_invoice(
1027            &invoice.header.document_id,
1028            DocumentType::CustomerInvoice,
1029            payment_amount,
1030            Decimal::ZERO, // No discount on partial payments
1031        );
1032
1033        // Add document reference
1034        receipt.header.add_reference(DocumentReference::new(
1035            DocumentType::CustomerReceipt,
1036            &receipt.header.document_id,
1037            DocumentType::CustomerInvoice,
1038            &invoice.header.document_id,
1039            ReferenceType::Payment,
1040            &receipt.header.company_code,
1041            payment_date,
1042        ));
1043
1044        receipt.post(created_by, payment_date);
1045
1046        // Calculate expected remainder date
1047        let expected_remainder_date = Some(
1048            payment_date
1049                + chrono::Duration::days(
1050                    self.config.payment_behavior.avg_days_until_remainder as i64,
1051                )
1052                + chrono::Duration::days(self.rng.gen_range(-7..7) as i64),
1053        );
1054
1055        (receipt, remaining_amount, expected_remainder_date)
1056    }
1057
1058    /// Generate a short payment for an invoice.
1059    pub fn generate_short_payment(
1060        &mut self,
1061        invoice: &CustomerInvoice,
1062        company_code: &str,
1063        customer: &Customer,
1064        payment_date: NaiveDate,
1065        fiscal_year: u16,
1066        fiscal_period: u8,
1067        created_by: &str,
1068    ) -> (Payment, ShortPayment) {
1069        self.rec_counter += 1;
1070        self.short_payment_counter += 1;
1071
1072        let receipt_id =
1073            self.make_doc_id("REC", "customer_receipt", company_code, self.rec_counter);
1074        let short_id = format!("SHORT-{}-{:06}", company_code, self.short_payment_counter);
1075
1076        let full_amount = invoice.amount_open;
1077
1078        // Calculate short amount (1-10% of invoice)
1079        let short_percent = self.rng.gen::<f64>() * self.config.payment_behavior.max_short_percent;
1080        let short_amount = (full_amount
1081            * Decimal::from_f64_retain(short_percent).unwrap_or(Decimal::ZERO))
1082        .round_dp(2)
1083        .max(Decimal::ONE); // At least $1 short
1084
1085        let payment_amount = full_amount - short_amount;
1086
1087        let mut receipt = Payment::new_ar_receipt(
1088            receipt_id.clone(),
1089            company_code,
1090            &customer.customer_id,
1091            payment_amount,
1092            fiscal_year,
1093            fiscal_period,
1094            payment_date,
1095            created_by,
1096        )
1097        .with_payment_method(self.select_payment_method())
1098        .with_value_date(payment_date);
1099
1100        // Allocate to invoice
1101        receipt.allocate_to_invoice(
1102            &invoice.header.document_id,
1103            DocumentType::CustomerInvoice,
1104            payment_amount,
1105            Decimal::ZERO,
1106        );
1107
1108        receipt.header.add_reference(DocumentReference::new(
1109            DocumentType::CustomerReceipt,
1110            &receipt.header.document_id,
1111            DocumentType::CustomerInvoice,
1112            &invoice.header.document_id,
1113            ReferenceType::Payment,
1114            &receipt.header.company_code,
1115            payment_date,
1116        ));
1117
1118        receipt.post(created_by, payment_date);
1119
1120        // Create short payment record
1121        let reason_code = self.select_short_payment_reason();
1122        let short_payment = ShortPayment::new(
1123            short_id,
1124            company_code.to_string(),
1125            customer.customer_id.clone(),
1126            receipt_id,
1127            invoice.header.document_id.clone(),
1128            full_amount,
1129            payment_amount,
1130            invoice.header.currency.clone(),
1131            payment_date,
1132            reason_code,
1133        );
1134
1135        (receipt, short_payment)
1136    }
1137
1138    /// Generate an on-account payment.
1139    pub fn generate_on_account_payment(
1140        &mut self,
1141        company_code: &str,
1142        customer: &Customer,
1143        payment_date: NaiveDate,
1144        fiscal_year: u16,
1145        fiscal_period: u8,
1146        created_by: &str,
1147        currency: &str,
1148        amount: Decimal,
1149    ) -> (Payment, OnAccountPayment) {
1150        self.rec_counter += 1;
1151        self.on_account_counter += 1;
1152
1153        let receipt_id =
1154            self.make_doc_id("REC", "customer_receipt", company_code, self.rec_counter);
1155        let on_account_id = format!("OA-{}-{:06}", company_code, self.on_account_counter);
1156
1157        let mut receipt = Payment::new_ar_receipt(
1158            receipt_id.clone(),
1159            company_code,
1160            &customer.customer_id,
1161            amount,
1162            fiscal_year,
1163            fiscal_period,
1164            payment_date,
1165            created_by,
1166        )
1167        .with_payment_method(self.select_payment_method())
1168        .with_value_date(payment_date);
1169
1170        // On-account payments are not allocated to any invoice
1171        receipt.post(created_by, payment_date);
1172
1173        // Create on-account payment record
1174        let reason = self.select_on_account_reason();
1175        let on_account = OnAccountPayment::new(
1176            on_account_id,
1177            company_code.to_string(),
1178            customer.customer_id.clone(),
1179            receipt_id,
1180            amount,
1181            currency.to_string(),
1182            payment_date,
1183        )
1184        .with_reason(reason);
1185
1186        (receipt, on_account)
1187    }
1188
1189    /// Generate a payment correction (NSF or chargeback).
1190    pub fn generate_payment_correction(
1191        &mut self,
1192        original_payment: &Payment,
1193        company_code: &str,
1194        customer_id: &str,
1195        correction_date: NaiveDate,
1196        currency: &str,
1197    ) -> PaymentCorrection {
1198        self.correction_counter += 1;
1199
1200        let correction_id = format!("CORR-{}-{:06}", company_code, self.correction_counter);
1201
1202        let correction_type = if self.rng.gen::<f64>() < 0.6 {
1203            PaymentCorrectionType::NSF
1204        } else {
1205            PaymentCorrectionType::Chargeback
1206        };
1207
1208        let mut correction = PaymentCorrection::new(
1209            correction_id,
1210            company_code.to_string(),
1211            customer_id.to_string(),
1212            original_payment.header.document_id.clone(),
1213            correction_type,
1214            original_payment.amount,
1215            original_payment.amount, // Full reversal
1216            currency.to_string(),
1217            correction_date,
1218        );
1219
1220        // Set appropriate details based on type
1221        match correction_type {
1222            PaymentCorrectionType::NSF => {
1223                correction.bank_reference = Some(format!("NSF-{}", self.rng.gen::<u32>()));
1224                correction.fee_amount = Decimal::from(35); // Standard NSF fee
1225                correction.reason = Some("Payment returned - Insufficient funds".to_string());
1226            }
1227            PaymentCorrectionType::Chargeback => {
1228                correction.chargeback_code =
1229                    Some(format!("CB{:04}", self.rng.gen_range(1000..9999)));
1230                correction.reason = Some("Credit card chargeback".to_string());
1231            }
1232            _ => {}
1233        }
1234
1235        // Add affected invoice
1236        if let Some(allocation) = original_payment.allocations.first() {
1237            correction.add_affected_invoice(allocation.invoice_id.clone());
1238        }
1239
1240        correction
1241    }
1242
1243    /// Select a random short payment reason code.
1244    fn select_short_payment_reason(&mut self) -> ShortPaymentReasonCode {
1245        let roll: f64 = self.rng.gen();
1246        if roll < 0.30 {
1247            ShortPaymentReasonCode::PricingDispute
1248        } else if roll < 0.50 {
1249            ShortPaymentReasonCode::QualityIssue
1250        } else if roll < 0.70 {
1251            ShortPaymentReasonCode::QuantityDiscrepancy
1252        } else if roll < 0.85 {
1253            ShortPaymentReasonCode::UnauthorizedDeduction
1254        } else {
1255            ShortPaymentReasonCode::IncorrectDiscount
1256        }
1257    }
1258
1259    /// Select a random on-account reason.
1260    fn select_on_account_reason(&mut self) -> OnAccountReason {
1261        let roll: f64 = self.rng.gen();
1262        if roll < 0.40 {
1263            OnAccountReason::NoInvoiceReference
1264        } else if roll < 0.60 {
1265            OnAccountReason::Overpayment
1266        } else if roll < 0.75 {
1267            OnAccountReason::Prepayment
1268        } else if roll < 0.90 {
1269            OnAccountReason::UnclearRemittance
1270        } else {
1271            OnAccountReason::Other
1272        }
1273    }
1274
1275    /// Determine the payment type based on configuration.
1276    fn determine_payment_type(&mut self) -> PaymentType {
1277        let roll: f64 = self.rng.gen();
1278        let pb = &self.config.payment_behavior;
1279
1280        let mut cumulative = 0.0;
1281
1282        cumulative += pb.partial_payment_rate;
1283        if roll < cumulative {
1284            return PaymentType::Partial;
1285        }
1286
1287        cumulative += pb.short_payment_rate;
1288        if roll < cumulative {
1289            return PaymentType::Short;
1290        }
1291
1292        cumulative += pb.on_account_rate;
1293        if roll < cumulative {
1294            return PaymentType::OnAccount;
1295        }
1296
1297        PaymentType::Full
1298    }
1299
1300    /// Determine partial payment percentage.
1301    fn determine_partial_payment_percent(&mut self) -> f64 {
1302        let roll: f64 = self.rng.gen();
1303        if roll < 0.15 {
1304            0.25
1305        } else if roll < 0.65 {
1306            0.50
1307        } else if roll < 0.90 {
1308            0.75
1309        } else {
1310            // Random between 30-80%
1311            0.30 + self.rng.gen::<f64>() * 0.50
1312        }
1313    }
1314}
1315
1316/// Type of payment to generate.
1317#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1318enum PaymentType {
1319    Full,
1320    Partial,
1321    Short,
1322    OnAccount,
1323}
1324
1325#[cfg(test)]
1326#[allow(clippy::unwrap_used)]
1327mod tests {
1328    use super::*;
1329    use datasynth_core::models::{CustomerPaymentBehavior, MaterialType};
1330
1331    fn create_test_customer() -> Customer {
1332        let mut customer = Customer::new(
1333            "C-000001",
1334            "Test Customer Inc.",
1335            datasynth_core::models::CustomerType::Corporate,
1336        );
1337        customer.credit_rating = CreditRating::A;
1338        customer.credit_limit = Decimal::from(1_000_000);
1339        customer.payment_behavior = CustomerPaymentBehavior::OnTime;
1340        customer
1341    }
1342
1343    fn create_test_materials() -> Vec<Material> {
1344        let mut mat1 = Material::new("MAT-001", "Test Product 1", MaterialType::FinishedGood);
1345        mat1.list_price = Decimal::from(100);
1346        mat1.standard_cost = Decimal::from(60);
1347
1348        let mut mat2 = Material::new("MAT-002", "Test Product 2", MaterialType::FinishedGood);
1349        mat2.list_price = Decimal::from(200);
1350        mat2.standard_cost = Decimal::from(120);
1351
1352        vec![mat1, mat2]
1353    }
1354
1355    #[test]
1356    fn test_o2c_chain_generation() {
1357        let mut gen = O2CGenerator::new(42);
1358        let customer = create_test_customer();
1359        let materials = create_test_materials();
1360        let material_refs: Vec<&Material> = materials.iter().collect();
1361
1362        let chain = gen.generate_chain(
1363            "1000",
1364            &customer,
1365            &material_refs,
1366            NaiveDate::from_ymd_opt(2024, 1, 15).unwrap(),
1367            2024,
1368            1,
1369            "JSMITH",
1370        );
1371
1372        assert!(!chain.sales_order.items.is_empty());
1373        assert!(chain.credit_check_passed);
1374        assert!(!chain.deliveries.is_empty());
1375        assert!(chain.customer_invoice.is_some());
1376    }
1377
1378    #[test]
1379    fn test_sales_order_generation() {
1380        let mut gen = O2CGenerator::new(42);
1381        let customer = create_test_customer();
1382        let materials = create_test_materials();
1383        let material_refs: Vec<&Material> = materials.iter().collect();
1384
1385        let so = gen.generate_sales_order(
1386            "1000",
1387            &customer,
1388            &material_refs,
1389            NaiveDate::from_ymd_opt(2024, 1, 15).unwrap(),
1390            2024,
1391            1,
1392            "JSMITH",
1393        );
1394
1395        assert_eq!(so.customer_id, "C-000001");
1396        assert_eq!(so.items.len(), 2);
1397        assert!(so.total_net_amount > Decimal::ZERO);
1398    }
1399
1400    #[test]
1401    fn test_credit_check_failure() {
1402        let config = O2CGeneratorConfig {
1403            credit_check_failure_rate: 1.0, // Force failure
1404            ..Default::default()
1405        };
1406
1407        let mut gen = O2CGenerator::with_config(42, config);
1408        let customer = create_test_customer();
1409        let materials = create_test_materials();
1410        let material_refs: Vec<&Material> = materials.iter().collect();
1411
1412        let chain = gen.generate_chain(
1413            "1000",
1414            &customer,
1415            &material_refs,
1416            NaiveDate::from_ymd_opt(2024, 1, 15).unwrap(),
1417            2024,
1418            1,
1419            "JSMITH",
1420        );
1421
1422        assert!(!chain.credit_check_passed);
1423        assert!(chain.deliveries.is_empty());
1424        assert!(chain.customer_invoice.is_none());
1425    }
1426
1427    #[test]
1428    fn test_document_references() {
1429        let mut gen = O2CGenerator::new(42);
1430        let customer = create_test_customer();
1431        let materials = create_test_materials();
1432        let material_refs: Vec<&Material> = materials.iter().collect();
1433
1434        let chain = gen.generate_chain(
1435            "1000",
1436            &customer,
1437            &material_refs,
1438            NaiveDate::from_ymd_opt(2024, 1, 15).unwrap(),
1439            2024,
1440            1,
1441            "JSMITH",
1442        );
1443
1444        // Delivery should reference SO
1445        if let Some(dlv) = chain.deliveries.first() {
1446            assert!(!dlv.header.document_references.is_empty());
1447        }
1448
1449        // Invoice should reference SO and Delivery
1450        if let Some(invoice) = &chain.customer_invoice {
1451            assert!(invoice.header.document_references.len() >= 2);
1452        }
1453    }
1454
1455    #[test]
1456    fn test_deterministic_generation() {
1457        let customer = create_test_customer();
1458        let materials = create_test_materials();
1459        let material_refs: Vec<&Material> = materials.iter().collect();
1460
1461        let mut gen1 = O2CGenerator::new(42);
1462        let mut gen2 = O2CGenerator::new(42);
1463
1464        let chain1 = gen1.generate_chain(
1465            "1000",
1466            &customer,
1467            &material_refs,
1468            NaiveDate::from_ymd_opt(2024, 1, 15).unwrap(),
1469            2024,
1470            1,
1471            "JSMITH",
1472        );
1473        let chain2 = gen2.generate_chain(
1474            "1000",
1475            &customer,
1476            &material_refs,
1477            NaiveDate::from_ymd_opt(2024, 1, 15).unwrap(),
1478            2024,
1479            1,
1480            "JSMITH",
1481        );
1482
1483        assert_eq!(
1484            chain1.sales_order.header.document_id,
1485            chain2.sales_order.header.document_id
1486        );
1487        assert_eq!(
1488            chain1.sales_order.total_net_amount,
1489            chain2.sales_order.total_net_amount
1490        );
1491    }
1492
1493    #[test]
1494    fn test_partial_shipment_config() {
1495        let config = O2CGeneratorConfig {
1496            partial_shipment_rate: 1.0, // Force partial shipment
1497            ..Default::default()
1498        };
1499
1500        let mut gen = O2CGenerator::with_config(42, config);
1501        let customer = create_test_customer();
1502        let materials = create_test_materials();
1503        let material_refs: Vec<&Material> = materials.iter().collect();
1504
1505        let chain = gen.generate_chain(
1506            "1000",
1507            &customer,
1508            &material_refs,
1509            NaiveDate::from_ymd_opt(2024, 1, 15).unwrap(),
1510            2024,
1511            1,
1512            "JSMITH",
1513        );
1514
1515        // Should have multiple deliveries due to partial shipment
1516        assert!(chain.deliveries.len() >= 2);
1517    }
1518
1519    #[test]
1520    fn test_gross_margin() {
1521        let mut gen = O2CGenerator::new(42);
1522        let customer = create_test_customer();
1523        let materials = create_test_materials();
1524        let material_refs: Vec<&Material> = materials.iter().collect();
1525
1526        let chain = gen.generate_chain(
1527            "1000",
1528            &customer,
1529            &material_refs,
1530            NaiveDate::from_ymd_opt(2024, 1, 15).unwrap(),
1531            2024,
1532            1,
1533            "JSMITH",
1534        );
1535
1536        if let Some(invoice) = &chain.customer_invoice {
1537            // Gross margin should be positive (revenue > COGS)
1538            let margin = invoice.gross_margin();
1539            assert!(margin > Decimal::ZERO, "Gross margin should be positive");
1540        }
1541    }
1542}