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