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!("{prefix}-{company_code}-{counter:010}")
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.sales_order_id = Some(so.header.document_id.clone());
819
820 invoice.customer_name = Some(customer.name.clone());
822
823 let mut delivered_quantities: std::collections::HashMap<u16, (Decimal, Decimal)> =
825 std::collections::HashMap::new();
826
827 for dlv in deliveries {
828 for dlv_item in &dlv.items {
829 let entry = delivered_quantities
830 .entry(dlv_item.base.line_number)
831 .or_insert((Decimal::ZERO, Decimal::ZERO));
832 entry.0 += dlv_item.base.quantity;
833 entry.1 += dlv_item.cogs_amount;
834 }
835 }
836
837 for so_item in &so.items {
839 if let Some(&(qty, cogs)) = delivered_quantities.get(&so_item.base.line_number) {
840 let ci_description =
841 self.pick_line_description("customer_invoice", &so_item.base.description);
842 let item = CustomerInvoiceItem::from_delivery(
843 so_item.base.line_number,
844 &ci_description,
845 qty,
846 so_item.base.unit_price,
847 &deliveries[0].header.document_id,
848 so_item.base.line_number,
849 )
850 .with_cogs(cogs)
851 .with_sales_order(&so.header.document_id, so_item.base.line_number);
852
853 invoice.add_item(item);
854 }
855 }
856
857 invoice.header.add_reference(DocumentReference::new(
859 DocumentType::SalesOrder,
860 &so.header.document_id,
861 DocumentType::CustomerInvoice,
862 &invoice.header.document_id,
863 ReferenceType::FollowOn,
864 company_code,
865 invoice_date,
866 ));
867
868 for dlv in deliveries {
870 invoice.header.add_reference(DocumentReference::new(
871 DocumentType::Delivery,
872 &dlv.header.document_id,
873 DocumentType::CustomerInvoice,
874 &invoice.header.document_id,
875 ReferenceType::FollowOn,
876 company_code,
877 invoice_date,
878 ));
879 }
880
881 invoice.post(created_by, invoice_date);
883
884 invoice
885 }
886
887 fn generate_customer_receipt(
889 &mut self,
890 invoice: &CustomerInvoice,
891 company_code: &str,
892 customer: &Customer,
893 payment_date: NaiveDate,
894 fiscal_year: u16,
895 fiscal_period: u8,
896 created_by: &str,
897 ) -> Payment {
898 self.rec_counter += 1;
899
900 let receipt_id =
901 self.make_doc_id("REC", "customer_receipt", company_code, self.rec_counter);
902
903 let take_discount = invoice.discount_date_1.is_some_and(|disc_date| {
905 payment_date <= disc_date
906 && self.rng.random::<f64>() < self.config.cash_discount_take_rate
907 });
908
909 let discount_amount = if take_discount {
910 invoice.cash_discount_available(payment_date)
911 } else {
912 Decimal::ZERO
913 };
914
915 let payment_amount = invoice.amount_open - discount_amount;
916
917 let mut receipt = Payment::new_ar_receipt(
918 receipt_id,
919 company_code,
920 &customer.customer_id,
921 payment_amount,
922 fiscal_year,
923 fiscal_period,
924 payment_date,
925 created_by,
926 )
927 .with_payment_method(self.select_payment_method())
928 .with_value_date(payment_date);
929
930 receipt.allocate_to_invoice(
932 &invoice.header.document_id,
933 DocumentType::CustomerInvoice,
934 payment_amount,
935 discount_amount,
936 );
937
938 receipt.header.add_reference(DocumentReference::new(
940 DocumentType::CustomerReceipt,
941 &receipt.header.document_id,
942 DocumentType::CustomerInvoice,
943 &invoice.header.document_id,
944 ReferenceType::Payment,
945 &receipt.header.company_code,
946 payment_date,
947 ));
948
949 receipt.post(created_by, payment_date);
951
952 receipt
953 }
954
955 pub fn generate_chains(
957 &mut self,
958 count: usize,
959 company_code: &str,
960 customers: &CustomerPool,
961 materials: &MaterialPool,
962 date_range: (NaiveDate, NaiveDate),
963 fiscal_year: u16,
964 created_by: &str,
965 ) -> Vec<O2CDocumentChain> {
966 tracing::debug!(count, company_code, "Generating O2C document chains");
967 let mut chains = Vec::new();
968
969 let (start_date, end_date) = date_range;
970 let days_range = (end_date - start_date).num_days() as u64;
971
972 for _ in 0..count {
973 let customer_idx = self.rng.random_range(0..customers.customers.len());
975 let customer = &customers.customers[customer_idx];
976
977 let num_items = self.rng.random_range(1..=5).min(materials.materials.len());
979 let selected_materials: Vec<&Material> = materials
980 .materials
981 .iter()
982 .choose_multiple(&mut self.rng, num_items)
983 .into_iter()
984 .collect();
985
986 let so_date =
988 start_date + chrono::Duration::days(self.rng.random_range(0..=days_range) as i64);
989 let fiscal_period = self.get_fiscal_period(so_date);
990
991 let chain = self.generate_chain(
992 company_code,
993 customer,
994 &selected_materials,
995 so_date,
996 fiscal_year,
997 fiscal_period,
998 created_by,
999 );
1000
1001 chains.push(chain);
1002 }
1003
1004 chains
1005 }
1006
1007 fn perform_credit_check(&mut self, customer: &Customer, order_amount: Decimal) -> bool {
1009 if !customer.can_place_order(order_amount) {
1011 return false;
1012 }
1013
1014 let fail_roll = self.rng.random::<f64>();
1016 if fail_roll < self.config.credit_check_failure_rate {
1017 return false;
1018 }
1019
1020 let additional_fail_rate = match customer.credit_rating {
1022 CreditRating::CCC | CreditRating::D => 0.20,
1023 CreditRating::B | CreditRating::BB => 0.05,
1024 _ => 0.0,
1025 };
1026
1027 self.rng.random::<f64>() >= additional_fail_rate
1028 }
1029
1030 fn calculate_delivery_date(&mut self, so_date: NaiveDate) -> NaiveDate {
1032 let variance = self.rng.random_range(0..3) as i64;
1033 so_date + chrono::Duration::days(self.config.avg_days_so_to_delivery as i64 + variance)
1034 }
1035
1036 fn calculate_invoice_date(&mut self, delivery_date: NaiveDate) -> NaiveDate {
1038 let variance = self.rng.random_range(0..2) as i64;
1039 delivery_date
1040 + chrono::Duration::days(self.config.avg_days_delivery_to_invoice as i64 + variance)
1041 }
1042
1043 fn calculate_payment_date(
1045 &mut self,
1046 invoice_date: NaiveDate,
1047 payment_terms: &PaymentTerms,
1048 customer: &Customer,
1049 ) -> NaiveDate {
1050 let base_days = payment_terms.net_days() as i64;
1051
1052 let behavior_adjustment = match customer.payment_behavior {
1054 datasynth_core::models::CustomerPaymentBehavior::Excellent
1055 | datasynth_core::models::CustomerPaymentBehavior::EarlyPayer => {
1056 -self.rng.random_range(5..15) as i64
1057 }
1058 datasynth_core::models::CustomerPaymentBehavior::Good
1059 | datasynth_core::models::CustomerPaymentBehavior::OnTime => {
1060 self.rng.random_range(-2..3) as i64
1061 }
1062 datasynth_core::models::CustomerPaymentBehavior::Fair
1063 | datasynth_core::models::CustomerPaymentBehavior::SlightlyLate => {
1064 self.rng.random_range(5..15) as i64
1065 }
1066 datasynth_core::models::CustomerPaymentBehavior::Poor
1067 | datasynth_core::models::CustomerPaymentBehavior::OftenLate => {
1068 self.rng.random_range(15..45) as i64
1069 }
1070 datasynth_core::models::CustomerPaymentBehavior::VeryPoor
1071 | datasynth_core::models::CustomerPaymentBehavior::HighRisk => {
1072 self.rng.random_range(30..90) as i64
1073 }
1074 };
1075
1076 let late_adjustment = if self.rng.random::<f64>() < self.config.late_payment_rate {
1078 self.rng.random_range(10..30) as i64
1079 } else {
1080 0
1081 };
1082
1083 invoice_date + chrono::Duration::days(base_days + behavior_adjustment + late_adjustment)
1084 }
1085
1086 fn calculate_due_date(
1088 &self,
1089 invoice_date: NaiveDate,
1090 payment_terms: &PaymentTerms,
1091 ) -> NaiveDate {
1092 invoice_date + chrono::Duration::days(payment_terms.net_days() as i64)
1093 }
1094
1095 fn select_payment_method(&mut self) -> PaymentMethod {
1097 let roll: f64 = self.rng.random();
1098 let mut cumulative = 0.0;
1099
1100 for (method, prob) in &self.config.payment_method_distribution {
1101 cumulative += prob;
1102 if roll < cumulative {
1103 return *method;
1104 }
1105 }
1106
1107 PaymentMethod::BankTransfer
1108 }
1109
1110 fn get_fiscal_period(&self, date: NaiveDate) -> u8 {
1112 date.month() as u8
1113 }
1114
1115 pub fn reset(&mut self) {
1117 self.rng = seeded_rng(self.seed, 0);
1118 self.so_counter = 0;
1119 self.dlv_counter = 0;
1120 self.ci_counter = 0;
1121 self.rec_counter = 0;
1122 self.short_payment_counter = 0;
1123 self.on_account_counter = 0;
1124 self.correction_counter = 0;
1125 }
1126
1127 pub fn generate_partial_payment(
1129 &mut self,
1130 invoice: &CustomerInvoice,
1131 company_code: &str,
1132 customer: &Customer,
1133 payment_date: NaiveDate,
1134 fiscal_year: u16,
1135 fiscal_period: u8,
1136 created_by: &str,
1137 payment_percent: f64,
1138 ) -> (Payment, Decimal, Option<NaiveDate>) {
1139 self.rec_counter += 1;
1140
1141 let receipt_id =
1142 self.make_doc_id("REC", "customer_receipt", company_code, self.rec_counter);
1143
1144 let full_amount = invoice.amount_open;
1145 let payment_amount = (full_amount
1146 * Decimal::from_f64_retain(payment_percent).unwrap_or(Decimal::ONE))
1147 .round_dp(2);
1148 let remaining_amount = full_amount - payment_amount;
1149
1150 let mut receipt = Payment::new_ar_receipt(
1151 receipt_id,
1152 company_code,
1153 &customer.customer_id,
1154 payment_amount,
1155 fiscal_year,
1156 fiscal_period,
1157 payment_date,
1158 created_by,
1159 )
1160 .with_payment_method(self.select_payment_method())
1161 .with_value_date(payment_date);
1162
1163 receipt.allocate_to_invoice(
1165 &invoice.header.document_id,
1166 DocumentType::CustomerInvoice,
1167 payment_amount,
1168 Decimal::ZERO, );
1170
1171 receipt.header.add_reference(DocumentReference::new(
1173 DocumentType::CustomerReceipt,
1174 &receipt.header.document_id,
1175 DocumentType::CustomerInvoice,
1176 &invoice.header.document_id,
1177 ReferenceType::Payment,
1178 &receipt.header.company_code,
1179 payment_date,
1180 ));
1181
1182 receipt.post(created_by, payment_date);
1183
1184 let expected_remainder_date = Some(
1186 payment_date
1187 + chrono::Duration::days(
1188 self.config.payment_behavior.avg_days_until_remainder as i64,
1189 )
1190 + chrono::Duration::days(self.rng.random_range(-7..7) as i64),
1191 );
1192
1193 (receipt, remaining_amount, expected_remainder_date)
1194 }
1195
1196 pub fn generate_remainder_payment(
1198 &mut self,
1199 invoice: &CustomerInvoice,
1200 company_code: &str,
1201 customer: &Customer,
1202 payment_date: NaiveDate,
1203 fiscal_year: u16,
1204 fiscal_period: u8,
1205 created_by: &str,
1206 amount: Decimal,
1207 ) -> Payment {
1208 self.rec_counter += 1;
1209
1210 let receipt_id =
1211 self.make_doc_id("REC", "customer_receipt", company_code, self.rec_counter);
1212
1213 let mut receipt = Payment::new_ar_receipt(
1214 receipt_id,
1215 company_code,
1216 &customer.customer_id,
1217 amount,
1218 fiscal_year,
1219 fiscal_period,
1220 payment_date,
1221 created_by,
1222 )
1223 .with_payment_method(self.select_payment_method())
1224 .with_value_date(payment_date);
1225
1226 receipt.allocate_to_invoice(
1228 &invoice.header.document_id,
1229 DocumentType::CustomerInvoice,
1230 amount,
1231 Decimal::ZERO, );
1233
1234 receipt.header.add_reference(DocumentReference::new(
1236 DocumentType::CustomerReceipt,
1237 &receipt.header.document_id,
1238 DocumentType::CustomerInvoice,
1239 &invoice.header.document_id,
1240 ReferenceType::Payment,
1241 &receipt.header.company_code,
1242 payment_date,
1243 ));
1244
1245 receipt.post(created_by, payment_date);
1247
1248 receipt
1249 }
1250
1251 pub fn generate_short_payment(
1253 &mut self,
1254 invoice: &CustomerInvoice,
1255 company_code: &str,
1256 customer: &Customer,
1257 payment_date: NaiveDate,
1258 fiscal_year: u16,
1259 fiscal_period: u8,
1260 created_by: &str,
1261 ) -> (Payment, ShortPayment) {
1262 self.rec_counter += 1;
1263 self.short_payment_counter += 1;
1264
1265 let receipt_id =
1266 self.make_doc_id("REC", "customer_receipt", company_code, self.rec_counter);
1267 let short_id = format!("SHORT-{}-{:06}", company_code, self.short_payment_counter);
1268
1269 let full_amount = invoice.amount_open;
1270
1271 let short_percent =
1273 self.rng.random::<f64>() * self.config.payment_behavior.max_short_percent;
1274 let short_amount = (full_amount
1275 * Decimal::from_f64_retain(short_percent).unwrap_or(Decimal::ZERO))
1276 .round_dp(2)
1277 .max(Decimal::ONE); let payment_amount = full_amount - short_amount;
1280
1281 let mut receipt = Payment::new_ar_receipt(
1282 receipt_id.clone(),
1283 company_code,
1284 &customer.customer_id,
1285 payment_amount,
1286 fiscal_year,
1287 fiscal_period,
1288 payment_date,
1289 created_by,
1290 )
1291 .with_payment_method(self.select_payment_method())
1292 .with_value_date(payment_date);
1293
1294 receipt.allocate_to_invoice(
1296 &invoice.header.document_id,
1297 DocumentType::CustomerInvoice,
1298 payment_amount,
1299 Decimal::ZERO,
1300 );
1301
1302 receipt.header.add_reference(DocumentReference::new(
1303 DocumentType::CustomerReceipt,
1304 &receipt.header.document_id,
1305 DocumentType::CustomerInvoice,
1306 &invoice.header.document_id,
1307 ReferenceType::Payment,
1308 &receipt.header.company_code,
1309 payment_date,
1310 ));
1311
1312 receipt.post(created_by, payment_date);
1313
1314 let reason_code = self.select_short_payment_reason();
1316 let short_payment = ShortPayment::new(
1317 short_id,
1318 company_code.to_string(),
1319 customer.customer_id.clone(),
1320 receipt_id,
1321 invoice.header.document_id.clone(),
1322 full_amount,
1323 payment_amount,
1324 invoice.header.currency.clone(),
1325 payment_date,
1326 reason_code,
1327 );
1328
1329 (receipt, short_payment)
1330 }
1331
1332 pub fn generate_on_account_payment(
1334 &mut self,
1335 company_code: &str,
1336 customer: &Customer,
1337 payment_date: NaiveDate,
1338 fiscal_year: u16,
1339 fiscal_period: u8,
1340 created_by: &str,
1341 currency: &str,
1342 amount: Decimal,
1343 ) -> (Payment, OnAccountPayment) {
1344 self.rec_counter += 1;
1345 self.on_account_counter += 1;
1346
1347 let receipt_id =
1348 self.make_doc_id("REC", "customer_receipt", company_code, self.rec_counter);
1349 let on_account_id = format!("OA-{}-{:06}", company_code, self.on_account_counter);
1350
1351 let mut receipt = Payment::new_ar_receipt(
1352 receipt_id.clone(),
1353 company_code,
1354 &customer.customer_id,
1355 amount,
1356 fiscal_year,
1357 fiscal_period,
1358 payment_date,
1359 created_by,
1360 )
1361 .with_payment_method(self.select_payment_method())
1362 .with_value_date(payment_date);
1363
1364 receipt.post(created_by, payment_date);
1366
1367 let reason = self.select_on_account_reason();
1369 let on_account = OnAccountPayment::new(
1370 on_account_id,
1371 company_code.to_string(),
1372 customer.customer_id.clone(),
1373 receipt_id,
1374 amount,
1375 currency.to_string(),
1376 payment_date,
1377 )
1378 .with_reason(reason);
1379
1380 (receipt, on_account)
1381 }
1382
1383 pub fn generate_payment_correction(
1385 &mut self,
1386 original_payment: &Payment,
1387 company_code: &str,
1388 customer_id: &str,
1389 correction_date: NaiveDate,
1390 currency: &str,
1391 ) -> PaymentCorrection {
1392 self.correction_counter += 1;
1393
1394 let correction_id = format!("CORR-{}-{:06}", company_code, self.correction_counter);
1395
1396 let correction_type = if self.rng.random::<f64>() < 0.6 {
1397 PaymentCorrectionType::NSF
1398 } else {
1399 PaymentCorrectionType::Chargeback
1400 };
1401
1402 let mut correction = PaymentCorrection::new(
1403 correction_id,
1404 company_code.to_string(),
1405 customer_id.to_string(),
1406 original_payment.header.document_id.clone(),
1407 correction_type,
1408 original_payment.amount,
1409 original_payment.amount, currency.to_string(),
1411 correction_date,
1412 );
1413
1414 match correction_type {
1416 PaymentCorrectionType::NSF => {
1417 correction.bank_reference = Some(format!("NSF-{}", self.rng.random::<u32>()));
1418 correction.fee_amount = Decimal::from(35); correction.reason = Some("Payment returned - Insufficient funds".to_string());
1420 }
1421 PaymentCorrectionType::Chargeback => {
1422 correction.chargeback_code =
1423 Some(format!("CB{:04}", self.rng.random_range(1000..9999)));
1424 correction.reason = Some("Credit card chargeback".to_string());
1425 }
1426 _ => {}
1427 }
1428
1429 if let Some(allocation) = original_payment.allocations.first() {
1431 correction.add_affected_invoice(allocation.invoice_id.clone());
1432 }
1433
1434 correction
1435 }
1436
1437 fn select_short_payment_reason(&mut self) -> ShortPaymentReasonCode {
1439 let roll: f64 = self.rng.random();
1440 if roll < 0.30 {
1441 ShortPaymentReasonCode::PricingDispute
1442 } else if roll < 0.50 {
1443 ShortPaymentReasonCode::QualityIssue
1444 } else if roll < 0.70 {
1445 ShortPaymentReasonCode::QuantityDiscrepancy
1446 } else if roll < 0.85 {
1447 ShortPaymentReasonCode::UnauthorizedDeduction
1448 } else {
1449 ShortPaymentReasonCode::IncorrectDiscount
1450 }
1451 }
1452
1453 fn select_on_account_reason(&mut self) -> OnAccountReason {
1455 let roll: f64 = self.rng.random();
1456 if roll < 0.40 {
1457 OnAccountReason::NoInvoiceReference
1458 } else if roll < 0.60 {
1459 OnAccountReason::Overpayment
1460 } else if roll < 0.75 {
1461 OnAccountReason::Prepayment
1462 } else if roll < 0.90 {
1463 OnAccountReason::UnclearRemittance
1464 } else {
1465 OnAccountReason::Other
1466 }
1467 }
1468
1469 fn determine_payment_type(&mut self) -> PaymentType {
1471 let roll: f64 = self.rng.random();
1472 let pb = &self.config.payment_behavior;
1473
1474 let mut cumulative = 0.0;
1475
1476 cumulative += pb.partial_payment_rate;
1477 if roll < cumulative {
1478 return PaymentType::Partial;
1479 }
1480
1481 cumulative += pb.short_payment_rate;
1482 if roll < cumulative {
1483 return PaymentType::Short;
1484 }
1485
1486 cumulative += pb.on_account_rate;
1487 if roll < cumulative {
1488 return PaymentType::OnAccount;
1489 }
1490
1491 PaymentType::Full
1492 }
1493
1494 fn determine_partial_payment_percent(&mut self) -> f64 {
1496 let roll: f64 = self.rng.random();
1497 if roll < 0.15 {
1498 0.25
1499 } else if roll < 0.65 {
1500 0.50
1501 } else if roll < 0.90 {
1502 0.75
1503 } else {
1504 0.30 + self.rng.random::<f64>() * 0.50
1506 }
1507 }
1508}
1509
1510#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1512enum PaymentType {
1513 Full,
1514 Partial,
1515 Short,
1516 OnAccount,
1517}
1518
1519#[cfg(test)]
1520#[allow(clippy::unwrap_used)]
1521mod tests {
1522 use super::*;
1523 use datasynth_core::models::{CustomerPaymentBehavior, MaterialType};
1524
1525 fn create_test_customer() -> Customer {
1526 let mut customer = Customer::new(
1527 "C-000001",
1528 "Test Customer Inc.",
1529 datasynth_core::models::CustomerType::Corporate,
1530 );
1531 customer.credit_rating = CreditRating::A;
1532 customer.credit_limit = Decimal::from(1_000_000);
1533 customer.payment_behavior = CustomerPaymentBehavior::OnTime;
1534 customer
1535 }
1536
1537 fn create_test_materials() -> Vec<Material> {
1538 let mut mat1 = Material::new("MAT-001", "Test Product 1", MaterialType::FinishedGood);
1539 mat1.list_price = Decimal::from(100);
1540 mat1.standard_cost = Decimal::from(60);
1541
1542 let mut mat2 = Material::new("MAT-002", "Test Product 2", MaterialType::FinishedGood);
1543 mat2.list_price = Decimal::from(200);
1544 mat2.standard_cost = Decimal::from(120);
1545
1546 vec![mat1, mat2]
1547 }
1548
1549 #[test]
1550 fn test_o2c_chain_generation() {
1551 let mut gen = O2CGenerator::new(42);
1552 let customer = create_test_customer();
1553 let materials = create_test_materials();
1554 let material_refs: Vec<&Material> = materials.iter().collect();
1555
1556 let chain = gen.generate_chain(
1557 "1000",
1558 &customer,
1559 &material_refs,
1560 NaiveDate::from_ymd_opt(2024, 1, 15).unwrap(),
1561 2024,
1562 1,
1563 "JSMITH",
1564 );
1565
1566 assert!(!chain.sales_order.items.is_empty());
1567 assert!(chain.credit_check_passed);
1568 assert!(!chain.deliveries.is_empty());
1569 assert!(chain.customer_invoice.is_some());
1570 }
1571
1572 #[test]
1573 fn test_sales_order_generation() {
1574 let mut gen = O2CGenerator::new(42);
1575 let customer = create_test_customer();
1576 let materials = create_test_materials();
1577 let material_refs: Vec<&Material> = materials.iter().collect();
1578
1579 let so = gen.generate_sales_order(
1580 "1000",
1581 &customer,
1582 &material_refs,
1583 NaiveDate::from_ymd_opt(2024, 1, 15).unwrap(),
1584 2024,
1585 1,
1586 "JSMITH",
1587 );
1588
1589 assert_eq!(so.customer_id, "C-000001");
1590 assert_eq!(so.items.len(), 2);
1591 assert!(so.total_net_amount > Decimal::ZERO);
1592 }
1593
1594 #[test]
1595 fn test_credit_check_failure() {
1596 let config = O2CGeneratorConfig {
1597 credit_check_failure_rate: 1.0, ..Default::default()
1599 };
1600
1601 let mut gen = O2CGenerator::with_config(42, config);
1602 let customer = create_test_customer();
1603 let materials = create_test_materials();
1604 let material_refs: Vec<&Material> = materials.iter().collect();
1605
1606 let chain = gen.generate_chain(
1607 "1000",
1608 &customer,
1609 &material_refs,
1610 NaiveDate::from_ymd_opt(2024, 1, 15).unwrap(),
1611 2024,
1612 1,
1613 "JSMITH",
1614 );
1615
1616 assert!(!chain.credit_check_passed);
1617 assert!(chain.deliveries.is_empty());
1618 assert!(chain.customer_invoice.is_none());
1619 }
1620
1621 #[test]
1622 fn test_document_references() {
1623 let mut gen = O2CGenerator::new(42);
1624 let customer = create_test_customer();
1625 let materials = create_test_materials();
1626 let material_refs: Vec<&Material> = materials.iter().collect();
1627
1628 let chain = gen.generate_chain(
1629 "1000",
1630 &customer,
1631 &material_refs,
1632 NaiveDate::from_ymd_opt(2024, 1, 15).unwrap(),
1633 2024,
1634 1,
1635 "JSMITH",
1636 );
1637
1638 if let Some(dlv) = chain.deliveries.first() {
1640 assert!(!dlv.header.document_references.is_empty());
1641 }
1642
1643 if let Some(invoice) = &chain.customer_invoice {
1645 assert!(invoice.header.document_references.len() >= 2);
1646 }
1647 }
1648
1649 #[test]
1650 fn test_deterministic_generation() {
1651 let customer = create_test_customer();
1652 let materials = create_test_materials();
1653 let material_refs: Vec<&Material> = materials.iter().collect();
1654
1655 let mut gen1 = O2CGenerator::new(42);
1656 let mut gen2 = O2CGenerator::new(42);
1657
1658 let chain1 = gen1.generate_chain(
1659 "1000",
1660 &customer,
1661 &material_refs,
1662 NaiveDate::from_ymd_opt(2024, 1, 15).unwrap(),
1663 2024,
1664 1,
1665 "JSMITH",
1666 );
1667 let chain2 = gen2.generate_chain(
1668 "1000",
1669 &customer,
1670 &material_refs,
1671 NaiveDate::from_ymd_opt(2024, 1, 15).unwrap(),
1672 2024,
1673 1,
1674 "JSMITH",
1675 );
1676
1677 assert_eq!(
1678 chain1.sales_order.header.document_id,
1679 chain2.sales_order.header.document_id
1680 );
1681 assert_eq!(
1682 chain1.sales_order.total_net_amount,
1683 chain2.sales_order.total_net_amount
1684 );
1685 }
1686
1687 #[test]
1688 fn test_partial_shipment_config() {
1689 let config = O2CGeneratorConfig {
1690 partial_shipment_rate: 1.0, ..Default::default()
1692 };
1693
1694 let mut gen = O2CGenerator::with_config(42, config);
1695 let customer = create_test_customer();
1696 let materials = create_test_materials();
1697 let material_refs: Vec<&Material> = materials.iter().collect();
1698
1699 let chain = gen.generate_chain(
1700 "1000",
1701 &customer,
1702 &material_refs,
1703 NaiveDate::from_ymd_opt(2024, 1, 15).unwrap(),
1704 2024,
1705 1,
1706 "JSMITH",
1707 );
1708
1709 assert!(chain.deliveries.len() >= 2);
1711 }
1712
1713 #[test]
1714 fn test_gross_margin() {
1715 let mut gen = O2CGenerator::new(42);
1716 let customer = create_test_customer();
1717 let materials = create_test_materials();
1718 let material_refs: Vec<&Material> = materials.iter().collect();
1719
1720 let chain = gen.generate_chain(
1721 "1000",
1722 &customer,
1723 &material_refs,
1724 NaiveDate::from_ymd_opt(2024, 1, 15).unwrap(),
1725 2024,
1726 1,
1727 "JSMITH",
1728 );
1729
1730 if let Some(invoice) = &chain.customer_invoice {
1731 let margin = invoice.gross_margin();
1733 assert!(margin > Decimal::ZERO, "Gross margin should be positive");
1734 }
1735 }
1736
1737 #[test]
1738 fn test_partial_payment_generates_remainder() {
1739 let config = O2CGeneratorConfig {
1740 bad_debt_rate: 0.0, payment_behavior: O2CPaymentBehavior {
1742 partial_payment_rate: 1.0, short_payment_rate: 0.0,
1744 on_account_rate: 0.0,
1745 payment_correction_rate: 0.0,
1746 ..Default::default()
1747 },
1748 ..Default::default()
1749 };
1750
1751 let mut gen = O2CGenerator::with_config(42, config);
1752 let customer = create_test_customer();
1753 let materials = create_test_materials();
1754 let material_refs: Vec<&Material> = materials.iter().collect();
1755
1756 let chain = gen.generate_chain(
1757 "1000",
1758 &customer,
1759 &material_refs,
1760 NaiveDate::from_ymd_opt(2024, 1, 15).unwrap(),
1761 2024,
1762 1,
1763 "JSMITH",
1764 );
1765
1766 let has_partial = chain
1768 .payment_events
1769 .iter()
1770 .any(|e| matches!(e, PaymentEvent::PartialPayment { .. }));
1771 let has_remainder = chain
1772 .payment_events
1773 .iter()
1774 .any(|e| matches!(e, PaymentEvent::RemainderPayment(_)));
1775
1776 assert!(has_partial, "Should have a PartialPayment event");
1777 assert!(has_remainder, "Should have a RemainderPayment event");
1778 assert!(
1779 chain.payment_events.len() >= 2,
1780 "Should have at least 2 payment events (partial + remainder)"
1781 );
1782 }
1783
1784 #[test]
1785 fn test_partial_plus_remainder_equals_invoice_total() {
1786 let config = O2CGeneratorConfig {
1787 bad_debt_rate: 0.0,
1788 payment_behavior: O2CPaymentBehavior {
1789 partial_payment_rate: 1.0,
1790 short_payment_rate: 0.0,
1791 on_account_rate: 0.0,
1792 payment_correction_rate: 0.0,
1793 ..Default::default()
1794 },
1795 ..Default::default()
1796 };
1797
1798 let mut gen = O2CGenerator::with_config(42, config);
1799 let customer = create_test_customer();
1800 let materials = create_test_materials();
1801 let material_refs: Vec<&Material> = materials.iter().collect();
1802
1803 let chain = gen.generate_chain(
1804 "1000",
1805 &customer,
1806 &material_refs,
1807 NaiveDate::from_ymd_opt(2024, 1, 15).unwrap(),
1808 2024,
1809 1,
1810 "JSMITH",
1811 );
1812
1813 let invoice = chain
1814 .customer_invoice
1815 .as_ref()
1816 .expect("Should have an invoice");
1817
1818 let partial_amount = chain
1820 .payment_events
1821 .iter()
1822 .find_map(|e| {
1823 if let PaymentEvent::PartialPayment { payment, .. } = e {
1824 Some(payment.amount)
1825 } else {
1826 None
1827 }
1828 })
1829 .expect("Should have a partial payment");
1830
1831 let remainder_amount = chain
1833 .payment_events
1834 .iter()
1835 .find_map(|e| {
1836 if let PaymentEvent::RemainderPayment(payment) = e {
1837 Some(payment.amount)
1838 } else {
1839 None
1840 }
1841 })
1842 .expect("Should have a remainder payment");
1843
1844 let total_paid = partial_amount + remainder_amount;
1846 assert_eq!(
1847 total_paid, invoice.total_gross_amount,
1848 "Partial ({}) + remainder ({}) = {} should equal invoice total ({})",
1849 partial_amount, remainder_amount, total_paid, invoice.total_gross_amount
1850 );
1851 }
1852
1853 #[test]
1854 fn test_remainder_receipts_vec_populated() {
1855 let config = O2CGeneratorConfig {
1856 bad_debt_rate: 0.0,
1857 payment_behavior: O2CPaymentBehavior {
1858 partial_payment_rate: 1.0,
1859 short_payment_rate: 0.0,
1860 on_account_rate: 0.0,
1861 payment_correction_rate: 0.0,
1862 ..Default::default()
1863 },
1864 ..Default::default()
1865 };
1866
1867 let mut gen = O2CGenerator::with_config(42, config);
1868 let customer = create_test_customer();
1869 let materials = create_test_materials();
1870 let material_refs: Vec<&Material> = materials.iter().collect();
1871
1872 let chain = gen.generate_chain(
1873 "1000",
1874 &customer,
1875 &material_refs,
1876 NaiveDate::from_ymd_opt(2024, 1, 15).unwrap(),
1877 2024,
1878 1,
1879 "JSMITH",
1880 );
1881
1882 assert!(
1883 !chain.remainder_receipts.is_empty(),
1884 "remainder_receipts should be populated for partial payment chains"
1885 );
1886 assert_eq!(
1887 chain.remainder_receipts.len(),
1888 1,
1889 "Should have exactly one remainder receipt"
1890 );
1891 }
1892
1893 #[test]
1894 fn test_remainder_date_after_partial_date() {
1895 let config = O2CGeneratorConfig {
1896 bad_debt_rate: 0.0,
1897 payment_behavior: O2CPaymentBehavior {
1898 partial_payment_rate: 1.0,
1899 short_payment_rate: 0.0,
1900 max_short_percent: 0.0,
1901 on_account_rate: 0.0,
1902 payment_correction_rate: 0.0,
1903 avg_days_until_remainder: 30,
1904 },
1905 ..Default::default()
1906 };
1907
1908 let mut gen = O2CGenerator::with_config(42, config);
1909 let customer = create_test_customer();
1910 let materials = create_test_materials();
1911 let material_refs: Vec<&Material> = materials.iter().collect();
1912
1913 let chain = gen.generate_chain(
1914 "1000",
1915 &customer,
1916 &material_refs,
1917 NaiveDate::from_ymd_opt(2024, 1, 15).unwrap(),
1918 2024,
1919 1,
1920 "JSMITH",
1921 );
1922
1923 let partial_date = chain
1925 .payment_events
1926 .iter()
1927 .find_map(|e| {
1928 if let PaymentEvent::PartialPayment { payment, .. } = e {
1929 Some(payment.value_date)
1930 } else {
1931 None
1932 }
1933 })
1934 .expect("Should have a partial payment");
1935
1936 let remainder_date = chain
1938 .payment_events
1939 .iter()
1940 .find_map(|e| {
1941 if let PaymentEvent::RemainderPayment(payment) = e {
1942 Some(payment.value_date)
1943 } else {
1944 None
1945 }
1946 })
1947 .expect("Should have a remainder payment");
1948
1949 assert!(
1950 remainder_date > partial_date,
1951 "Remainder date ({}) should be after partial payment date ({})",
1952 remainder_date,
1953 partial_date
1954 );
1955 }
1956
1957 #[test]
1958 fn test_partial_payment_chain_is_complete() {
1959 let config = O2CGeneratorConfig {
1960 bad_debt_rate: 0.0,
1961 payment_behavior: O2CPaymentBehavior {
1962 partial_payment_rate: 1.0,
1963 short_payment_rate: 0.0,
1964 on_account_rate: 0.0,
1965 payment_correction_rate: 0.0,
1966 ..Default::default()
1967 },
1968 ..Default::default()
1969 };
1970
1971 let mut gen = O2CGenerator::with_config(42, config);
1972 let customer = create_test_customer();
1973 let materials = create_test_materials();
1974 let material_refs: Vec<&Material> = materials.iter().collect();
1975
1976 let chain = gen.generate_chain(
1977 "1000",
1978 &customer,
1979 &material_refs,
1980 NaiveDate::from_ymd_opt(2024, 1, 15).unwrap(),
1981 2024,
1982 1,
1983 "JSMITH",
1984 );
1985
1986 assert!(
1988 chain.is_complete,
1989 "Chain with partial + remainder payment should be marked complete"
1990 );
1991 }
1992
1993 #[test]
1994 fn test_non_partial_chain_has_empty_remainder_receipts() {
1995 let config = O2CGeneratorConfig {
1996 bad_debt_rate: 0.0,
1997 payment_behavior: O2CPaymentBehavior {
1998 partial_payment_rate: 0.0, short_payment_rate: 0.0,
2000 on_account_rate: 0.0,
2001 payment_correction_rate: 0.0,
2002 ..Default::default()
2003 },
2004 ..Default::default()
2005 };
2006
2007 let mut gen = O2CGenerator::with_config(42, config);
2008 let customer = create_test_customer();
2009 let materials = create_test_materials();
2010 let material_refs: Vec<&Material> = materials.iter().collect();
2011
2012 let chain = gen.generate_chain(
2013 "1000",
2014 &customer,
2015 &material_refs,
2016 NaiveDate::from_ymd_opt(2024, 1, 15).unwrap(),
2017 2024,
2018 1,
2019 "JSMITH",
2020 );
2021
2022 assert!(
2023 chain.remainder_receipts.is_empty(),
2024 "Non-partial payment chains should have empty remainder_receipts"
2025 );
2026 }
2027
2028 #[test]
2029 fn test_o2c_returns_rate_generates_credit_memos() {
2030 let config = O2CGeneratorConfig {
2031 returns_rate: 1.0, ..Default::default()
2033 };
2034 let mut gen = O2CGenerator::with_config(42, config);
2035 let customer = create_test_customer();
2036 let materials = create_test_materials();
2037 let material_refs: Vec<&Material> = materials.iter().collect();
2038
2039 let chain = gen.generate_chain(
2040 "1000",
2041 &customer,
2042 &material_refs,
2043 NaiveDate::from_ymd_opt(2024, 1, 15).unwrap(),
2044 2024,
2045 1,
2046 "JSMITH",
2047 );
2048
2049 assert!(chain.credit_check_passed);
2050 assert!(chain.is_return);
2051 assert!(chain.credit_memo.is_some());
2052 }
2053
2054 #[test]
2055 fn test_credit_memo_references_invoice() {
2056 let config = O2CGeneratorConfig {
2057 returns_rate: 1.0,
2058 ..Default::default()
2059 };
2060 let mut gen = O2CGenerator::with_config(42, config);
2061 let customer = create_test_customer();
2062 let materials = create_test_materials();
2063 let material_refs: Vec<&Material> = materials.iter().collect();
2064
2065 let chain = gen.generate_chain(
2066 "1000",
2067 &customer,
2068 &material_refs,
2069 NaiveDate::from_ymd_opt(2024, 1, 15).unwrap(),
2070 2024,
2071 1,
2072 "JSMITH",
2073 );
2074
2075 let memo = chain.credit_memo.as_ref().unwrap();
2076 let invoice = chain.customer_invoice.as_ref().unwrap();
2077 assert_eq!(
2078 memo.reference_invoice.as_deref(),
2079 Some(invoice.header.document_id.as_str())
2080 );
2081 }
2082
2083 #[test]
2084 fn test_credit_memo_amount_bounded() {
2085 let config = O2CGeneratorConfig {
2086 returns_rate: 1.0,
2087 ..Default::default()
2088 };
2089 let _ = O2CGenerator::with_config(42, config);
2090 let customer = create_test_customer();
2091 let materials = create_test_materials();
2092 let material_refs: Vec<&Material> = materials.iter().collect();
2093
2094 for seed in 0..10 {
2095 let mut gen = O2CGenerator::with_config(
2096 seed,
2097 O2CGeneratorConfig {
2098 returns_rate: 1.0,
2099 ..Default::default()
2100 },
2101 );
2102 let chain = gen.generate_chain(
2103 "1000",
2104 &customer,
2105 &material_refs,
2106 NaiveDate::from_ymd_opt(2024, 1, 15).unwrap(),
2107 2024,
2108 1,
2109 "JSMITH",
2110 );
2111 if let (Some(memo), Some(invoice)) = (&chain.credit_memo, &chain.customer_invoice) {
2112 assert!(
2113 memo.gross_amount.document_amount <= invoice.total_gross_amount,
2114 "Credit memo gross {:?} exceeds invoice gross {}",
2115 memo.gross_amount.document_amount,
2116 invoice.total_gross_amount
2117 );
2118 }
2119 }
2120 }
2121
2122 #[test]
2123 fn test_zero_returns_rate() {
2124 let customer = create_test_customer();
2125 let materials = create_test_materials();
2126 let material_refs: Vec<&Material> = materials.iter().collect();
2127
2128 for seed in 0..20 {
2129 let mut gen = O2CGenerator::with_config(
2130 seed,
2131 O2CGeneratorConfig {
2132 returns_rate: 0.0,
2133 ..Default::default()
2134 },
2135 );
2136 let chain = gen.generate_chain(
2137 "1000",
2138 &customer,
2139 &material_refs,
2140 NaiveDate::from_ymd_opt(2024, 1, 15).unwrap(),
2141 2024,
2142 1,
2143 "JSMITH",
2144 );
2145 assert!(
2146 chain.credit_memo.is_none(),
2147 "No credit memos with returns_rate=0"
2148 );
2149 assert!(!chain.is_return);
2150 }
2151 }
2152}