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