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 OnAccountPayment, OnAccountReason, PaymentCorrection, PaymentCorrectionType, ShortPayment,
14 ShortPaymentReasonCode,
15 },
16 CreditRating, Customer, CustomerPool, Material, MaterialPool, PaymentTerms,
17};
18use datasynth_core::utils::seeded_rng;
19use datasynth_core::CountryPack;
20use rand::prelude::*;
21use rand_chacha::ChaCha8Rng;
22use rust_decimal::Decimal;
23
24#[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 is_complete: bool,
117 pub credit_check_passed: bool,
119 pub is_return: bool,
121 pub payment_events: Vec<PaymentEvent>,
123 pub remainder_receipts: Vec<Payment>,
125}
126
127#[derive(Debug, Clone)]
129pub enum PaymentEvent {
130 FullPayment(Payment),
132 PartialPayment {
134 payment: Payment,
135 remaining_amount: Decimal,
136 expected_remainder_date: Option<NaiveDate>,
137 },
138 ShortPayment {
140 payment: Payment,
141 short_payment: ShortPayment,
142 },
143 OnAccountPayment(OnAccountPayment),
145 PaymentCorrection {
147 original_payment: Payment,
148 correction: PaymentCorrection,
149 },
150 RemainderPayment(Payment),
152}
153
154pub struct O2CGenerator {
156 rng: ChaCha8Rng,
157 seed: u64,
158 config: O2CGeneratorConfig,
159 so_counter: usize,
160 dlv_counter: usize,
161 ci_counter: usize,
162 rec_counter: usize,
163 short_payment_counter: usize,
164 on_account_counter: usize,
165 correction_counter: usize,
166 country_pack: Option<CountryPack>,
167}
168
169impl O2CGenerator {
170 pub fn new(seed: u64) -> Self {
172 Self::with_config(seed, O2CGeneratorConfig::default())
173 }
174
175 pub fn with_config(seed: u64, config: O2CGeneratorConfig) -> Self {
177 Self {
178 rng: seeded_rng(seed, 0),
179 seed,
180 config,
181 so_counter: 0,
182 dlv_counter: 0,
183 ci_counter: 0,
184 rec_counter: 0,
185 short_payment_counter: 0,
186 on_account_counter: 0,
187 correction_counter: 0,
188 country_pack: None,
189 }
190 }
191
192 pub fn set_country_pack(&mut self, pack: CountryPack) {
194 self.country_pack = Some(pack);
195 }
196
197 fn make_doc_id(
199 &self,
200 default_prefix: &str,
201 pack_key: &str,
202 company_code: &str,
203 counter: usize,
204 ) -> String {
205 let prefix = self
206 .country_pack
207 .as_ref()
208 .map(|p| {
209 let grp = match pack_key {
210 "sales_order" => &p.document_texts.sales_order,
211 "delivery" => &p.document_texts.delivery,
212 "customer_invoice" => &p.document_texts.customer_invoice,
213 "customer_receipt" => &p.document_texts.customer_receipt,
214 _ => return default_prefix.to_string(),
215 };
216 if grp.reference_prefix.is_empty() {
217 default_prefix.to_string()
218 } else {
219 grp.reference_prefix.clone()
220 }
221 })
222 .unwrap_or_else(|| default_prefix.to_string());
223 format!("{}-{}-{:010}", prefix, company_code, counter)
224 }
225
226 fn pick_line_description(&mut self, pack_key: &str, default: &str) -> String {
229 if let Some(pack) = &self.country_pack {
230 let descriptions = match pack_key {
231 "sales_order" => &pack.document_texts.sales_order.line_descriptions,
232 "delivery" => &pack.document_texts.delivery.line_descriptions,
233 "customer_invoice" => &pack.document_texts.customer_invoice.line_descriptions,
234 "customer_receipt" => &pack.document_texts.customer_receipt.line_descriptions,
235 _ => return default.to_string(),
236 };
237 if !descriptions.is_empty() {
238 let idx = self.rng.random_range(0..descriptions.len());
239 return descriptions[idx].clone();
240 }
241 }
242 default.to_string()
243 }
244
245 pub fn generate_chain(
247 &mut self,
248 company_code: &str,
249 customer: &Customer,
250 materials: &[&Material],
251 so_date: NaiveDate,
252 fiscal_year: u16,
253 fiscal_period: u8,
254 created_by: &str,
255 ) -> O2CDocumentChain {
256 let mut so = self.generate_sales_order(
258 company_code,
259 customer,
260 materials,
261 so_date,
262 fiscal_year,
263 fiscal_period,
264 created_by,
265 );
266
267 let credit_check_passed = self.perform_credit_check(customer, so.total_gross_amount);
269 so.check_credit(
270 credit_check_passed,
271 if !credit_check_passed {
272 Some("Credit limit exceeded".to_string())
273 } else {
274 None
275 },
276 );
277
278 if !credit_check_passed {
280 return O2CDocumentChain {
281 sales_order: so,
282 deliveries: Vec::new(),
283 customer_invoice: None,
284 customer_receipt: None,
285 is_complete: false,
286 credit_check_passed: false,
287 is_return: false,
288 payment_events: Vec::new(),
289 remainder_receipts: Vec::new(),
290 };
291 }
292
293 so.release_for_delivery();
295
296 let delivery_date = self.calculate_delivery_date(so_date);
298 let delivery_fiscal_period = self.get_fiscal_period(delivery_date);
299
300 let deliveries = self.generate_deliveries(
302 &so,
303 company_code,
304 customer,
305 delivery_date,
306 fiscal_year,
307 delivery_fiscal_period,
308 created_by,
309 );
310
311 let invoice_date = self.calculate_invoice_date(delivery_date);
313 let invoice_fiscal_period = self.get_fiscal_period(invoice_date);
314
315 so.release_for_billing();
317
318 let customer_invoice = if !deliveries.is_empty() {
320 Some(self.generate_customer_invoice(
321 &so,
322 &deliveries,
323 company_code,
324 customer,
325 invoice_date,
326 fiscal_year,
327 invoice_fiscal_period,
328 created_by,
329 ))
330 } else {
331 None
332 };
333
334 let will_pay = self.rng.random::<f64>() >= self.config.bad_debt_rate;
336
337 let mut payment_events = Vec::new();
339 let mut customer_receipt = None;
340 let mut remainder_receipts = Vec::new();
341
342 if will_pay {
343 if let Some(ref invoice) = customer_invoice {
344 let payment_date =
345 self.calculate_payment_date(invoice_date, &customer.payment_terms, customer);
346 let payment_fiscal_period = self.get_fiscal_period(payment_date);
347
348 let payment_type = self.determine_payment_type();
349
350 match payment_type {
351 PaymentType::Partial => {
352 let payment_percent = self.determine_partial_payment_percent();
353 let (payment, remaining, expected_date) = self.generate_partial_payment(
354 invoice,
355 company_code,
356 customer,
357 payment_date,
358 fiscal_year,
359 payment_fiscal_period,
360 created_by,
361 payment_percent,
362 );
363
364 payment_events.push(PaymentEvent::PartialPayment {
365 payment: payment.clone(),
366 remaining_amount: remaining,
367 expected_remainder_date: expected_date,
368 });
369 customer_receipt = Some(payment);
370
371 if remaining > Decimal::ZERO {
373 let remainder_date = expected_date.unwrap_or(
374 payment_date
375 + chrono::Duration::days(
376 self.config.payment_behavior.avg_days_until_remainder
377 as i64,
378 ),
379 );
380 let remainder_period = self.get_fiscal_period(remainder_date);
381 let remainder_payment = self.generate_remainder_payment(
382 invoice,
383 company_code,
384 customer,
385 remainder_date,
386 fiscal_year,
387 remainder_period,
388 created_by,
389 remaining,
390 );
391 payment_events
392 .push(PaymentEvent::RemainderPayment(remainder_payment.clone()));
393 remainder_receipts.push(remainder_payment);
394 }
395 }
396 PaymentType::Short => {
397 let (payment, short) = self.generate_short_payment(
398 invoice,
399 company_code,
400 customer,
401 payment_date,
402 fiscal_year,
403 payment_fiscal_period,
404 created_by,
405 );
406
407 payment_events.push(PaymentEvent::ShortPayment {
408 payment: payment.clone(),
409 short_payment: short,
410 });
411 customer_receipt = Some(payment);
412 }
413 PaymentType::OnAccount => {
414 let amount = invoice.total_gross_amount
416 * Decimal::from_f64_retain(0.8 + self.rng.random::<f64>() * 0.4)
417 .unwrap_or(Decimal::ONE);
418 let (payment, on_account) = self.generate_on_account_payment(
419 company_code,
420 customer,
421 payment_date,
422 fiscal_year,
423 payment_fiscal_period,
424 created_by,
425 &invoice.header.currency,
426 amount.round_dp(2),
427 );
428
429 payment_events.push(PaymentEvent::OnAccountPayment(on_account));
430 customer_receipt = Some(payment);
431 }
432 PaymentType::Full => {
433 let payment = self.generate_customer_receipt(
434 invoice,
435 company_code,
436 customer,
437 payment_date,
438 fiscal_year,
439 payment_fiscal_period,
440 created_by,
441 );
442
443 if self.rng.random::<f64>()
445 < self.config.payment_behavior.payment_correction_rate
446 {
447 let correction_date = payment_date
448 + chrono::Duration::days(self.rng.random_range(3..14) as i64);
449
450 let correction = self.generate_payment_correction(
451 &payment,
452 company_code,
453 &customer.customer_id,
454 correction_date,
455 &invoice.header.currency,
456 );
457
458 payment_events.push(PaymentEvent::PaymentCorrection {
459 original_payment: payment.clone(),
460 correction,
461 });
462 } else {
463 payment_events.push(PaymentEvent::FullPayment(payment.clone()));
464 }
465
466 customer_receipt = Some(payment);
467 }
468 }
469 }
470 }
471
472 let has_partial = payment_events
473 .iter()
474 .any(|e| matches!(e, PaymentEvent::PartialPayment { .. }));
475 let has_remainder = payment_events
476 .iter()
477 .any(|e| matches!(e, PaymentEvent::RemainderPayment(_)));
478 let has_correction = payment_events
479 .iter()
480 .any(|e| matches!(e, PaymentEvent::PaymentCorrection { .. }));
481
482 let is_complete =
483 customer_receipt.is_some() && !has_correction && (!has_partial || has_remainder);
484
485 O2CDocumentChain {
486 sales_order: so,
487 deliveries,
488 customer_invoice,
489 customer_receipt,
490 is_complete,
491 credit_check_passed: true,
492 is_return: false,
493 payment_events,
494 remainder_receipts,
495 }
496 }
497
498 pub fn generate_sales_order(
500 &mut self,
501 company_code: &str,
502 customer: &Customer,
503 materials: &[&Material],
504 so_date: NaiveDate,
505 fiscal_year: u16,
506 fiscal_period: u8,
507 created_by: &str,
508 ) -> SalesOrder {
509 self.so_counter += 1;
510
511 let so_id = self.make_doc_id("SO", "sales_order", company_code, self.so_counter);
512
513 let requested_delivery =
514 so_date + chrono::Duration::days(self.config.avg_days_so_to_delivery as i64);
515
516 let mut so = SalesOrder::new(
517 so_id,
518 company_code,
519 &customer.customer_id,
520 fiscal_year,
521 fiscal_period,
522 so_date,
523 created_by,
524 )
525 .with_requested_delivery_date(requested_delivery);
526
527 for (idx, material) in materials.iter().enumerate() {
529 let quantity = Decimal::from(self.rng.random_range(1..50));
530 let unit_price = material.list_price;
531
532 let description = self.pick_line_description("sales_order", &material.description);
533 let mut item =
534 SalesOrderItem::new((idx + 1) as u16 * 10, &description, quantity, unit_price)
535 .with_material(&material.material_id);
536
537 item.add_schedule_line(requested_delivery, quantity);
539
540 so.add_item(item);
541 }
542
543 so
544 }
545
546 fn generate_deliveries(
548 &mut self,
549 so: &SalesOrder,
550 company_code: &str,
551 customer: &Customer,
552 delivery_date: NaiveDate,
553 fiscal_year: u16,
554 fiscal_period: u8,
555 created_by: &str,
556 ) -> Vec<Delivery> {
557 let mut deliveries = Vec::new();
558
559 let is_partial = self.rng.random::<f64>() < self.config.partial_shipment_rate;
561
562 if is_partial {
563 let first_pct = 0.6 + self.rng.random::<f64>() * 0.2;
565 let dlv1 = self.create_delivery(
566 so,
567 company_code,
568 customer,
569 delivery_date,
570 fiscal_year,
571 fiscal_period,
572 created_by,
573 first_pct,
574 );
575 deliveries.push(dlv1);
576
577 let second_date =
579 delivery_date + chrono::Duration::days(self.rng.random_range(3..7) as i64);
580 let second_period = self.get_fiscal_period(second_date);
581 let dlv2 = self.create_delivery(
582 so,
583 company_code,
584 customer,
585 second_date,
586 fiscal_year,
587 second_period,
588 created_by,
589 1.0 - first_pct,
590 );
591 deliveries.push(dlv2);
592 } else {
593 let dlv = self.create_delivery(
595 so,
596 company_code,
597 customer,
598 delivery_date,
599 fiscal_year,
600 fiscal_period,
601 created_by,
602 1.0,
603 );
604 deliveries.push(dlv);
605 }
606
607 deliveries
608 }
609
610 fn create_delivery(
612 &mut self,
613 so: &SalesOrder,
614 company_code: &str,
615 customer: &Customer,
616 delivery_date: NaiveDate,
617 fiscal_year: u16,
618 fiscal_period: u8,
619 created_by: &str,
620 quantity_pct: f64,
621 ) -> Delivery {
622 self.dlv_counter += 1;
623
624 let dlv_id = self.make_doc_id("DLV", "delivery", company_code, self.dlv_counter);
625
626 let mut delivery = Delivery::from_sales_order(
627 dlv_id,
628 company_code,
629 &so.header.document_id,
630 &customer.customer_id,
631 format!("SP{}", company_code),
632 fiscal_year,
633 fiscal_period,
634 delivery_date,
635 created_by,
636 );
637
638 for so_item in &so.items {
640 let ship_qty = (so_item.base.quantity
641 * Decimal::from_f64_retain(quantity_pct).unwrap_or(Decimal::ONE))
642 .round_dp(0);
643
644 if ship_qty > Decimal::ZERO {
645 let cogs_pct = 0.60 + self.rng.random::<f64>() * 0.10;
647 let cogs = (so_item.base.unit_price
648 * ship_qty
649 * Decimal::from_f64_retain(cogs_pct)
650 .unwrap_or(Decimal::from_f64_retain(0.65).expect("valid decimal literal")))
651 .round_dp(2);
652
653 let dlv_description =
654 self.pick_line_description("delivery", &so_item.base.description);
655 let mut item = DeliveryItem::from_sales_order(
656 so_item.base.line_number,
657 &dlv_description,
658 ship_qty,
659 so_item.base.unit_price,
660 &so.header.document_id,
661 so_item.base.line_number,
662 )
663 .with_cogs(cogs);
664
665 if let Some(material_id) = &so_item.base.material_id {
666 item = item.with_material(material_id);
667 }
668
669 item.record_pick(ship_qty);
671
672 delivery.add_item(item);
673 }
674 }
675
676 delivery.release_for_picking(created_by);
678 delivery.confirm_pick();
679 delivery.confirm_pack(self.rng.random_range(1..10));
680 delivery.post_goods_issue(created_by, delivery_date);
681
682 delivery
683 }
684
685 fn generate_customer_invoice(
687 &mut self,
688 so: &SalesOrder,
689 deliveries: &[Delivery],
690 company_code: &str,
691 customer: &Customer,
692 invoice_date: NaiveDate,
693 fiscal_year: u16,
694 fiscal_period: u8,
695 created_by: &str,
696 ) -> CustomerInvoice {
697 self.ci_counter += 1;
698
699 let invoice_id = self.make_doc_id("CI", "customer_invoice", company_code, self.ci_counter);
700
701 let due_date = self.calculate_due_date(invoice_date, &customer.payment_terms);
703
704 let mut invoice = CustomerInvoice::from_delivery(
705 invoice_id,
706 company_code,
707 &deliveries[0].header.document_id,
708 &customer.customer_id,
709 fiscal_year,
710 fiscal_period,
711 invoice_date,
712 due_date,
713 created_by,
714 )
715 .with_payment_terms(
716 customer.payment_terms.code(),
717 customer.payment_terms.discount_days(),
718 customer.payment_terms.discount_percent(),
719 );
720
721 let mut delivered_quantities: std::collections::HashMap<u16, (Decimal, Decimal)> =
723 std::collections::HashMap::new();
724
725 for dlv in deliveries {
726 for dlv_item in &dlv.items {
727 let entry = delivered_quantities
728 .entry(dlv_item.base.line_number)
729 .or_insert((Decimal::ZERO, Decimal::ZERO));
730 entry.0 += dlv_item.base.quantity;
731 entry.1 += dlv_item.cogs_amount;
732 }
733 }
734
735 for so_item in &so.items {
737 if let Some(&(qty, cogs)) = delivered_quantities.get(&so_item.base.line_number) {
738 let ci_description =
739 self.pick_line_description("customer_invoice", &so_item.base.description);
740 let item = CustomerInvoiceItem::from_delivery(
741 so_item.base.line_number,
742 &ci_description,
743 qty,
744 so_item.base.unit_price,
745 &deliveries[0].header.document_id,
746 so_item.base.line_number,
747 )
748 .with_cogs(cogs)
749 .with_sales_order(&so.header.document_id, so_item.base.line_number);
750
751 invoice.add_item(item);
752 }
753 }
754
755 invoice.header.add_reference(DocumentReference::new(
757 DocumentType::SalesOrder,
758 &so.header.document_id,
759 DocumentType::CustomerInvoice,
760 &invoice.header.document_id,
761 ReferenceType::FollowOn,
762 company_code,
763 invoice_date,
764 ));
765
766 for dlv in deliveries {
768 invoice.header.add_reference(DocumentReference::new(
769 DocumentType::Delivery,
770 &dlv.header.document_id,
771 DocumentType::CustomerInvoice,
772 &invoice.header.document_id,
773 ReferenceType::FollowOn,
774 company_code,
775 invoice_date,
776 ));
777 }
778
779 invoice.post(created_by, invoice_date);
781
782 invoice
783 }
784
785 fn generate_customer_receipt(
787 &mut self,
788 invoice: &CustomerInvoice,
789 company_code: &str,
790 customer: &Customer,
791 payment_date: NaiveDate,
792 fiscal_year: u16,
793 fiscal_period: u8,
794 created_by: &str,
795 ) -> Payment {
796 self.rec_counter += 1;
797
798 let receipt_id =
799 self.make_doc_id("REC", "customer_receipt", company_code, self.rec_counter);
800
801 let take_discount = invoice.discount_date_1.is_some_and(|disc_date| {
803 payment_date <= disc_date
804 && self.rng.random::<f64>() < self.config.cash_discount_take_rate
805 });
806
807 let discount_amount = if take_discount {
808 invoice.cash_discount_available(payment_date)
809 } else {
810 Decimal::ZERO
811 };
812
813 let payment_amount = invoice.amount_open - discount_amount;
814
815 let mut receipt = Payment::new_ar_receipt(
816 receipt_id,
817 company_code,
818 &customer.customer_id,
819 payment_amount,
820 fiscal_year,
821 fiscal_period,
822 payment_date,
823 created_by,
824 )
825 .with_payment_method(self.select_payment_method())
826 .with_value_date(payment_date);
827
828 receipt.allocate_to_invoice(
830 &invoice.header.document_id,
831 DocumentType::CustomerInvoice,
832 payment_amount,
833 discount_amount,
834 );
835
836 receipt.header.add_reference(DocumentReference::new(
838 DocumentType::CustomerReceipt,
839 &receipt.header.document_id,
840 DocumentType::CustomerInvoice,
841 &invoice.header.document_id,
842 ReferenceType::Payment,
843 &receipt.header.company_code,
844 payment_date,
845 ));
846
847 receipt.post(created_by, payment_date);
849
850 receipt
851 }
852
853 pub fn generate_chains(
855 &mut self,
856 count: usize,
857 company_code: &str,
858 customers: &CustomerPool,
859 materials: &MaterialPool,
860 date_range: (NaiveDate, NaiveDate),
861 fiscal_year: u16,
862 created_by: &str,
863 ) -> Vec<O2CDocumentChain> {
864 tracing::debug!(count, company_code, "Generating O2C document chains");
865 let mut chains = Vec::new();
866
867 let (start_date, end_date) = date_range;
868 let days_range = (end_date - start_date).num_days() as u64;
869
870 for _ in 0..count {
871 let customer_idx = self.rng.random_range(0..customers.customers.len());
873 let customer = &customers.customers[customer_idx];
874
875 let num_items = self.rng.random_range(1..=5).min(materials.materials.len());
877 let selected_materials: Vec<&Material> = materials
878 .materials
879 .iter()
880 .choose_multiple(&mut self.rng, num_items)
881 .into_iter()
882 .collect();
883
884 let so_date =
886 start_date + chrono::Duration::days(self.rng.random_range(0..=days_range) as i64);
887 let fiscal_period = self.get_fiscal_period(so_date);
888
889 let chain = self.generate_chain(
890 company_code,
891 customer,
892 &selected_materials,
893 so_date,
894 fiscal_year,
895 fiscal_period,
896 created_by,
897 );
898
899 chains.push(chain);
900 }
901
902 chains
903 }
904
905 fn perform_credit_check(&mut self, customer: &Customer, order_amount: Decimal) -> bool {
907 if !customer.can_place_order(order_amount) {
909 return false;
910 }
911
912 let fail_roll = self.rng.random::<f64>();
914 if fail_roll < self.config.credit_check_failure_rate {
915 return false;
916 }
917
918 let additional_fail_rate = match customer.credit_rating {
920 CreditRating::CCC | CreditRating::D => 0.20,
921 CreditRating::B | CreditRating::BB => 0.05,
922 _ => 0.0,
923 };
924
925 self.rng.random::<f64>() >= additional_fail_rate
926 }
927
928 fn calculate_delivery_date(&mut self, so_date: NaiveDate) -> NaiveDate {
930 let variance = self.rng.random_range(0..3) as i64;
931 so_date + chrono::Duration::days(self.config.avg_days_so_to_delivery as i64 + variance)
932 }
933
934 fn calculate_invoice_date(&mut self, delivery_date: NaiveDate) -> NaiveDate {
936 let variance = self.rng.random_range(0..2) as i64;
937 delivery_date
938 + chrono::Duration::days(self.config.avg_days_delivery_to_invoice as i64 + variance)
939 }
940
941 fn calculate_payment_date(
943 &mut self,
944 invoice_date: NaiveDate,
945 payment_terms: &PaymentTerms,
946 customer: &Customer,
947 ) -> NaiveDate {
948 let base_days = payment_terms.net_days() as i64;
949
950 let behavior_adjustment = match customer.payment_behavior {
952 datasynth_core::models::CustomerPaymentBehavior::Excellent
953 | datasynth_core::models::CustomerPaymentBehavior::EarlyPayer => {
954 -self.rng.random_range(5..15) as i64
955 }
956 datasynth_core::models::CustomerPaymentBehavior::Good
957 | datasynth_core::models::CustomerPaymentBehavior::OnTime => {
958 self.rng.random_range(-2..3) as i64
959 }
960 datasynth_core::models::CustomerPaymentBehavior::Fair
961 | datasynth_core::models::CustomerPaymentBehavior::SlightlyLate => {
962 self.rng.random_range(5..15) as i64
963 }
964 datasynth_core::models::CustomerPaymentBehavior::Poor
965 | datasynth_core::models::CustomerPaymentBehavior::OftenLate => {
966 self.rng.random_range(15..45) as i64
967 }
968 datasynth_core::models::CustomerPaymentBehavior::VeryPoor
969 | datasynth_core::models::CustomerPaymentBehavior::HighRisk => {
970 self.rng.random_range(30..90) as i64
971 }
972 };
973
974 let late_adjustment = if self.rng.random::<f64>() < self.config.late_payment_rate {
976 self.rng.random_range(10..30) as i64
977 } else {
978 0
979 };
980
981 invoice_date + chrono::Duration::days(base_days + behavior_adjustment + late_adjustment)
982 }
983
984 fn calculate_due_date(
986 &self,
987 invoice_date: NaiveDate,
988 payment_terms: &PaymentTerms,
989 ) -> NaiveDate {
990 invoice_date + chrono::Duration::days(payment_terms.net_days() as i64)
991 }
992
993 fn select_payment_method(&mut self) -> PaymentMethod {
995 let roll: f64 = self.rng.random();
996 let mut cumulative = 0.0;
997
998 for (method, prob) in &self.config.payment_method_distribution {
999 cumulative += prob;
1000 if roll < cumulative {
1001 return *method;
1002 }
1003 }
1004
1005 PaymentMethod::BankTransfer
1006 }
1007
1008 fn get_fiscal_period(&self, date: NaiveDate) -> u8 {
1010 date.month() as u8
1011 }
1012
1013 pub fn reset(&mut self) {
1015 self.rng = seeded_rng(self.seed, 0);
1016 self.so_counter = 0;
1017 self.dlv_counter = 0;
1018 self.ci_counter = 0;
1019 self.rec_counter = 0;
1020 self.short_payment_counter = 0;
1021 self.on_account_counter = 0;
1022 self.correction_counter = 0;
1023 }
1024
1025 pub fn generate_partial_payment(
1027 &mut self,
1028 invoice: &CustomerInvoice,
1029 company_code: &str,
1030 customer: &Customer,
1031 payment_date: NaiveDate,
1032 fiscal_year: u16,
1033 fiscal_period: u8,
1034 created_by: &str,
1035 payment_percent: f64,
1036 ) -> (Payment, Decimal, Option<NaiveDate>) {
1037 self.rec_counter += 1;
1038
1039 let receipt_id =
1040 self.make_doc_id("REC", "customer_receipt", company_code, self.rec_counter);
1041
1042 let full_amount = invoice.amount_open;
1043 let payment_amount = (full_amount
1044 * Decimal::from_f64_retain(payment_percent).unwrap_or(Decimal::ONE))
1045 .round_dp(2);
1046 let remaining_amount = full_amount - payment_amount;
1047
1048 let mut receipt = Payment::new_ar_receipt(
1049 receipt_id,
1050 company_code,
1051 &customer.customer_id,
1052 payment_amount,
1053 fiscal_year,
1054 fiscal_period,
1055 payment_date,
1056 created_by,
1057 )
1058 .with_payment_method(self.select_payment_method())
1059 .with_value_date(payment_date);
1060
1061 receipt.allocate_to_invoice(
1063 &invoice.header.document_id,
1064 DocumentType::CustomerInvoice,
1065 payment_amount,
1066 Decimal::ZERO, );
1068
1069 receipt.header.add_reference(DocumentReference::new(
1071 DocumentType::CustomerReceipt,
1072 &receipt.header.document_id,
1073 DocumentType::CustomerInvoice,
1074 &invoice.header.document_id,
1075 ReferenceType::Payment,
1076 &receipt.header.company_code,
1077 payment_date,
1078 ));
1079
1080 receipt.post(created_by, payment_date);
1081
1082 let expected_remainder_date = Some(
1084 payment_date
1085 + chrono::Duration::days(
1086 self.config.payment_behavior.avg_days_until_remainder as i64,
1087 )
1088 + chrono::Duration::days(self.rng.random_range(-7..7) as i64),
1089 );
1090
1091 (receipt, remaining_amount, expected_remainder_date)
1092 }
1093
1094 pub fn generate_remainder_payment(
1096 &mut self,
1097 invoice: &CustomerInvoice,
1098 company_code: &str,
1099 customer: &Customer,
1100 payment_date: NaiveDate,
1101 fiscal_year: u16,
1102 fiscal_period: u8,
1103 created_by: &str,
1104 amount: Decimal,
1105 ) -> Payment {
1106 self.rec_counter += 1;
1107
1108 let receipt_id =
1109 self.make_doc_id("REC", "customer_receipt", company_code, self.rec_counter);
1110
1111 let mut receipt = Payment::new_ar_receipt(
1112 receipt_id,
1113 company_code,
1114 &customer.customer_id,
1115 amount,
1116 fiscal_year,
1117 fiscal_period,
1118 payment_date,
1119 created_by,
1120 )
1121 .with_payment_method(self.select_payment_method())
1122 .with_value_date(payment_date);
1123
1124 receipt.allocate_to_invoice(
1126 &invoice.header.document_id,
1127 DocumentType::CustomerInvoice,
1128 amount,
1129 Decimal::ZERO, );
1131
1132 receipt.header.add_reference(DocumentReference::new(
1134 DocumentType::CustomerReceipt,
1135 &receipt.header.document_id,
1136 DocumentType::CustomerInvoice,
1137 &invoice.header.document_id,
1138 ReferenceType::Payment,
1139 &receipt.header.company_code,
1140 payment_date,
1141 ));
1142
1143 receipt.post(created_by, payment_date);
1145
1146 receipt
1147 }
1148
1149 pub fn generate_short_payment(
1151 &mut self,
1152 invoice: &CustomerInvoice,
1153 company_code: &str,
1154 customer: &Customer,
1155 payment_date: NaiveDate,
1156 fiscal_year: u16,
1157 fiscal_period: u8,
1158 created_by: &str,
1159 ) -> (Payment, ShortPayment) {
1160 self.rec_counter += 1;
1161 self.short_payment_counter += 1;
1162
1163 let receipt_id =
1164 self.make_doc_id("REC", "customer_receipt", company_code, self.rec_counter);
1165 let short_id = format!("SHORT-{}-{:06}", company_code, self.short_payment_counter);
1166
1167 let full_amount = invoice.amount_open;
1168
1169 let short_percent =
1171 self.rng.random::<f64>() * self.config.payment_behavior.max_short_percent;
1172 let short_amount = (full_amount
1173 * Decimal::from_f64_retain(short_percent).unwrap_or(Decimal::ZERO))
1174 .round_dp(2)
1175 .max(Decimal::ONE); let payment_amount = full_amount - short_amount;
1178
1179 let mut receipt = Payment::new_ar_receipt(
1180 receipt_id.clone(),
1181 company_code,
1182 &customer.customer_id,
1183 payment_amount,
1184 fiscal_year,
1185 fiscal_period,
1186 payment_date,
1187 created_by,
1188 )
1189 .with_payment_method(self.select_payment_method())
1190 .with_value_date(payment_date);
1191
1192 receipt.allocate_to_invoice(
1194 &invoice.header.document_id,
1195 DocumentType::CustomerInvoice,
1196 payment_amount,
1197 Decimal::ZERO,
1198 );
1199
1200 receipt.header.add_reference(DocumentReference::new(
1201 DocumentType::CustomerReceipt,
1202 &receipt.header.document_id,
1203 DocumentType::CustomerInvoice,
1204 &invoice.header.document_id,
1205 ReferenceType::Payment,
1206 &receipt.header.company_code,
1207 payment_date,
1208 ));
1209
1210 receipt.post(created_by, payment_date);
1211
1212 let reason_code = self.select_short_payment_reason();
1214 let short_payment = ShortPayment::new(
1215 short_id,
1216 company_code.to_string(),
1217 customer.customer_id.clone(),
1218 receipt_id,
1219 invoice.header.document_id.clone(),
1220 full_amount,
1221 payment_amount,
1222 invoice.header.currency.clone(),
1223 payment_date,
1224 reason_code,
1225 );
1226
1227 (receipt, short_payment)
1228 }
1229
1230 pub fn generate_on_account_payment(
1232 &mut self,
1233 company_code: &str,
1234 customer: &Customer,
1235 payment_date: NaiveDate,
1236 fiscal_year: u16,
1237 fiscal_period: u8,
1238 created_by: &str,
1239 currency: &str,
1240 amount: Decimal,
1241 ) -> (Payment, OnAccountPayment) {
1242 self.rec_counter += 1;
1243 self.on_account_counter += 1;
1244
1245 let receipt_id =
1246 self.make_doc_id("REC", "customer_receipt", company_code, self.rec_counter);
1247 let on_account_id = format!("OA-{}-{:06}", company_code, self.on_account_counter);
1248
1249 let mut receipt = Payment::new_ar_receipt(
1250 receipt_id.clone(),
1251 company_code,
1252 &customer.customer_id,
1253 amount,
1254 fiscal_year,
1255 fiscal_period,
1256 payment_date,
1257 created_by,
1258 )
1259 .with_payment_method(self.select_payment_method())
1260 .with_value_date(payment_date);
1261
1262 receipt.post(created_by, payment_date);
1264
1265 let reason = self.select_on_account_reason();
1267 let on_account = OnAccountPayment::new(
1268 on_account_id,
1269 company_code.to_string(),
1270 customer.customer_id.clone(),
1271 receipt_id,
1272 amount,
1273 currency.to_string(),
1274 payment_date,
1275 )
1276 .with_reason(reason);
1277
1278 (receipt, on_account)
1279 }
1280
1281 pub fn generate_payment_correction(
1283 &mut self,
1284 original_payment: &Payment,
1285 company_code: &str,
1286 customer_id: &str,
1287 correction_date: NaiveDate,
1288 currency: &str,
1289 ) -> PaymentCorrection {
1290 self.correction_counter += 1;
1291
1292 let correction_id = format!("CORR-{}-{:06}", company_code, self.correction_counter);
1293
1294 let correction_type = if self.rng.random::<f64>() < 0.6 {
1295 PaymentCorrectionType::NSF
1296 } else {
1297 PaymentCorrectionType::Chargeback
1298 };
1299
1300 let mut correction = PaymentCorrection::new(
1301 correction_id,
1302 company_code.to_string(),
1303 customer_id.to_string(),
1304 original_payment.header.document_id.clone(),
1305 correction_type,
1306 original_payment.amount,
1307 original_payment.amount, currency.to_string(),
1309 correction_date,
1310 );
1311
1312 match correction_type {
1314 PaymentCorrectionType::NSF => {
1315 correction.bank_reference = Some(format!("NSF-{}", self.rng.random::<u32>()));
1316 correction.fee_amount = Decimal::from(35); correction.reason = Some("Payment returned - Insufficient funds".to_string());
1318 }
1319 PaymentCorrectionType::Chargeback => {
1320 correction.chargeback_code =
1321 Some(format!("CB{:04}", self.rng.random_range(1000..9999)));
1322 correction.reason = Some("Credit card chargeback".to_string());
1323 }
1324 _ => {}
1325 }
1326
1327 if let Some(allocation) = original_payment.allocations.first() {
1329 correction.add_affected_invoice(allocation.invoice_id.clone());
1330 }
1331
1332 correction
1333 }
1334
1335 fn select_short_payment_reason(&mut self) -> ShortPaymentReasonCode {
1337 let roll: f64 = self.rng.random();
1338 if roll < 0.30 {
1339 ShortPaymentReasonCode::PricingDispute
1340 } else if roll < 0.50 {
1341 ShortPaymentReasonCode::QualityIssue
1342 } else if roll < 0.70 {
1343 ShortPaymentReasonCode::QuantityDiscrepancy
1344 } else if roll < 0.85 {
1345 ShortPaymentReasonCode::UnauthorizedDeduction
1346 } else {
1347 ShortPaymentReasonCode::IncorrectDiscount
1348 }
1349 }
1350
1351 fn select_on_account_reason(&mut self) -> OnAccountReason {
1353 let roll: f64 = self.rng.random();
1354 if roll < 0.40 {
1355 OnAccountReason::NoInvoiceReference
1356 } else if roll < 0.60 {
1357 OnAccountReason::Overpayment
1358 } else if roll < 0.75 {
1359 OnAccountReason::Prepayment
1360 } else if roll < 0.90 {
1361 OnAccountReason::UnclearRemittance
1362 } else {
1363 OnAccountReason::Other
1364 }
1365 }
1366
1367 fn determine_payment_type(&mut self) -> PaymentType {
1369 let roll: f64 = self.rng.random();
1370 let pb = &self.config.payment_behavior;
1371
1372 let mut cumulative = 0.0;
1373
1374 cumulative += pb.partial_payment_rate;
1375 if roll < cumulative {
1376 return PaymentType::Partial;
1377 }
1378
1379 cumulative += pb.short_payment_rate;
1380 if roll < cumulative {
1381 return PaymentType::Short;
1382 }
1383
1384 cumulative += pb.on_account_rate;
1385 if roll < cumulative {
1386 return PaymentType::OnAccount;
1387 }
1388
1389 PaymentType::Full
1390 }
1391
1392 fn determine_partial_payment_percent(&mut self) -> f64 {
1394 let roll: f64 = self.rng.random();
1395 if roll < 0.15 {
1396 0.25
1397 } else if roll < 0.65 {
1398 0.50
1399 } else if roll < 0.90 {
1400 0.75
1401 } else {
1402 0.30 + self.rng.random::<f64>() * 0.50
1404 }
1405 }
1406}
1407
1408#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1410enum PaymentType {
1411 Full,
1412 Partial,
1413 Short,
1414 OnAccount,
1415}
1416
1417#[cfg(test)]
1418#[allow(clippy::unwrap_used)]
1419mod tests {
1420 use super::*;
1421 use datasynth_core::models::{CustomerPaymentBehavior, MaterialType};
1422
1423 fn create_test_customer() -> Customer {
1424 let mut customer = Customer::new(
1425 "C-000001",
1426 "Test Customer Inc.",
1427 datasynth_core::models::CustomerType::Corporate,
1428 );
1429 customer.credit_rating = CreditRating::A;
1430 customer.credit_limit = Decimal::from(1_000_000);
1431 customer.payment_behavior = CustomerPaymentBehavior::OnTime;
1432 customer
1433 }
1434
1435 fn create_test_materials() -> Vec<Material> {
1436 let mut mat1 = Material::new("MAT-001", "Test Product 1", MaterialType::FinishedGood);
1437 mat1.list_price = Decimal::from(100);
1438 mat1.standard_cost = Decimal::from(60);
1439
1440 let mut mat2 = Material::new("MAT-002", "Test Product 2", MaterialType::FinishedGood);
1441 mat2.list_price = Decimal::from(200);
1442 mat2.standard_cost = Decimal::from(120);
1443
1444 vec![mat1, mat2]
1445 }
1446
1447 #[test]
1448 fn test_o2c_chain_generation() {
1449 let mut gen = O2CGenerator::new(42);
1450 let customer = create_test_customer();
1451 let materials = create_test_materials();
1452 let material_refs: Vec<&Material> = materials.iter().collect();
1453
1454 let chain = gen.generate_chain(
1455 "1000",
1456 &customer,
1457 &material_refs,
1458 NaiveDate::from_ymd_opt(2024, 1, 15).unwrap(),
1459 2024,
1460 1,
1461 "JSMITH",
1462 );
1463
1464 assert!(!chain.sales_order.items.is_empty());
1465 assert!(chain.credit_check_passed);
1466 assert!(!chain.deliveries.is_empty());
1467 assert!(chain.customer_invoice.is_some());
1468 }
1469
1470 #[test]
1471 fn test_sales_order_generation() {
1472 let mut gen = O2CGenerator::new(42);
1473 let customer = create_test_customer();
1474 let materials = create_test_materials();
1475 let material_refs: Vec<&Material> = materials.iter().collect();
1476
1477 let so = gen.generate_sales_order(
1478 "1000",
1479 &customer,
1480 &material_refs,
1481 NaiveDate::from_ymd_opt(2024, 1, 15).unwrap(),
1482 2024,
1483 1,
1484 "JSMITH",
1485 );
1486
1487 assert_eq!(so.customer_id, "C-000001");
1488 assert_eq!(so.items.len(), 2);
1489 assert!(so.total_net_amount > Decimal::ZERO);
1490 }
1491
1492 #[test]
1493 fn test_credit_check_failure() {
1494 let config = O2CGeneratorConfig {
1495 credit_check_failure_rate: 1.0, ..Default::default()
1497 };
1498
1499 let mut gen = O2CGenerator::with_config(42, config);
1500 let customer = create_test_customer();
1501 let materials = create_test_materials();
1502 let material_refs: Vec<&Material> = materials.iter().collect();
1503
1504 let chain = gen.generate_chain(
1505 "1000",
1506 &customer,
1507 &material_refs,
1508 NaiveDate::from_ymd_opt(2024, 1, 15).unwrap(),
1509 2024,
1510 1,
1511 "JSMITH",
1512 );
1513
1514 assert!(!chain.credit_check_passed);
1515 assert!(chain.deliveries.is_empty());
1516 assert!(chain.customer_invoice.is_none());
1517 }
1518
1519 #[test]
1520 fn test_document_references() {
1521 let mut gen = O2CGenerator::new(42);
1522 let customer = create_test_customer();
1523 let materials = create_test_materials();
1524 let material_refs: Vec<&Material> = materials.iter().collect();
1525
1526 let chain = gen.generate_chain(
1527 "1000",
1528 &customer,
1529 &material_refs,
1530 NaiveDate::from_ymd_opt(2024, 1, 15).unwrap(),
1531 2024,
1532 1,
1533 "JSMITH",
1534 );
1535
1536 if let Some(dlv) = chain.deliveries.first() {
1538 assert!(!dlv.header.document_references.is_empty());
1539 }
1540
1541 if let Some(invoice) = &chain.customer_invoice {
1543 assert!(invoice.header.document_references.len() >= 2);
1544 }
1545 }
1546
1547 #[test]
1548 fn test_deterministic_generation() {
1549 let customer = create_test_customer();
1550 let materials = create_test_materials();
1551 let material_refs: Vec<&Material> = materials.iter().collect();
1552
1553 let mut gen1 = O2CGenerator::new(42);
1554 let mut gen2 = O2CGenerator::new(42);
1555
1556 let chain1 = gen1.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 let chain2 = gen2.generate_chain(
1566 "1000",
1567 &customer,
1568 &material_refs,
1569 NaiveDate::from_ymd_opt(2024, 1, 15).unwrap(),
1570 2024,
1571 1,
1572 "JSMITH",
1573 );
1574
1575 assert_eq!(
1576 chain1.sales_order.header.document_id,
1577 chain2.sales_order.header.document_id
1578 );
1579 assert_eq!(
1580 chain1.sales_order.total_net_amount,
1581 chain2.sales_order.total_net_amount
1582 );
1583 }
1584
1585 #[test]
1586 fn test_partial_shipment_config() {
1587 let config = O2CGeneratorConfig {
1588 partial_shipment_rate: 1.0, ..Default::default()
1590 };
1591
1592 let mut gen = O2CGenerator::with_config(42, config);
1593 let customer = create_test_customer();
1594 let materials = create_test_materials();
1595 let material_refs: Vec<&Material> = materials.iter().collect();
1596
1597 let chain = gen.generate_chain(
1598 "1000",
1599 &customer,
1600 &material_refs,
1601 NaiveDate::from_ymd_opt(2024, 1, 15).unwrap(),
1602 2024,
1603 1,
1604 "JSMITH",
1605 );
1606
1607 assert!(chain.deliveries.len() >= 2);
1609 }
1610
1611 #[test]
1612 fn test_gross_margin() {
1613 let mut gen = O2CGenerator::new(42);
1614 let customer = create_test_customer();
1615 let materials = create_test_materials();
1616 let material_refs: Vec<&Material> = materials.iter().collect();
1617
1618 let chain = gen.generate_chain(
1619 "1000",
1620 &customer,
1621 &material_refs,
1622 NaiveDate::from_ymd_opt(2024, 1, 15).unwrap(),
1623 2024,
1624 1,
1625 "JSMITH",
1626 );
1627
1628 if let Some(invoice) = &chain.customer_invoice {
1629 let margin = invoice.gross_margin();
1631 assert!(margin > Decimal::ZERO, "Gross margin should be positive");
1632 }
1633 }
1634
1635 #[test]
1636 fn test_partial_payment_generates_remainder() {
1637 let config = O2CGeneratorConfig {
1638 bad_debt_rate: 0.0, payment_behavior: O2CPaymentBehavior {
1640 partial_payment_rate: 1.0, short_payment_rate: 0.0,
1642 on_account_rate: 0.0,
1643 payment_correction_rate: 0.0,
1644 ..Default::default()
1645 },
1646 ..Default::default()
1647 };
1648
1649 let mut gen = O2CGenerator::with_config(42, config);
1650 let customer = create_test_customer();
1651 let materials = create_test_materials();
1652 let material_refs: Vec<&Material> = materials.iter().collect();
1653
1654 let chain = gen.generate_chain(
1655 "1000",
1656 &customer,
1657 &material_refs,
1658 NaiveDate::from_ymd_opt(2024, 1, 15).unwrap(),
1659 2024,
1660 1,
1661 "JSMITH",
1662 );
1663
1664 let has_partial = chain
1666 .payment_events
1667 .iter()
1668 .any(|e| matches!(e, PaymentEvent::PartialPayment { .. }));
1669 let has_remainder = chain
1670 .payment_events
1671 .iter()
1672 .any(|e| matches!(e, PaymentEvent::RemainderPayment(_)));
1673
1674 assert!(has_partial, "Should have a PartialPayment event");
1675 assert!(has_remainder, "Should have a RemainderPayment event");
1676 assert!(
1677 chain.payment_events.len() >= 2,
1678 "Should have at least 2 payment events (partial + remainder)"
1679 );
1680 }
1681
1682 #[test]
1683 fn test_partial_plus_remainder_equals_invoice_total() {
1684 let config = O2CGeneratorConfig {
1685 bad_debt_rate: 0.0,
1686 payment_behavior: O2CPaymentBehavior {
1687 partial_payment_rate: 1.0,
1688 short_payment_rate: 0.0,
1689 on_account_rate: 0.0,
1690 payment_correction_rate: 0.0,
1691 ..Default::default()
1692 },
1693 ..Default::default()
1694 };
1695
1696 let mut gen = O2CGenerator::with_config(42, config);
1697 let customer = create_test_customer();
1698 let materials = create_test_materials();
1699 let material_refs: Vec<&Material> = materials.iter().collect();
1700
1701 let chain = gen.generate_chain(
1702 "1000",
1703 &customer,
1704 &material_refs,
1705 NaiveDate::from_ymd_opt(2024, 1, 15).unwrap(),
1706 2024,
1707 1,
1708 "JSMITH",
1709 );
1710
1711 let invoice = chain
1712 .customer_invoice
1713 .as_ref()
1714 .expect("Should have an invoice");
1715
1716 let partial_amount = chain
1718 .payment_events
1719 .iter()
1720 .find_map(|e| {
1721 if let PaymentEvent::PartialPayment { payment, .. } = e {
1722 Some(payment.amount)
1723 } else {
1724 None
1725 }
1726 })
1727 .expect("Should have a partial payment");
1728
1729 let remainder_amount = chain
1731 .payment_events
1732 .iter()
1733 .find_map(|e| {
1734 if let PaymentEvent::RemainderPayment(payment) = e {
1735 Some(payment.amount)
1736 } else {
1737 None
1738 }
1739 })
1740 .expect("Should have a remainder payment");
1741
1742 let total_paid = partial_amount + remainder_amount;
1744 assert_eq!(
1745 total_paid, invoice.total_gross_amount,
1746 "Partial ({}) + remainder ({}) = {} should equal invoice total ({})",
1747 partial_amount, remainder_amount, total_paid, invoice.total_gross_amount
1748 );
1749 }
1750
1751 #[test]
1752 fn test_remainder_receipts_vec_populated() {
1753 let config = O2CGeneratorConfig {
1754 bad_debt_rate: 0.0,
1755 payment_behavior: O2CPaymentBehavior {
1756 partial_payment_rate: 1.0,
1757 short_payment_rate: 0.0,
1758 on_account_rate: 0.0,
1759 payment_correction_rate: 0.0,
1760 ..Default::default()
1761 },
1762 ..Default::default()
1763 };
1764
1765 let mut gen = O2CGenerator::with_config(42, config);
1766 let customer = create_test_customer();
1767 let materials = create_test_materials();
1768 let material_refs: Vec<&Material> = materials.iter().collect();
1769
1770 let chain = gen.generate_chain(
1771 "1000",
1772 &customer,
1773 &material_refs,
1774 NaiveDate::from_ymd_opt(2024, 1, 15).unwrap(),
1775 2024,
1776 1,
1777 "JSMITH",
1778 );
1779
1780 assert!(
1781 !chain.remainder_receipts.is_empty(),
1782 "remainder_receipts should be populated for partial payment chains"
1783 );
1784 assert_eq!(
1785 chain.remainder_receipts.len(),
1786 1,
1787 "Should have exactly one remainder receipt"
1788 );
1789 }
1790
1791 #[test]
1792 fn test_remainder_date_after_partial_date() {
1793 let config = O2CGeneratorConfig {
1794 bad_debt_rate: 0.0,
1795 payment_behavior: O2CPaymentBehavior {
1796 partial_payment_rate: 1.0,
1797 short_payment_rate: 0.0,
1798 max_short_percent: 0.0,
1799 on_account_rate: 0.0,
1800 payment_correction_rate: 0.0,
1801 avg_days_until_remainder: 30,
1802 },
1803 ..Default::default()
1804 };
1805
1806 let mut gen = O2CGenerator::with_config(42, config);
1807 let customer = create_test_customer();
1808 let materials = create_test_materials();
1809 let material_refs: Vec<&Material> = materials.iter().collect();
1810
1811 let chain = gen.generate_chain(
1812 "1000",
1813 &customer,
1814 &material_refs,
1815 NaiveDate::from_ymd_opt(2024, 1, 15).unwrap(),
1816 2024,
1817 1,
1818 "JSMITH",
1819 );
1820
1821 let partial_date = chain
1823 .payment_events
1824 .iter()
1825 .find_map(|e| {
1826 if let PaymentEvent::PartialPayment { payment, .. } = e {
1827 Some(payment.value_date)
1828 } else {
1829 None
1830 }
1831 })
1832 .expect("Should have a partial payment");
1833
1834 let remainder_date = chain
1836 .payment_events
1837 .iter()
1838 .find_map(|e| {
1839 if let PaymentEvent::RemainderPayment(payment) = e {
1840 Some(payment.value_date)
1841 } else {
1842 None
1843 }
1844 })
1845 .expect("Should have a remainder payment");
1846
1847 assert!(
1848 remainder_date > partial_date,
1849 "Remainder date ({}) should be after partial payment date ({})",
1850 remainder_date,
1851 partial_date
1852 );
1853 }
1854
1855 #[test]
1856 fn test_partial_payment_chain_is_complete() {
1857 let config = O2CGeneratorConfig {
1858 bad_debt_rate: 0.0,
1859 payment_behavior: O2CPaymentBehavior {
1860 partial_payment_rate: 1.0,
1861 short_payment_rate: 0.0,
1862 on_account_rate: 0.0,
1863 payment_correction_rate: 0.0,
1864 ..Default::default()
1865 },
1866 ..Default::default()
1867 };
1868
1869 let mut gen = O2CGenerator::with_config(42, config);
1870 let customer = create_test_customer();
1871 let materials = create_test_materials();
1872 let material_refs: Vec<&Material> = materials.iter().collect();
1873
1874 let chain = gen.generate_chain(
1875 "1000",
1876 &customer,
1877 &material_refs,
1878 NaiveDate::from_ymd_opt(2024, 1, 15).unwrap(),
1879 2024,
1880 1,
1881 "JSMITH",
1882 );
1883
1884 assert!(
1886 chain.is_complete,
1887 "Chain with partial + remainder payment should be marked complete"
1888 );
1889 }
1890
1891 #[test]
1892 fn test_non_partial_chain_has_empty_remainder_receipts() {
1893 let config = O2CGeneratorConfig {
1894 bad_debt_rate: 0.0,
1895 payment_behavior: O2CPaymentBehavior {
1896 partial_payment_rate: 0.0, short_payment_rate: 0.0,
1898 on_account_rate: 0.0,
1899 payment_correction_rate: 0.0,
1900 ..Default::default()
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 assert!(
1921 chain.remainder_receipts.is_empty(),
1922 "Non-partial payment chains should have empty remainder_receipts"
1923 );
1924 }
1925}