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