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 so.customer_name = Some(customer.name.clone());
529
530 for (idx, material) in materials.iter().enumerate() {
532 let quantity = Decimal::from(self.rng.random_range(1..50));
533 let unit_price = material.list_price;
534
535 let description = self.pick_line_description("sales_order", &material.description);
536 let mut item =
537 SalesOrderItem::new((idx + 1) as u16 * 10, &description, quantity, unit_price)
538 .with_material(&material.material_id);
539
540 item.add_schedule_line(requested_delivery, quantity);
542
543 so.add_item(item);
544 }
545
546 so
547 }
548
549 fn generate_deliveries(
551 &mut self,
552 so: &SalesOrder,
553 company_code: &str,
554 customer: &Customer,
555 delivery_date: NaiveDate,
556 fiscal_year: u16,
557 fiscal_period: u8,
558 created_by: &str,
559 ) -> Vec<Delivery> {
560 let mut deliveries = Vec::new();
561
562 let is_partial = self.rng.random::<f64>() < self.config.partial_shipment_rate;
564
565 if is_partial {
566 let first_pct = 0.6 + self.rng.random::<f64>() * 0.2;
568 let dlv1 = self.create_delivery(
569 so,
570 company_code,
571 customer,
572 delivery_date,
573 fiscal_year,
574 fiscal_period,
575 created_by,
576 first_pct,
577 );
578 deliveries.push(dlv1);
579
580 let second_date =
582 delivery_date + chrono::Duration::days(self.rng.random_range(3..7) as i64);
583 let second_period = self.get_fiscal_period(second_date);
584 let dlv2 = self.create_delivery(
585 so,
586 company_code,
587 customer,
588 second_date,
589 fiscal_year,
590 second_period,
591 created_by,
592 1.0 - first_pct,
593 );
594 deliveries.push(dlv2);
595 } else {
596 let dlv = self.create_delivery(
598 so,
599 company_code,
600 customer,
601 delivery_date,
602 fiscal_year,
603 fiscal_period,
604 created_by,
605 1.0,
606 );
607 deliveries.push(dlv);
608 }
609
610 deliveries
611 }
612
613 fn create_delivery(
615 &mut self,
616 so: &SalesOrder,
617 company_code: &str,
618 customer: &Customer,
619 delivery_date: NaiveDate,
620 fiscal_year: u16,
621 fiscal_period: u8,
622 created_by: &str,
623 quantity_pct: f64,
624 ) -> Delivery {
625 self.dlv_counter += 1;
626
627 let dlv_id = self.make_doc_id("DLV", "delivery", company_code, self.dlv_counter);
628
629 let mut delivery = Delivery::from_sales_order(
630 dlv_id,
631 company_code,
632 &so.header.document_id,
633 &customer.customer_id,
634 format!("SP{}", company_code),
635 fiscal_year,
636 fiscal_period,
637 delivery_date,
638 created_by,
639 );
640
641 for so_item in &so.items {
643 let ship_qty = (so_item.base.quantity
644 * Decimal::from_f64_retain(quantity_pct).unwrap_or(Decimal::ONE))
645 .round_dp(0);
646
647 if ship_qty > Decimal::ZERO {
648 let cogs_pct = 0.60 + self.rng.random::<f64>() * 0.10;
650 let cogs = (so_item.base.unit_price
651 * ship_qty
652 * Decimal::from_f64_retain(cogs_pct)
653 .unwrap_or(Decimal::from_f64_retain(0.65).expect("valid decimal literal")))
654 .round_dp(2);
655
656 let dlv_description =
657 self.pick_line_description("delivery", &so_item.base.description);
658 let mut item = DeliveryItem::from_sales_order(
659 so_item.base.line_number,
660 &dlv_description,
661 ship_qty,
662 so_item.base.unit_price,
663 &so.header.document_id,
664 so_item.base.line_number,
665 )
666 .with_cogs(cogs);
667
668 if let Some(material_id) = &so_item.base.material_id {
669 item = item.with_material(material_id);
670 }
671
672 item.record_pick(ship_qty);
674
675 delivery.add_item(item);
676 }
677 }
678
679 delivery.release_for_picking(created_by);
681 delivery.confirm_pick();
682 delivery.confirm_pack(self.rng.random_range(1..10));
683 delivery.post_goods_issue(created_by, delivery_date);
684
685 delivery
686 }
687
688 fn generate_customer_invoice(
690 &mut self,
691 so: &SalesOrder,
692 deliveries: &[Delivery],
693 company_code: &str,
694 customer: &Customer,
695 invoice_date: NaiveDate,
696 fiscal_year: u16,
697 fiscal_period: u8,
698 created_by: &str,
699 ) -> CustomerInvoice {
700 self.ci_counter += 1;
701
702 let invoice_id = self.make_doc_id("CI", "customer_invoice", company_code, self.ci_counter);
703
704 let due_date = self.calculate_due_date(invoice_date, &customer.payment_terms);
706
707 let mut invoice = CustomerInvoice::from_delivery(
708 invoice_id,
709 company_code,
710 &deliveries[0].header.document_id,
711 &customer.customer_id,
712 fiscal_year,
713 fiscal_period,
714 invoice_date,
715 due_date,
716 created_by,
717 )
718 .with_payment_terms(
719 customer.payment_terms.code(),
720 customer.payment_terms.discount_days(),
721 customer.payment_terms.discount_percent(),
722 );
723
724 invoice.customer_name = Some(customer.name.clone());
726
727 let mut delivered_quantities: std::collections::HashMap<u16, (Decimal, Decimal)> =
729 std::collections::HashMap::new();
730
731 for dlv in deliveries {
732 for dlv_item in &dlv.items {
733 let entry = delivered_quantities
734 .entry(dlv_item.base.line_number)
735 .or_insert((Decimal::ZERO, Decimal::ZERO));
736 entry.0 += dlv_item.base.quantity;
737 entry.1 += dlv_item.cogs_amount;
738 }
739 }
740
741 for so_item in &so.items {
743 if let Some(&(qty, cogs)) = delivered_quantities.get(&so_item.base.line_number) {
744 let ci_description =
745 self.pick_line_description("customer_invoice", &so_item.base.description);
746 let item = CustomerInvoiceItem::from_delivery(
747 so_item.base.line_number,
748 &ci_description,
749 qty,
750 so_item.base.unit_price,
751 &deliveries[0].header.document_id,
752 so_item.base.line_number,
753 )
754 .with_cogs(cogs)
755 .with_sales_order(&so.header.document_id, so_item.base.line_number);
756
757 invoice.add_item(item);
758 }
759 }
760
761 invoice.header.add_reference(DocumentReference::new(
763 DocumentType::SalesOrder,
764 &so.header.document_id,
765 DocumentType::CustomerInvoice,
766 &invoice.header.document_id,
767 ReferenceType::FollowOn,
768 company_code,
769 invoice_date,
770 ));
771
772 for dlv in deliveries {
774 invoice.header.add_reference(DocumentReference::new(
775 DocumentType::Delivery,
776 &dlv.header.document_id,
777 DocumentType::CustomerInvoice,
778 &invoice.header.document_id,
779 ReferenceType::FollowOn,
780 company_code,
781 invoice_date,
782 ));
783 }
784
785 invoice.post(created_by, invoice_date);
787
788 invoice
789 }
790
791 fn generate_customer_receipt(
793 &mut self,
794 invoice: &CustomerInvoice,
795 company_code: &str,
796 customer: &Customer,
797 payment_date: NaiveDate,
798 fiscal_year: u16,
799 fiscal_period: u8,
800 created_by: &str,
801 ) -> Payment {
802 self.rec_counter += 1;
803
804 let receipt_id =
805 self.make_doc_id("REC", "customer_receipt", company_code, self.rec_counter);
806
807 let take_discount = invoice.discount_date_1.is_some_and(|disc_date| {
809 payment_date <= disc_date
810 && self.rng.random::<f64>() < self.config.cash_discount_take_rate
811 });
812
813 let discount_amount = if take_discount {
814 invoice.cash_discount_available(payment_date)
815 } else {
816 Decimal::ZERO
817 };
818
819 let payment_amount = invoice.amount_open - discount_amount;
820
821 let mut receipt = Payment::new_ar_receipt(
822 receipt_id,
823 company_code,
824 &customer.customer_id,
825 payment_amount,
826 fiscal_year,
827 fiscal_period,
828 payment_date,
829 created_by,
830 )
831 .with_payment_method(self.select_payment_method())
832 .with_value_date(payment_date);
833
834 receipt.allocate_to_invoice(
836 &invoice.header.document_id,
837 DocumentType::CustomerInvoice,
838 payment_amount,
839 discount_amount,
840 );
841
842 receipt.header.add_reference(DocumentReference::new(
844 DocumentType::CustomerReceipt,
845 &receipt.header.document_id,
846 DocumentType::CustomerInvoice,
847 &invoice.header.document_id,
848 ReferenceType::Payment,
849 &receipt.header.company_code,
850 payment_date,
851 ));
852
853 receipt.post(created_by, payment_date);
855
856 receipt
857 }
858
859 pub fn generate_chains(
861 &mut self,
862 count: usize,
863 company_code: &str,
864 customers: &CustomerPool,
865 materials: &MaterialPool,
866 date_range: (NaiveDate, NaiveDate),
867 fiscal_year: u16,
868 created_by: &str,
869 ) -> Vec<O2CDocumentChain> {
870 tracing::debug!(count, company_code, "Generating O2C document chains");
871 let mut chains = Vec::new();
872
873 let (start_date, end_date) = date_range;
874 let days_range = (end_date - start_date).num_days() as u64;
875
876 for _ in 0..count {
877 let customer_idx = self.rng.random_range(0..customers.customers.len());
879 let customer = &customers.customers[customer_idx];
880
881 let num_items = self.rng.random_range(1..=5).min(materials.materials.len());
883 let selected_materials: Vec<&Material> = materials
884 .materials
885 .iter()
886 .choose_multiple(&mut self.rng, num_items)
887 .into_iter()
888 .collect();
889
890 let so_date =
892 start_date + chrono::Duration::days(self.rng.random_range(0..=days_range) as i64);
893 let fiscal_period = self.get_fiscal_period(so_date);
894
895 let chain = self.generate_chain(
896 company_code,
897 customer,
898 &selected_materials,
899 so_date,
900 fiscal_year,
901 fiscal_period,
902 created_by,
903 );
904
905 chains.push(chain);
906 }
907
908 chains
909 }
910
911 fn perform_credit_check(&mut self, customer: &Customer, order_amount: Decimal) -> bool {
913 if !customer.can_place_order(order_amount) {
915 return false;
916 }
917
918 let fail_roll = self.rng.random::<f64>();
920 if fail_roll < self.config.credit_check_failure_rate {
921 return false;
922 }
923
924 let additional_fail_rate = match customer.credit_rating {
926 CreditRating::CCC | CreditRating::D => 0.20,
927 CreditRating::B | CreditRating::BB => 0.05,
928 _ => 0.0,
929 };
930
931 self.rng.random::<f64>() >= additional_fail_rate
932 }
933
934 fn calculate_delivery_date(&mut self, so_date: NaiveDate) -> NaiveDate {
936 let variance = self.rng.random_range(0..3) as i64;
937 so_date + chrono::Duration::days(self.config.avg_days_so_to_delivery as i64 + variance)
938 }
939
940 fn calculate_invoice_date(&mut self, delivery_date: NaiveDate) -> NaiveDate {
942 let variance = self.rng.random_range(0..2) as i64;
943 delivery_date
944 + chrono::Duration::days(self.config.avg_days_delivery_to_invoice as i64 + variance)
945 }
946
947 fn calculate_payment_date(
949 &mut self,
950 invoice_date: NaiveDate,
951 payment_terms: &PaymentTerms,
952 customer: &Customer,
953 ) -> NaiveDate {
954 let base_days = payment_terms.net_days() as i64;
955
956 let behavior_adjustment = match customer.payment_behavior {
958 datasynth_core::models::CustomerPaymentBehavior::Excellent
959 | datasynth_core::models::CustomerPaymentBehavior::EarlyPayer => {
960 -self.rng.random_range(5..15) as i64
961 }
962 datasynth_core::models::CustomerPaymentBehavior::Good
963 | datasynth_core::models::CustomerPaymentBehavior::OnTime => {
964 self.rng.random_range(-2..3) as i64
965 }
966 datasynth_core::models::CustomerPaymentBehavior::Fair
967 | datasynth_core::models::CustomerPaymentBehavior::SlightlyLate => {
968 self.rng.random_range(5..15) as i64
969 }
970 datasynth_core::models::CustomerPaymentBehavior::Poor
971 | datasynth_core::models::CustomerPaymentBehavior::OftenLate => {
972 self.rng.random_range(15..45) as i64
973 }
974 datasynth_core::models::CustomerPaymentBehavior::VeryPoor
975 | datasynth_core::models::CustomerPaymentBehavior::HighRisk => {
976 self.rng.random_range(30..90) as i64
977 }
978 };
979
980 let late_adjustment = if self.rng.random::<f64>() < self.config.late_payment_rate {
982 self.rng.random_range(10..30) as i64
983 } else {
984 0
985 };
986
987 invoice_date + chrono::Duration::days(base_days + behavior_adjustment + late_adjustment)
988 }
989
990 fn calculate_due_date(
992 &self,
993 invoice_date: NaiveDate,
994 payment_terms: &PaymentTerms,
995 ) -> NaiveDate {
996 invoice_date + chrono::Duration::days(payment_terms.net_days() as i64)
997 }
998
999 fn select_payment_method(&mut self) -> PaymentMethod {
1001 let roll: f64 = self.rng.random();
1002 let mut cumulative = 0.0;
1003
1004 for (method, prob) in &self.config.payment_method_distribution {
1005 cumulative += prob;
1006 if roll < cumulative {
1007 return *method;
1008 }
1009 }
1010
1011 PaymentMethod::BankTransfer
1012 }
1013
1014 fn get_fiscal_period(&self, date: NaiveDate) -> u8 {
1016 date.month() as u8
1017 }
1018
1019 pub fn reset(&mut self) {
1021 self.rng = seeded_rng(self.seed, 0);
1022 self.so_counter = 0;
1023 self.dlv_counter = 0;
1024 self.ci_counter = 0;
1025 self.rec_counter = 0;
1026 self.short_payment_counter = 0;
1027 self.on_account_counter = 0;
1028 self.correction_counter = 0;
1029 }
1030
1031 pub fn generate_partial_payment(
1033 &mut self,
1034 invoice: &CustomerInvoice,
1035 company_code: &str,
1036 customer: &Customer,
1037 payment_date: NaiveDate,
1038 fiscal_year: u16,
1039 fiscal_period: u8,
1040 created_by: &str,
1041 payment_percent: f64,
1042 ) -> (Payment, Decimal, Option<NaiveDate>) {
1043 self.rec_counter += 1;
1044
1045 let receipt_id =
1046 self.make_doc_id("REC", "customer_receipt", company_code, self.rec_counter);
1047
1048 let full_amount = invoice.amount_open;
1049 let payment_amount = (full_amount
1050 * Decimal::from_f64_retain(payment_percent).unwrap_or(Decimal::ONE))
1051 .round_dp(2);
1052 let remaining_amount = full_amount - payment_amount;
1053
1054 let mut receipt = Payment::new_ar_receipt(
1055 receipt_id,
1056 company_code,
1057 &customer.customer_id,
1058 payment_amount,
1059 fiscal_year,
1060 fiscal_period,
1061 payment_date,
1062 created_by,
1063 )
1064 .with_payment_method(self.select_payment_method())
1065 .with_value_date(payment_date);
1066
1067 receipt.allocate_to_invoice(
1069 &invoice.header.document_id,
1070 DocumentType::CustomerInvoice,
1071 payment_amount,
1072 Decimal::ZERO, );
1074
1075 receipt.header.add_reference(DocumentReference::new(
1077 DocumentType::CustomerReceipt,
1078 &receipt.header.document_id,
1079 DocumentType::CustomerInvoice,
1080 &invoice.header.document_id,
1081 ReferenceType::Payment,
1082 &receipt.header.company_code,
1083 payment_date,
1084 ));
1085
1086 receipt.post(created_by, payment_date);
1087
1088 let expected_remainder_date = Some(
1090 payment_date
1091 + chrono::Duration::days(
1092 self.config.payment_behavior.avg_days_until_remainder as i64,
1093 )
1094 + chrono::Duration::days(self.rng.random_range(-7..7) as i64),
1095 );
1096
1097 (receipt, remaining_amount, expected_remainder_date)
1098 }
1099
1100 pub fn generate_remainder_payment(
1102 &mut self,
1103 invoice: &CustomerInvoice,
1104 company_code: &str,
1105 customer: &Customer,
1106 payment_date: NaiveDate,
1107 fiscal_year: u16,
1108 fiscal_period: u8,
1109 created_by: &str,
1110 amount: Decimal,
1111 ) -> Payment {
1112 self.rec_counter += 1;
1113
1114 let receipt_id =
1115 self.make_doc_id("REC", "customer_receipt", company_code, self.rec_counter);
1116
1117 let mut receipt = Payment::new_ar_receipt(
1118 receipt_id,
1119 company_code,
1120 &customer.customer_id,
1121 amount,
1122 fiscal_year,
1123 fiscal_period,
1124 payment_date,
1125 created_by,
1126 )
1127 .with_payment_method(self.select_payment_method())
1128 .with_value_date(payment_date);
1129
1130 receipt.allocate_to_invoice(
1132 &invoice.header.document_id,
1133 DocumentType::CustomerInvoice,
1134 amount,
1135 Decimal::ZERO, );
1137
1138 receipt.header.add_reference(DocumentReference::new(
1140 DocumentType::CustomerReceipt,
1141 &receipt.header.document_id,
1142 DocumentType::CustomerInvoice,
1143 &invoice.header.document_id,
1144 ReferenceType::Payment,
1145 &receipt.header.company_code,
1146 payment_date,
1147 ));
1148
1149 receipt.post(created_by, payment_date);
1151
1152 receipt
1153 }
1154
1155 pub fn generate_short_payment(
1157 &mut self,
1158 invoice: &CustomerInvoice,
1159 company_code: &str,
1160 customer: &Customer,
1161 payment_date: NaiveDate,
1162 fiscal_year: u16,
1163 fiscal_period: u8,
1164 created_by: &str,
1165 ) -> (Payment, ShortPayment) {
1166 self.rec_counter += 1;
1167 self.short_payment_counter += 1;
1168
1169 let receipt_id =
1170 self.make_doc_id("REC", "customer_receipt", company_code, self.rec_counter);
1171 let short_id = format!("SHORT-{}-{:06}", company_code, self.short_payment_counter);
1172
1173 let full_amount = invoice.amount_open;
1174
1175 let short_percent =
1177 self.rng.random::<f64>() * self.config.payment_behavior.max_short_percent;
1178 let short_amount = (full_amount
1179 * Decimal::from_f64_retain(short_percent).unwrap_or(Decimal::ZERO))
1180 .round_dp(2)
1181 .max(Decimal::ONE); let payment_amount = full_amount - short_amount;
1184
1185 let mut receipt = Payment::new_ar_receipt(
1186 receipt_id.clone(),
1187 company_code,
1188 &customer.customer_id,
1189 payment_amount,
1190 fiscal_year,
1191 fiscal_period,
1192 payment_date,
1193 created_by,
1194 )
1195 .with_payment_method(self.select_payment_method())
1196 .with_value_date(payment_date);
1197
1198 receipt.allocate_to_invoice(
1200 &invoice.header.document_id,
1201 DocumentType::CustomerInvoice,
1202 payment_amount,
1203 Decimal::ZERO,
1204 );
1205
1206 receipt.header.add_reference(DocumentReference::new(
1207 DocumentType::CustomerReceipt,
1208 &receipt.header.document_id,
1209 DocumentType::CustomerInvoice,
1210 &invoice.header.document_id,
1211 ReferenceType::Payment,
1212 &receipt.header.company_code,
1213 payment_date,
1214 ));
1215
1216 receipt.post(created_by, payment_date);
1217
1218 let reason_code = self.select_short_payment_reason();
1220 let short_payment = ShortPayment::new(
1221 short_id,
1222 company_code.to_string(),
1223 customer.customer_id.clone(),
1224 receipt_id,
1225 invoice.header.document_id.clone(),
1226 full_amount,
1227 payment_amount,
1228 invoice.header.currency.clone(),
1229 payment_date,
1230 reason_code,
1231 );
1232
1233 (receipt, short_payment)
1234 }
1235
1236 pub fn generate_on_account_payment(
1238 &mut self,
1239 company_code: &str,
1240 customer: &Customer,
1241 payment_date: NaiveDate,
1242 fiscal_year: u16,
1243 fiscal_period: u8,
1244 created_by: &str,
1245 currency: &str,
1246 amount: Decimal,
1247 ) -> (Payment, OnAccountPayment) {
1248 self.rec_counter += 1;
1249 self.on_account_counter += 1;
1250
1251 let receipt_id =
1252 self.make_doc_id("REC", "customer_receipt", company_code, self.rec_counter);
1253 let on_account_id = format!("OA-{}-{:06}", company_code, self.on_account_counter);
1254
1255 let mut receipt = Payment::new_ar_receipt(
1256 receipt_id.clone(),
1257 company_code,
1258 &customer.customer_id,
1259 amount,
1260 fiscal_year,
1261 fiscal_period,
1262 payment_date,
1263 created_by,
1264 )
1265 .with_payment_method(self.select_payment_method())
1266 .with_value_date(payment_date);
1267
1268 receipt.post(created_by, payment_date);
1270
1271 let reason = self.select_on_account_reason();
1273 let on_account = OnAccountPayment::new(
1274 on_account_id,
1275 company_code.to_string(),
1276 customer.customer_id.clone(),
1277 receipt_id,
1278 amount,
1279 currency.to_string(),
1280 payment_date,
1281 )
1282 .with_reason(reason);
1283
1284 (receipt, on_account)
1285 }
1286
1287 pub fn generate_payment_correction(
1289 &mut self,
1290 original_payment: &Payment,
1291 company_code: &str,
1292 customer_id: &str,
1293 correction_date: NaiveDate,
1294 currency: &str,
1295 ) -> PaymentCorrection {
1296 self.correction_counter += 1;
1297
1298 let correction_id = format!("CORR-{}-{:06}", company_code, self.correction_counter);
1299
1300 let correction_type = if self.rng.random::<f64>() < 0.6 {
1301 PaymentCorrectionType::NSF
1302 } else {
1303 PaymentCorrectionType::Chargeback
1304 };
1305
1306 let mut correction = PaymentCorrection::new(
1307 correction_id,
1308 company_code.to_string(),
1309 customer_id.to_string(),
1310 original_payment.header.document_id.clone(),
1311 correction_type,
1312 original_payment.amount,
1313 original_payment.amount, currency.to_string(),
1315 correction_date,
1316 );
1317
1318 match correction_type {
1320 PaymentCorrectionType::NSF => {
1321 correction.bank_reference = Some(format!("NSF-{}", self.rng.random::<u32>()));
1322 correction.fee_amount = Decimal::from(35); correction.reason = Some("Payment returned - Insufficient funds".to_string());
1324 }
1325 PaymentCorrectionType::Chargeback => {
1326 correction.chargeback_code =
1327 Some(format!("CB{:04}", self.rng.random_range(1000..9999)));
1328 correction.reason = Some("Credit card chargeback".to_string());
1329 }
1330 _ => {}
1331 }
1332
1333 if let Some(allocation) = original_payment.allocations.first() {
1335 correction.add_affected_invoice(allocation.invoice_id.clone());
1336 }
1337
1338 correction
1339 }
1340
1341 fn select_short_payment_reason(&mut self) -> ShortPaymentReasonCode {
1343 let roll: f64 = self.rng.random();
1344 if roll < 0.30 {
1345 ShortPaymentReasonCode::PricingDispute
1346 } else if roll < 0.50 {
1347 ShortPaymentReasonCode::QualityIssue
1348 } else if roll < 0.70 {
1349 ShortPaymentReasonCode::QuantityDiscrepancy
1350 } else if roll < 0.85 {
1351 ShortPaymentReasonCode::UnauthorizedDeduction
1352 } else {
1353 ShortPaymentReasonCode::IncorrectDiscount
1354 }
1355 }
1356
1357 fn select_on_account_reason(&mut self) -> OnAccountReason {
1359 let roll: f64 = self.rng.random();
1360 if roll < 0.40 {
1361 OnAccountReason::NoInvoiceReference
1362 } else if roll < 0.60 {
1363 OnAccountReason::Overpayment
1364 } else if roll < 0.75 {
1365 OnAccountReason::Prepayment
1366 } else if roll < 0.90 {
1367 OnAccountReason::UnclearRemittance
1368 } else {
1369 OnAccountReason::Other
1370 }
1371 }
1372
1373 fn determine_payment_type(&mut self) -> PaymentType {
1375 let roll: f64 = self.rng.random();
1376 let pb = &self.config.payment_behavior;
1377
1378 let mut cumulative = 0.0;
1379
1380 cumulative += pb.partial_payment_rate;
1381 if roll < cumulative {
1382 return PaymentType::Partial;
1383 }
1384
1385 cumulative += pb.short_payment_rate;
1386 if roll < cumulative {
1387 return PaymentType::Short;
1388 }
1389
1390 cumulative += pb.on_account_rate;
1391 if roll < cumulative {
1392 return PaymentType::OnAccount;
1393 }
1394
1395 PaymentType::Full
1396 }
1397
1398 fn determine_partial_payment_percent(&mut self) -> f64 {
1400 let roll: f64 = self.rng.random();
1401 if roll < 0.15 {
1402 0.25
1403 } else if roll < 0.65 {
1404 0.50
1405 } else if roll < 0.90 {
1406 0.75
1407 } else {
1408 0.30 + self.rng.random::<f64>() * 0.50
1410 }
1411 }
1412}
1413
1414#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1416enum PaymentType {
1417 Full,
1418 Partial,
1419 Short,
1420 OnAccount,
1421}
1422
1423#[cfg(test)]
1424#[allow(clippy::unwrap_used)]
1425mod tests {
1426 use super::*;
1427 use datasynth_core::models::{CustomerPaymentBehavior, MaterialType};
1428
1429 fn create_test_customer() -> Customer {
1430 let mut customer = Customer::new(
1431 "C-000001",
1432 "Test Customer Inc.",
1433 datasynth_core::models::CustomerType::Corporate,
1434 );
1435 customer.credit_rating = CreditRating::A;
1436 customer.credit_limit = Decimal::from(1_000_000);
1437 customer.payment_behavior = CustomerPaymentBehavior::OnTime;
1438 customer
1439 }
1440
1441 fn create_test_materials() -> Vec<Material> {
1442 let mut mat1 = Material::new("MAT-001", "Test Product 1", MaterialType::FinishedGood);
1443 mat1.list_price = Decimal::from(100);
1444 mat1.standard_cost = Decimal::from(60);
1445
1446 let mut mat2 = Material::new("MAT-002", "Test Product 2", MaterialType::FinishedGood);
1447 mat2.list_price = Decimal::from(200);
1448 mat2.standard_cost = Decimal::from(120);
1449
1450 vec![mat1, mat2]
1451 }
1452
1453 #[test]
1454 fn test_o2c_chain_generation() {
1455 let mut gen = O2CGenerator::new(42);
1456 let customer = create_test_customer();
1457 let materials = create_test_materials();
1458 let material_refs: Vec<&Material> = materials.iter().collect();
1459
1460 let chain = gen.generate_chain(
1461 "1000",
1462 &customer,
1463 &material_refs,
1464 NaiveDate::from_ymd_opt(2024, 1, 15).unwrap(),
1465 2024,
1466 1,
1467 "JSMITH",
1468 );
1469
1470 assert!(!chain.sales_order.items.is_empty());
1471 assert!(chain.credit_check_passed);
1472 assert!(!chain.deliveries.is_empty());
1473 assert!(chain.customer_invoice.is_some());
1474 }
1475
1476 #[test]
1477 fn test_sales_order_generation() {
1478 let mut gen = O2CGenerator::new(42);
1479 let customer = create_test_customer();
1480 let materials = create_test_materials();
1481 let material_refs: Vec<&Material> = materials.iter().collect();
1482
1483 let so = gen.generate_sales_order(
1484 "1000",
1485 &customer,
1486 &material_refs,
1487 NaiveDate::from_ymd_opt(2024, 1, 15).unwrap(),
1488 2024,
1489 1,
1490 "JSMITH",
1491 );
1492
1493 assert_eq!(so.customer_id, "C-000001");
1494 assert_eq!(so.items.len(), 2);
1495 assert!(so.total_net_amount > Decimal::ZERO);
1496 }
1497
1498 #[test]
1499 fn test_credit_check_failure() {
1500 let config = O2CGeneratorConfig {
1501 credit_check_failure_rate: 1.0, ..Default::default()
1503 };
1504
1505 let mut gen = O2CGenerator::with_config(42, config);
1506 let customer = create_test_customer();
1507 let materials = create_test_materials();
1508 let material_refs: Vec<&Material> = materials.iter().collect();
1509
1510 let chain = gen.generate_chain(
1511 "1000",
1512 &customer,
1513 &material_refs,
1514 NaiveDate::from_ymd_opt(2024, 1, 15).unwrap(),
1515 2024,
1516 1,
1517 "JSMITH",
1518 );
1519
1520 assert!(!chain.credit_check_passed);
1521 assert!(chain.deliveries.is_empty());
1522 assert!(chain.customer_invoice.is_none());
1523 }
1524
1525 #[test]
1526 fn test_document_references() {
1527 let mut gen = O2CGenerator::new(42);
1528 let customer = create_test_customer();
1529 let materials = create_test_materials();
1530 let material_refs: Vec<&Material> = materials.iter().collect();
1531
1532 let chain = gen.generate_chain(
1533 "1000",
1534 &customer,
1535 &material_refs,
1536 NaiveDate::from_ymd_opt(2024, 1, 15).unwrap(),
1537 2024,
1538 1,
1539 "JSMITH",
1540 );
1541
1542 if let Some(dlv) = chain.deliveries.first() {
1544 assert!(!dlv.header.document_references.is_empty());
1545 }
1546
1547 if let Some(invoice) = &chain.customer_invoice {
1549 assert!(invoice.header.document_references.len() >= 2);
1550 }
1551 }
1552
1553 #[test]
1554 fn test_deterministic_generation() {
1555 let customer = create_test_customer();
1556 let materials = create_test_materials();
1557 let material_refs: Vec<&Material> = materials.iter().collect();
1558
1559 let mut gen1 = O2CGenerator::new(42);
1560 let mut gen2 = O2CGenerator::new(42);
1561
1562 let chain1 = gen1.generate_chain(
1563 "1000",
1564 &customer,
1565 &material_refs,
1566 NaiveDate::from_ymd_opt(2024, 1, 15).unwrap(),
1567 2024,
1568 1,
1569 "JSMITH",
1570 );
1571 let chain2 = gen2.generate_chain(
1572 "1000",
1573 &customer,
1574 &material_refs,
1575 NaiveDate::from_ymd_opt(2024, 1, 15).unwrap(),
1576 2024,
1577 1,
1578 "JSMITH",
1579 );
1580
1581 assert_eq!(
1582 chain1.sales_order.header.document_id,
1583 chain2.sales_order.header.document_id
1584 );
1585 assert_eq!(
1586 chain1.sales_order.total_net_amount,
1587 chain2.sales_order.total_net_amount
1588 );
1589 }
1590
1591 #[test]
1592 fn test_partial_shipment_config() {
1593 let config = O2CGeneratorConfig {
1594 partial_shipment_rate: 1.0, ..Default::default()
1596 };
1597
1598 let mut gen = O2CGenerator::with_config(42, config);
1599 let customer = create_test_customer();
1600 let materials = create_test_materials();
1601 let material_refs: Vec<&Material> = materials.iter().collect();
1602
1603 let chain = gen.generate_chain(
1604 "1000",
1605 &customer,
1606 &material_refs,
1607 NaiveDate::from_ymd_opt(2024, 1, 15).unwrap(),
1608 2024,
1609 1,
1610 "JSMITH",
1611 );
1612
1613 assert!(chain.deliveries.len() >= 2);
1615 }
1616
1617 #[test]
1618 fn test_gross_margin() {
1619 let mut gen = O2CGenerator::new(42);
1620 let customer = create_test_customer();
1621 let materials = create_test_materials();
1622 let material_refs: Vec<&Material> = materials.iter().collect();
1623
1624 let chain = gen.generate_chain(
1625 "1000",
1626 &customer,
1627 &material_refs,
1628 NaiveDate::from_ymd_opt(2024, 1, 15).unwrap(),
1629 2024,
1630 1,
1631 "JSMITH",
1632 );
1633
1634 if let Some(invoice) = &chain.customer_invoice {
1635 let margin = invoice.gross_margin();
1637 assert!(margin > Decimal::ZERO, "Gross margin should be positive");
1638 }
1639 }
1640
1641 #[test]
1642 fn test_partial_payment_generates_remainder() {
1643 let config = O2CGeneratorConfig {
1644 bad_debt_rate: 0.0, payment_behavior: O2CPaymentBehavior {
1646 partial_payment_rate: 1.0, short_payment_rate: 0.0,
1648 on_account_rate: 0.0,
1649 payment_correction_rate: 0.0,
1650 ..Default::default()
1651 },
1652 ..Default::default()
1653 };
1654
1655 let mut gen = O2CGenerator::with_config(42, config);
1656 let customer = create_test_customer();
1657 let materials = create_test_materials();
1658 let material_refs: Vec<&Material> = materials.iter().collect();
1659
1660 let chain = gen.generate_chain(
1661 "1000",
1662 &customer,
1663 &material_refs,
1664 NaiveDate::from_ymd_opt(2024, 1, 15).unwrap(),
1665 2024,
1666 1,
1667 "JSMITH",
1668 );
1669
1670 let has_partial = chain
1672 .payment_events
1673 .iter()
1674 .any(|e| matches!(e, PaymentEvent::PartialPayment { .. }));
1675 let has_remainder = chain
1676 .payment_events
1677 .iter()
1678 .any(|e| matches!(e, PaymentEvent::RemainderPayment(_)));
1679
1680 assert!(has_partial, "Should have a PartialPayment event");
1681 assert!(has_remainder, "Should have a RemainderPayment event");
1682 assert!(
1683 chain.payment_events.len() >= 2,
1684 "Should have at least 2 payment events (partial + remainder)"
1685 );
1686 }
1687
1688 #[test]
1689 fn test_partial_plus_remainder_equals_invoice_total() {
1690 let config = O2CGeneratorConfig {
1691 bad_debt_rate: 0.0,
1692 payment_behavior: O2CPaymentBehavior {
1693 partial_payment_rate: 1.0,
1694 short_payment_rate: 0.0,
1695 on_account_rate: 0.0,
1696 payment_correction_rate: 0.0,
1697 ..Default::default()
1698 },
1699 ..Default::default()
1700 };
1701
1702 let mut gen = O2CGenerator::with_config(42, config);
1703 let customer = create_test_customer();
1704 let materials = create_test_materials();
1705 let material_refs: Vec<&Material> = materials.iter().collect();
1706
1707 let chain = gen.generate_chain(
1708 "1000",
1709 &customer,
1710 &material_refs,
1711 NaiveDate::from_ymd_opt(2024, 1, 15).unwrap(),
1712 2024,
1713 1,
1714 "JSMITH",
1715 );
1716
1717 let invoice = chain
1718 .customer_invoice
1719 .as_ref()
1720 .expect("Should have an invoice");
1721
1722 let partial_amount = chain
1724 .payment_events
1725 .iter()
1726 .find_map(|e| {
1727 if let PaymentEvent::PartialPayment { payment, .. } = e {
1728 Some(payment.amount)
1729 } else {
1730 None
1731 }
1732 })
1733 .expect("Should have a partial payment");
1734
1735 let remainder_amount = chain
1737 .payment_events
1738 .iter()
1739 .find_map(|e| {
1740 if let PaymentEvent::RemainderPayment(payment) = e {
1741 Some(payment.amount)
1742 } else {
1743 None
1744 }
1745 })
1746 .expect("Should have a remainder payment");
1747
1748 let total_paid = partial_amount + remainder_amount;
1750 assert_eq!(
1751 total_paid, invoice.total_gross_amount,
1752 "Partial ({}) + remainder ({}) = {} should equal invoice total ({})",
1753 partial_amount, remainder_amount, total_paid, invoice.total_gross_amount
1754 );
1755 }
1756
1757 #[test]
1758 fn test_remainder_receipts_vec_populated() {
1759 let config = O2CGeneratorConfig {
1760 bad_debt_rate: 0.0,
1761 payment_behavior: O2CPaymentBehavior {
1762 partial_payment_rate: 1.0,
1763 short_payment_rate: 0.0,
1764 on_account_rate: 0.0,
1765 payment_correction_rate: 0.0,
1766 ..Default::default()
1767 },
1768 ..Default::default()
1769 };
1770
1771 let mut gen = O2CGenerator::with_config(42, config);
1772 let customer = create_test_customer();
1773 let materials = create_test_materials();
1774 let material_refs: Vec<&Material> = materials.iter().collect();
1775
1776 let chain = gen.generate_chain(
1777 "1000",
1778 &customer,
1779 &material_refs,
1780 NaiveDate::from_ymd_opt(2024, 1, 15).unwrap(),
1781 2024,
1782 1,
1783 "JSMITH",
1784 );
1785
1786 assert!(
1787 !chain.remainder_receipts.is_empty(),
1788 "remainder_receipts should be populated for partial payment chains"
1789 );
1790 assert_eq!(
1791 chain.remainder_receipts.len(),
1792 1,
1793 "Should have exactly one remainder receipt"
1794 );
1795 }
1796
1797 #[test]
1798 fn test_remainder_date_after_partial_date() {
1799 let config = O2CGeneratorConfig {
1800 bad_debt_rate: 0.0,
1801 payment_behavior: O2CPaymentBehavior {
1802 partial_payment_rate: 1.0,
1803 short_payment_rate: 0.0,
1804 max_short_percent: 0.0,
1805 on_account_rate: 0.0,
1806 payment_correction_rate: 0.0,
1807 avg_days_until_remainder: 30,
1808 },
1809 ..Default::default()
1810 };
1811
1812 let mut gen = O2CGenerator::with_config(42, config);
1813 let customer = create_test_customer();
1814 let materials = create_test_materials();
1815 let material_refs: Vec<&Material> = materials.iter().collect();
1816
1817 let chain = gen.generate_chain(
1818 "1000",
1819 &customer,
1820 &material_refs,
1821 NaiveDate::from_ymd_opt(2024, 1, 15).unwrap(),
1822 2024,
1823 1,
1824 "JSMITH",
1825 );
1826
1827 let partial_date = chain
1829 .payment_events
1830 .iter()
1831 .find_map(|e| {
1832 if let PaymentEvent::PartialPayment { payment, .. } = e {
1833 Some(payment.value_date)
1834 } else {
1835 None
1836 }
1837 })
1838 .expect("Should have a partial payment");
1839
1840 let remainder_date = chain
1842 .payment_events
1843 .iter()
1844 .find_map(|e| {
1845 if let PaymentEvent::RemainderPayment(payment) = e {
1846 Some(payment.value_date)
1847 } else {
1848 None
1849 }
1850 })
1851 .expect("Should have a remainder payment");
1852
1853 assert!(
1854 remainder_date > partial_date,
1855 "Remainder date ({}) should be after partial payment date ({})",
1856 remainder_date,
1857 partial_date
1858 );
1859 }
1860
1861 #[test]
1862 fn test_partial_payment_chain_is_complete() {
1863 let config = O2CGeneratorConfig {
1864 bad_debt_rate: 0.0,
1865 payment_behavior: O2CPaymentBehavior {
1866 partial_payment_rate: 1.0,
1867 short_payment_rate: 0.0,
1868 on_account_rate: 0.0,
1869 payment_correction_rate: 0.0,
1870 ..Default::default()
1871 },
1872 ..Default::default()
1873 };
1874
1875 let mut gen = O2CGenerator::with_config(42, config);
1876 let customer = create_test_customer();
1877 let materials = create_test_materials();
1878 let material_refs: Vec<&Material> = materials.iter().collect();
1879
1880 let chain = gen.generate_chain(
1881 "1000",
1882 &customer,
1883 &material_refs,
1884 NaiveDate::from_ymd_opt(2024, 1, 15).unwrap(),
1885 2024,
1886 1,
1887 "JSMITH",
1888 );
1889
1890 assert!(
1892 chain.is_complete,
1893 "Chain with partial + remainder payment should be marked complete"
1894 );
1895 }
1896
1897 #[test]
1898 fn test_non_partial_chain_has_empty_remainder_receipts() {
1899 let config = O2CGeneratorConfig {
1900 bad_debt_rate: 0.0,
1901 payment_behavior: O2CPaymentBehavior {
1902 partial_payment_rate: 0.0, short_payment_rate: 0.0,
1904 on_account_rate: 0.0,
1905 payment_correction_rate: 0.0,
1906 ..Default::default()
1907 },
1908 ..Default::default()
1909 };
1910
1911 let mut gen = O2CGenerator::with_config(42, config);
1912 let customer = create_test_customer();
1913 let materials = create_test_materials();
1914 let material_refs: Vec<&Material> = materials.iter().collect();
1915
1916 let chain = gen.generate_chain(
1917 "1000",
1918 &customer,
1919 &material_refs,
1920 NaiveDate::from_ymd_opt(2024, 1, 15).unwrap(),
1921 2024,
1922 1,
1923 "JSMITH",
1924 );
1925
1926 assert!(
1927 chain.remainder_receipts.is_empty(),
1928 "Non-partial payment chains should have empty remainder_receipts"
1929 );
1930 }
1931}