1use std::collections::HashMap;
17
18use chrono::NaiveDate;
19use rust_decimal::Decimal;
20
21use datasynth_core::accounts::{
22 cash_accounts, control_accounts, expense_accounts, revenue_accounts, tax_accounts,
23};
24use datasynth_core::models::{
25 documents::{CustomerInvoice, Delivery, GoodsReceipt, Payment, VendorInvoice},
26 BusinessProcess, DocumentRef, JournalEntry, JournalEntryHeader, JournalEntryLine,
27 TransactionSource,
28};
29use datasynth_core::uuid_factory::{DeterministicUuidFactory, GeneratorType};
30
31use super::{O2CDocumentChain, P2PDocumentChain};
32
33#[derive(Debug, Clone)]
35pub struct DocumentFlowJeConfig {
36 pub inventory_account: String,
38 pub gr_ir_clearing_account: String,
40 pub ap_account: String,
42 pub cash_account: String,
44 pub ar_account: String,
46 pub revenue_account: String,
48 pub cogs_account: String,
50 pub vat_output_account: String,
52 pub vat_input_account: String,
54 pub populate_fec_fields: bool,
57}
58
59impl Default for DocumentFlowJeConfig {
60 fn default() -> Self {
61 Self {
62 inventory_account: control_accounts::INVENTORY.to_string(),
63 gr_ir_clearing_account: control_accounts::GR_IR_CLEARING.to_string(),
64 ap_account: control_accounts::AP_CONTROL.to_string(),
65 cash_account: cash_accounts::OPERATING_CASH.to_string(),
66 ar_account: control_accounts::AR_CONTROL.to_string(),
67 revenue_account: revenue_accounts::PRODUCT_REVENUE.to_string(),
68 cogs_account: expense_accounts::COGS.to_string(),
69 vat_output_account: tax_accounts::VAT_PAYABLE.to_string(),
70 vat_input_account: tax_accounts::INPUT_VAT.to_string(),
71 populate_fec_fields: false,
72 }
73 }
74}
75
76impl DocumentFlowJeConfig {
77 pub fn french_gaap() -> Self {
79 use datasynth_core::pcg;
80 Self {
81 inventory_account: pcg::control_accounts::INVENTORY.to_string(),
82 gr_ir_clearing_account: pcg::control_accounts::GR_IR_CLEARING.to_string(),
83 ap_account: pcg::control_accounts::AP_CONTROL.to_string(),
84 cash_account: pcg::cash_accounts::BANK_ACCOUNT.to_string(),
85 ar_account: pcg::control_accounts::AR_CONTROL.to_string(),
86 revenue_account: pcg::revenue_accounts::PRODUCT_REVENUE.to_string(),
87 cogs_account: pcg::expense_accounts::COGS.to_string(),
88 vat_output_account: pcg::tax_accounts::OUTPUT_VAT.to_string(),
89 vat_input_account: pcg::tax_accounts::INPUT_VAT.to_string(),
90 populate_fec_fields: true,
91 }
92 }
93}
94
95impl From<&datasynth_core::FrameworkAccounts> for DocumentFlowJeConfig {
96 fn from(fa: &datasynth_core::FrameworkAccounts) -> Self {
97 Self {
98 inventory_account: fa.inventory.clone(),
99 gr_ir_clearing_account: fa.gr_ir_clearing.clone(),
100 ap_account: fa.ap_control.clone(),
101 cash_account: fa.bank_account.clone(),
102 ar_account: fa.ar_control.clone(),
103 revenue_account: fa.product_revenue.clone(),
104 cogs_account: fa.cogs.clone(),
105 vat_output_account: fa.vat_payable.clone(),
106 vat_input_account: fa.input_vat.clone(),
107 populate_fec_fields: fa.audit_export.fec_enabled,
108 }
109 }
110}
111
112pub struct DocumentFlowJeGenerator {
114 config: DocumentFlowJeConfig,
115 uuid_factory: DeterministicUuidFactory,
116 auxiliary_account_lookup: HashMap<String, String>,
121 cost_center_pool: Vec<String>,
126 profit_center_pool: Vec<String>,
129}
130
131impl DocumentFlowJeGenerator {
132 pub fn new() -> Self {
134 Self::with_config_and_seed(DocumentFlowJeConfig::default(), 0)
135 }
136
137 pub fn with_config_and_seed(config: DocumentFlowJeConfig, seed: u64) -> Self {
139 Self {
140 config,
141 uuid_factory: DeterministicUuidFactory::new(seed, GeneratorType::DocumentFlow),
142 auxiliary_account_lookup: HashMap::new(),
143 cost_center_pool: Vec::new(),
144 profit_center_pool: Vec::new(),
145 }
146 }
147
148 pub fn set_auxiliary_account_lookup(&mut self, lookup: HashMap<String, String>) {
154 self.auxiliary_account_lookup = lookup;
155 }
156
157 pub fn set_cost_center_pool(&mut self, ids: Vec<String>) {
160 self.cost_center_pool = ids;
161 }
162
163 pub fn set_profit_center_pool(&mut self, ids: Vec<String>) {
165 self.profit_center_pool = ids;
166 }
167
168 fn account_description_map(&self) -> HashMap<String, String> {
170 let mut map = HashMap::new();
171 map.insert(
172 self.config.inventory_account.clone(),
173 "Inventory".to_string(),
174 );
175 map.insert(
176 self.config.gr_ir_clearing_account.clone(),
177 "GR/IR Clearing".to_string(),
178 );
179 map.insert(
180 self.config.ap_account.clone(),
181 "Accounts Payable".to_string(),
182 );
183 map.insert(
184 self.config.cash_account.clone(),
185 "Cash and Cash Equivalents".to_string(),
186 );
187 map.insert(
188 self.config.ar_account.clone(),
189 "Accounts Receivable".to_string(),
190 );
191 map.insert(
192 self.config.revenue_account.clone(),
193 "Product Revenue".to_string(),
194 );
195 map.insert(
196 self.config.cogs_account.clone(),
197 "Cost of Goods Sold".to_string(),
198 );
199 map.insert(
200 self.config.vat_output_account.clone(),
201 "VAT Payable".to_string(),
202 );
203 map.insert(
204 self.config.vat_input_account.clone(),
205 "Input VAT".to_string(),
206 );
207 map
208 }
209
210 const COST_CENTER_POOL: &'static [&'static str] =
212 &["CC1000", "CC2000", "CC3000", "CC4000", "CC5000"];
213
214 fn enrich_line_items(&self, entry: &mut JournalEntry) {
220 let desc_map = self.account_description_map();
221 let posting_date = entry.header.posting_date;
222 let company_code = &entry.header.company_code;
223 let header_text = entry.header.header_text.clone();
224 let business_process = entry.header.business_process;
225
226 let doc_id_bytes = entry.header.document_id.as_bytes();
228 let mut cc_seed: usize = 0;
229 for &b in doc_id_bytes {
230 cc_seed = cc_seed.wrapping_add(b as usize);
231 }
232
233 for (i, line) in entry.lines.iter_mut().enumerate() {
234 if line.account_description.is_none() {
236 line.account_description = desc_map.get(&line.gl_account).cloned();
237 }
238
239 if line.cost_center.is_none() {
245 let first_char = line.gl_account.chars().next().unwrap_or('0');
246 if first_char == '5' || first_char == '6' {
247 if !self.cost_center_pool.is_empty() {
248 let needle = format!("-{company_code}-");
249 let candidates: Vec<&String> = self
250 .cost_center_pool
251 .iter()
252 .filter(|id| id.contains(&needle))
253 .collect();
254 let pool: Vec<&String> = if candidates.is_empty() {
255 self.cost_center_pool.iter().collect()
256 } else {
257 candidates
258 };
259 let idx = cc_seed.wrapping_add(i) % pool.len();
260 line.cost_center = Some(pool[idx].clone());
261 } else {
262 let idx = cc_seed.wrapping_add(i) % Self::COST_CENTER_POOL.len();
263 line.cost_center = Some(Self::COST_CENTER_POOL[idx].to_string());
264 }
265 }
266 }
267
268 if line.profit_center.is_none() {
271 if !self.profit_center_pool.is_empty() {
272 let needle = format!("-{company_code}-");
273 let candidates: Vec<&String> = self
274 .profit_center_pool
275 .iter()
276 .filter(|id| id.contains(&needle))
277 .collect();
278 let pool: Vec<&String> = if candidates.is_empty() {
279 self.profit_center_pool.iter().collect()
280 } else {
281 candidates
282 };
283 let idx = cc_seed.wrapping_add(i) % pool.len();
284 line.profit_center = Some(pool[idx].clone());
285 } else {
286 let suffix = match business_process {
287 Some(BusinessProcess::P2P) => "-P2P",
288 Some(BusinessProcess::O2C) => "-O2C",
289 _ => "",
290 };
291 line.profit_center = Some(format!("PC-{company_code}{suffix}"));
292 }
293 }
294
295 if line.line_text.is_none() {
297 line.line_text = header_text.clone();
298 }
299
300 if line.value_date.is_none()
302 && (line.gl_account == self.config.ar_account
303 || line.gl_account == self.config.ap_account)
304 {
305 line.value_date = Some(posting_date);
306 }
307
308 if line.assignment.is_none()
310 && (line.gl_account == self.config.ap_account
311 || line.gl_account == self.config.ar_account)
312 {
313 if let Some(ref ht) = header_text {
314 if let Some(partner_part) = ht.rsplit(" - ").next() {
315 line.assignment = Some(partner_part.to_string());
316 }
317 }
318 }
319 }
320 }
321
322 fn set_auxiliary_fields(
331 &self,
332 line: &mut JournalEntryLine,
333 partner_id: &str,
334 partner_label: &str,
335 ) {
336 if !self.config.populate_fec_fields {
337 return;
338 }
339 if line.gl_account == self.config.ap_account || line.gl_account == self.config.ar_account {
340 let aux_account = self
343 .auxiliary_account_lookup
344 .get(partner_id)
345 .cloned()
346 .unwrap_or_else(|| partner_id.to_string());
347 line.auxiliary_account_number = Some(aux_account);
348 line.auxiliary_account_label = Some(partner_label.to_string());
349 }
350 }
351
352 fn apply_lettrage(
358 &self,
359 entries: &mut [JournalEntry],
360 chain_id: &str,
361 lettrage_date: NaiveDate,
362 ) {
363 if !self.config.populate_fec_fields {
364 return;
365 }
366 let code = format!("LTR-{}", &chain_id[..chain_id.len().min(8)]);
367 for entry in entries.iter_mut() {
368 for line in entry.lines.iter_mut() {
369 if line.gl_account == self.config.ap_account
370 || line.gl_account == self.config.ar_account
371 {
372 line.lettrage = Some(code.clone());
373 line.lettrage_date = Some(lettrage_date);
374 }
375 }
376 }
377 }
378
379 fn wire_predecessor_chain(entries: &mut [JournalEntry]) {
405 if entries.len() < 2 {
406 return;
407 }
408 for i in 1..entries.len() {
409 let prev_lines: Vec<(String, String)> = entries[i - 1]
412 .lines
413 .iter()
414 .map(|l| {
415 let tx_id = l.transaction_id.clone().unwrap_or_else(|| {
416 datasynth_core::models::JournalEntryLine::derive_transaction_id(
417 l.document_id,
418 l.line_number,
419 )
420 });
421 (l.gl_account.clone(), tx_id)
422 })
423 .collect();
424
425 for line in entries[i].lines.iter_mut() {
426 if line.predecessor_line_id.is_some() {
427 continue;
428 }
429 if let Some((_, tx_id)) =
430 prev_lines.iter().find(|(acct, _)| acct == &line.gl_account)
431 {
432 line.predecessor_line_id = Some(tx_id.clone());
433 }
434 }
435 }
436 }
437
438 pub fn generate_from_p2p_chain(&mut self, chain: &P2PDocumentChain) -> Vec<JournalEntry> {
440 let mut entries = Vec::new();
441
442 for gr in &chain.goods_receipts {
444 if let Some(je) = self.generate_from_goods_receipt(gr) {
445 entries.push(je);
446 }
447 }
448
449 if let Some(ref invoice) = chain.vendor_invoice {
451 if let Some(je) = self.generate_from_vendor_invoice(invoice) {
452 entries.push(je);
453 }
454 }
455
456 if let Some(ref payment) = chain.payment {
458 if let Some(je) = self.generate_from_ap_payment(payment) {
459 entries.push(je);
460 }
461 }
462
463 for payment in &chain.remainder_payments {
465 if let Some(je) = self.generate_from_ap_payment(payment) {
466 entries.push(je);
467 }
468 }
469
470 if self.config.populate_fec_fields && chain.is_complete {
472 if let Some(ref payment) = chain.payment {
473 let posting_date = payment
474 .header
475 .posting_date
476 .unwrap_or(payment.header.document_date);
477 self.apply_lettrage(
478 &mut entries,
479 &chain.purchase_order.header.document_id,
480 posting_date,
481 );
482 }
483 }
484
485 Self::wire_predecessor_chain(&mut entries);
488
489 entries
490 }
491
492 pub fn generate_from_o2c_chain(&mut self, chain: &O2CDocumentChain) -> Vec<JournalEntry> {
494 let mut entries = Vec::new();
495
496 for delivery in &chain.deliveries {
498 if let Some(je) = self.generate_from_delivery(delivery) {
499 entries.push(je);
500 }
501 }
502
503 if let Some(ref invoice) = chain.customer_invoice {
505 if let Some(je) = self.generate_from_customer_invoice(invoice) {
506 entries.push(je);
507 }
508 }
509
510 if let Some(ref receipt) = chain.customer_receipt {
512 if let Some(je) = self.generate_from_ar_receipt(receipt) {
513 entries.push(je);
514 }
515 }
516
517 for receipt in &chain.remainder_receipts {
519 if let Some(je) = self.generate_from_ar_receipt(receipt) {
520 entries.push(je);
521 }
522 }
523
524 if self.config.populate_fec_fields && chain.customer_receipt.is_some() {
526 if let Some(ref receipt) = chain.customer_receipt {
527 let posting_date = receipt
528 .header
529 .posting_date
530 .unwrap_or(receipt.header.document_date);
531 self.apply_lettrage(
532 &mut entries,
533 &chain.sales_order.header.document_id,
534 posting_date,
535 );
536 }
537 }
538
539 Self::wire_predecessor_chain(&mut entries);
541
542 entries
543 }
544
545 pub fn generate_from_goods_receipt(&mut self, gr: &GoodsReceipt) -> Option<JournalEntry> {
548 if gr.items.is_empty() {
549 return None;
550 }
551
552 let document_id = self.uuid_factory.next();
553
554 let total_amount = if gr.total_value > Decimal::ZERO {
556 gr.total_value
557 } else {
558 gr.items
559 .iter()
560 .map(|item| item.base.net_amount)
561 .sum::<Decimal>()
562 };
563
564 if total_amount == Decimal::ZERO {
565 return None;
566 }
567
568 let posting_date = gr.header.posting_date.unwrap_or(gr.header.document_date);
570
571 let mut header = JournalEntryHeader::with_deterministic_id(
572 gr.header.company_code.clone(),
573 posting_date,
574 document_id,
575 );
576 header.source = TransactionSource::Automated;
577 header.business_process = Some(BusinessProcess::P2P);
578 header.document_type = "WE".to_string();
579 header.reference = Some(format!("GR:{}", gr.header.document_id));
580 header.source_document = Some(DocumentRef::GoodsReceipt(gr.header.document_id.clone()));
581 header.header_text = Some(format!(
582 "Goods Receipt {} - {}",
583 gr.header.document_id,
584 gr.vendor_id.as_deref().unwrap_or("Unknown")
585 ));
586
587 let mut entry = JournalEntry::new(header);
588
589 let debit_line = JournalEntryLine::debit(
591 entry.header.document_id,
592 1,
593 self.config.inventory_account.clone(),
594 total_amount,
595 );
596 entry.add_line(debit_line);
597
598 let credit_line = JournalEntryLine::credit(
600 entry.header.document_id,
601 2,
602 self.config.gr_ir_clearing_account.clone(),
603 total_amount,
604 );
605 entry.add_line(credit_line);
606
607 self.enrich_line_items(&mut entry);
608 Some(entry)
609 }
610
611 pub fn generate_from_vendor_invoice(
622 &mut self,
623 invoice: &VendorInvoice,
624 ) -> Option<JournalEntry> {
625 if invoice.payable_amount == Decimal::ZERO {
626 return None;
627 }
628
629 let document_id = self.uuid_factory.next();
630
631 let posting_date = invoice
633 .header
634 .posting_date
635 .unwrap_or(invoice.header.document_date);
636
637 let mut header = JournalEntryHeader::with_deterministic_id(
638 invoice.header.company_code.clone(),
639 posting_date,
640 document_id,
641 );
642 header.source = TransactionSource::Automated;
643 header.business_process = Some(BusinessProcess::P2P);
644 header.document_type = "KR".to_string();
645 header.reference = Some(format!("VI:{}", invoice.header.document_id));
646 header.source_document = Some(DocumentRef::VendorInvoice(
647 invoice.header.document_id.clone(),
648 ));
649 header.header_text = Some(format!(
650 "Vendor Invoice {} - {}",
651 invoice.vendor_invoice_number, invoice.vendor_id
652 ));
653
654 let mut entry = JournalEntry::new(header);
655
656 let has_vat = invoice.tax_amount > Decimal::ZERO;
657 let clearing_amount = if has_vat {
658 invoice.net_amount
659 } else {
660 invoice.payable_amount
661 };
662
663 let debit_line = JournalEntryLine::debit(
665 entry.header.document_id,
666 1,
667 self.config.gr_ir_clearing_account.clone(),
668 clearing_amount,
669 );
670 entry.add_line(debit_line);
671
672 if has_vat {
674 let vat_line = JournalEntryLine::debit(
675 entry.header.document_id,
676 2,
677 self.config.vat_input_account.clone(),
678 invoice.tax_amount,
679 );
680 entry.add_line(vat_line);
681 }
682
683 let mut credit_line = JournalEntryLine::credit(
685 entry.header.document_id,
686 if has_vat { 3 } else { 2 },
687 self.config.ap_account.clone(),
688 invoice.payable_amount,
689 );
690 self.set_auxiliary_fields(&mut credit_line, &invoice.vendor_id, &invoice.vendor_id);
691 entry.add_line(credit_line);
692
693 self.enrich_line_items(&mut entry);
694 Some(entry)
695 }
696
697 pub fn generate_from_ap_payment(&mut self, payment: &Payment) -> Option<JournalEntry> {
700 if payment.amount == Decimal::ZERO {
701 return None;
702 }
703
704 let document_id = self.uuid_factory.next();
705
706 let posting_date = payment
708 .header
709 .posting_date
710 .unwrap_or(payment.header.document_date);
711
712 let mut header = JournalEntryHeader::with_deterministic_id(
713 payment.header.company_code.clone(),
714 posting_date,
715 document_id,
716 );
717 header.source = TransactionSource::Automated;
718 header.business_process = Some(BusinessProcess::P2P);
719 header.document_type = "KZ".to_string();
720 header.reference = Some(format!("PAY:{}", payment.header.document_id));
721 header.source_document = Some(DocumentRef::Payment(payment.header.document_id.clone()));
722 header.header_text = Some(format!(
723 "Payment {} - {}",
724 payment.header.document_id, payment.business_partner_id
725 ));
726
727 let mut entry = JournalEntry::new(header);
728
729 let mut debit_line = JournalEntryLine::debit(
731 entry.header.document_id,
732 1,
733 self.config.ap_account.clone(),
734 payment.amount,
735 );
736 self.set_auxiliary_fields(
737 &mut debit_line,
738 &payment.business_partner_id,
739 &payment.business_partner_id,
740 );
741 entry.add_line(debit_line);
742
743 let credit_line = JournalEntryLine::credit(
745 entry.header.document_id,
746 2,
747 self.config.cash_account.clone(),
748 payment.amount,
749 );
750 entry.add_line(credit_line);
751
752 self.enrich_line_items(&mut entry);
753 Some(entry)
754 }
755
756 pub fn generate_from_delivery(&mut self, delivery: &Delivery) -> Option<JournalEntry> {
759 if delivery.items.is_empty() {
760 return None;
761 }
762
763 let document_id = self.uuid_factory.next();
764
765 let total_cost = delivery
767 .items
768 .iter()
769 .map(|item| item.base.net_amount)
770 .sum::<Decimal>();
771
772 if total_cost == Decimal::ZERO {
773 return None;
774 }
775
776 let posting_date = delivery
778 .header
779 .posting_date
780 .unwrap_or(delivery.header.document_date);
781
782 let mut header = JournalEntryHeader::with_deterministic_id(
783 delivery.header.company_code.clone(),
784 posting_date,
785 document_id,
786 );
787 header.source = TransactionSource::Automated;
788 header.business_process = Some(BusinessProcess::O2C);
789 header.document_type = "WL".to_string();
790 header.reference = Some(format!("DEL:{}", delivery.header.document_id));
791 header.source_document = Some(DocumentRef::Delivery(delivery.header.document_id.clone()));
792 header.header_text = Some(format!(
793 "Delivery {} - {}",
794 delivery.header.document_id, delivery.customer_id
795 ));
796
797 let mut entry = JournalEntry::new(header);
798
799 let debit_line = JournalEntryLine::debit(
801 entry.header.document_id,
802 1,
803 self.config.cogs_account.clone(),
804 total_cost,
805 );
806 entry.add_line(debit_line);
807
808 let credit_line = JournalEntryLine::credit(
810 entry.header.document_id,
811 2,
812 self.config.inventory_account.clone(),
813 total_cost,
814 );
815 entry.add_line(credit_line);
816
817 self.enrich_line_items(&mut entry);
818 Some(entry)
819 }
820
821 pub fn generate_from_customer_invoice(
832 &mut self,
833 invoice: &CustomerInvoice,
834 ) -> Option<JournalEntry> {
835 if invoice.total_gross_amount == Decimal::ZERO {
836 return None;
837 }
838
839 let document_id = self.uuid_factory.next();
840
841 let posting_date = invoice
843 .header
844 .posting_date
845 .unwrap_or(invoice.header.document_date);
846
847 let mut header = JournalEntryHeader::with_deterministic_id(
848 invoice.header.company_code.clone(),
849 posting_date,
850 document_id,
851 );
852 header.source = TransactionSource::Automated;
853 header.business_process = Some(BusinessProcess::O2C);
854 header.document_type = "DR".to_string();
855 header.reference = Some(format!("CI:{}", invoice.header.document_id));
856 header.source_document = Some(DocumentRef::CustomerInvoice(
857 invoice.header.document_id.clone(),
858 ));
859 header.header_text = Some(format!(
860 "Customer Invoice {} - {}",
861 invoice.header.document_id, invoice.customer_id
862 ));
863
864 let mut entry = JournalEntry::new(header);
865
866 let mut debit_line = JournalEntryLine::debit(
868 entry.header.document_id,
869 1,
870 self.config.ar_account.clone(),
871 invoice.total_gross_amount,
872 );
873 self.set_auxiliary_fields(&mut debit_line, &invoice.customer_id, &invoice.customer_id);
874 entry.add_line(debit_line);
875
876 let revenue_amount = if invoice.total_tax_amount > Decimal::ZERO {
878 invoice.total_net_amount
879 } else {
880 invoice.total_gross_amount
881 };
882 let credit_line = JournalEntryLine::credit(
883 entry.header.document_id,
884 2,
885 self.config.revenue_account.clone(),
886 revenue_amount,
887 );
888 entry.add_line(credit_line);
889
890 if invoice.total_tax_amount > Decimal::ZERO {
892 let vat_line = JournalEntryLine::credit(
893 entry.header.document_id,
894 3,
895 self.config.vat_output_account.clone(),
896 invoice.total_tax_amount,
897 );
898 entry.add_line(vat_line);
899 }
900
901 self.enrich_line_items(&mut entry);
902 Some(entry)
903 }
904
905 pub fn generate_from_ar_receipt(&mut self, payment: &Payment) -> Option<JournalEntry> {
908 if payment.amount == Decimal::ZERO {
909 return None;
910 }
911
912 let document_id = self.uuid_factory.next();
913
914 let posting_date = payment
916 .header
917 .posting_date
918 .unwrap_or(payment.header.document_date);
919
920 let mut header = JournalEntryHeader::with_deterministic_id(
921 payment.header.company_code.clone(),
922 posting_date,
923 document_id,
924 );
925 header.source = TransactionSource::Automated;
926 header.business_process = Some(BusinessProcess::O2C);
927 header.document_type = "DZ".to_string();
928 header.reference = Some(format!("RCP:{}", payment.header.document_id));
929 header.source_document = Some(DocumentRef::Receipt(payment.header.document_id.clone()));
930 header.header_text = Some(format!(
931 "Customer Receipt {} - {}",
932 payment.header.document_id, payment.business_partner_id
933 ));
934
935 let mut entry = JournalEntry::new(header);
936
937 let debit_line = JournalEntryLine::debit(
939 entry.header.document_id,
940 1,
941 self.config.cash_account.clone(),
942 payment.amount,
943 );
944 entry.add_line(debit_line);
945
946 let mut credit_line = JournalEntryLine::credit(
948 entry.header.document_id,
949 2,
950 self.config.ar_account.clone(),
951 payment.amount,
952 );
953 self.set_auxiliary_fields(
954 &mut credit_line,
955 &payment.business_partner_id,
956 &payment.business_partner_id,
957 );
958 entry.add_line(credit_line);
959
960 self.enrich_line_items(&mut entry);
961 Some(entry)
962 }
963}
964
965impl Default for DocumentFlowJeGenerator {
966 fn default() -> Self {
967 Self::new()
968 }
969}
970
971#[cfg(test)]
972#[allow(clippy::unwrap_used)]
973mod tests {
974 use super::*;
975 use chrono::NaiveDate;
976 use datasynth_core::models::documents::{GoodsReceiptItem, MovementType};
977
978 fn create_test_gr() -> GoodsReceipt {
979 let mut gr = GoodsReceipt::from_purchase_order(
980 "GR-001".to_string(),
981 "1000",
982 "PO-001",
983 "V-001",
984 "P1000",
985 "0001",
986 2024,
987 1,
988 NaiveDate::from_ymd_opt(2024, 1, 15).unwrap(),
989 "JSMITH",
990 );
991
992 let item = GoodsReceiptItem::from_po(
993 10,
994 "Test Material",
995 Decimal::from(100),
996 Decimal::from(50),
997 "PO-001",
998 10,
999 )
1000 .with_movement_type(MovementType::GrForPo);
1001
1002 gr.add_item(item);
1003 gr.post("JSMITH", NaiveDate::from_ymd_opt(2024, 1, 15).unwrap());
1004
1005 gr
1006 }
1007
1008 fn create_test_vendor_invoice() -> VendorInvoice {
1009 use datasynth_core::models::documents::VendorInvoiceItem;
1010
1011 let mut invoice = VendorInvoice::new(
1012 "VI-001".to_string(),
1013 "1000",
1014 "V-001",
1015 "INV-12345".to_string(),
1016 2024,
1017 1,
1018 NaiveDate::from_ymd_opt(2024, 1, 20).unwrap(),
1019 "JSMITH",
1020 );
1021
1022 let item = VendorInvoiceItem::from_po_gr(
1023 10,
1024 "Test Material",
1025 Decimal::from(100),
1026 Decimal::from(50),
1027 "PO-001",
1028 10,
1029 Some("GR-001".to_string()),
1030 Some(10),
1031 );
1032
1033 invoice.add_item(item);
1034 invoice.post("JSMITH", NaiveDate::from_ymd_opt(2024, 1, 20).unwrap());
1035
1036 invoice
1037 }
1038
1039 fn create_test_payment() -> Payment {
1040 let mut payment = Payment::new_ap_payment(
1041 "PAY-001".to_string(),
1042 "1000",
1043 "V-001",
1044 Decimal::from(5000),
1045 2024,
1046 2,
1047 NaiveDate::from_ymd_opt(2024, 2, 15).unwrap(),
1048 "JSMITH",
1049 );
1050
1051 payment.post("JSMITH", NaiveDate::from_ymd_opt(2024, 2, 15).unwrap());
1052
1053 payment
1054 }
1055
1056 #[test]
1057 fn test_generate_from_goods_receipt() {
1058 let mut generator = DocumentFlowJeGenerator::new();
1059 let gr = create_test_gr();
1060
1061 let je = generator.generate_from_goods_receipt(&gr);
1062
1063 assert!(je.is_some());
1064 let je = je.unwrap();
1065
1066 assert!(je.is_balanced());
1068
1069 assert_eq!(je.line_count(), 2);
1071
1072 assert!(je.total_debit() > Decimal::ZERO);
1074 assert_eq!(je.total_debit(), je.total_credit());
1075
1076 assert!(je.header.reference.is_some());
1078 assert!(je.header.reference.as_ref().unwrap().contains("GR:"));
1079 }
1080
1081 #[test]
1082 fn test_generate_from_vendor_invoice() {
1083 let mut generator = DocumentFlowJeGenerator::new();
1084 let invoice = create_test_vendor_invoice();
1085
1086 let je = generator.generate_from_vendor_invoice(&invoice);
1087
1088 assert!(je.is_some());
1089 let je = je.unwrap();
1090
1091 assert!(je.is_balanced());
1092 assert_eq!(je.line_count(), 2);
1093 assert!(je.header.reference.as_ref().unwrap().contains("VI:"));
1094 }
1095
1096 #[test]
1097 fn test_generate_from_ap_payment() {
1098 let mut generator = DocumentFlowJeGenerator::new();
1099 let payment = create_test_payment();
1100
1101 let je = generator.generate_from_ap_payment(&payment);
1102
1103 assert!(je.is_some());
1104 let je = je.unwrap();
1105
1106 assert!(je.is_balanced());
1107 assert_eq!(je.line_count(), 2);
1108 assert!(je.header.reference.as_ref().unwrap().contains("PAY:"));
1109 }
1110
1111 #[test]
1112 fn test_all_entries_are_balanced() {
1113 let mut generator = DocumentFlowJeGenerator::new();
1114
1115 let gr = create_test_gr();
1116 let invoice = create_test_vendor_invoice();
1117 let payment = create_test_payment();
1118
1119 let entries = vec![
1120 generator.generate_from_goods_receipt(&gr),
1121 generator.generate_from_vendor_invoice(&invoice),
1122 generator.generate_from_ap_payment(&payment),
1123 ];
1124
1125 for entry in entries.into_iter().flatten() {
1126 assert!(
1127 entry.is_balanced(),
1128 "Entry {} is not balanced",
1129 entry.header.document_id
1130 );
1131 }
1132 }
1133
1134 #[test]
1139 fn test_french_gaap_auxiliary_on_ap_ar_lines_only() {
1140 let mut generator =
1142 DocumentFlowJeGenerator::with_config_and_seed(DocumentFlowJeConfig::french_gaap(), 42);
1143
1144 let invoice = create_test_vendor_invoice();
1146 let je = generator.generate_from_vendor_invoice(&invoice).unwrap();
1147
1148 assert!(
1150 je.lines[0].auxiliary_account_number.is_none(),
1151 "GR/IR clearing line should not have auxiliary"
1152 );
1153
1154 assert_eq!(
1156 je.lines[1].auxiliary_account_number.as_deref(),
1157 Some("V-001"),
1158 "AP line should have vendor ID as auxiliary"
1159 );
1160 assert_eq!(
1161 je.lines[1].auxiliary_account_label.as_deref(),
1162 Some("V-001"),
1163 );
1164 }
1165
1166 #[test]
1167 fn test_french_gaap_lettrage_on_complete_p2p_chain() {
1168 use datasynth_core::models::documents::PurchaseOrder;
1169
1170 let mut generator =
1171 DocumentFlowJeGenerator::with_config_and_seed(DocumentFlowJeConfig::french_gaap(), 42);
1172
1173 let po = PurchaseOrder::new(
1174 "PO-001",
1175 "1000",
1176 "V-001",
1177 2024,
1178 1,
1179 NaiveDate::from_ymd_opt(2024, 1, 10).unwrap(),
1180 "JSMITH",
1181 );
1182
1183 let chain = P2PDocumentChain {
1184 purchase_order: po,
1185 goods_receipts: vec![create_test_gr()],
1186 vendor_invoice: Some(create_test_vendor_invoice()),
1187 payment: Some(create_test_payment()),
1188 remainder_payments: Vec::new(),
1189 is_complete: true,
1190 three_way_match_passed: true,
1191 payment_timing: None,
1192 };
1193
1194 let entries = generator.generate_from_p2p_chain(&chain);
1195 assert!(!entries.is_empty());
1196
1197 let ap_account = &generator.config.ap_account;
1199 let mut lettrage_codes: Vec<&str> = Vec::new();
1200 for entry in &entries {
1201 for line in &entry.lines {
1202 if &line.gl_account == ap_account {
1203 assert!(
1204 line.lettrage.is_some(),
1205 "AP line should have lettrage on complete chain"
1206 );
1207 assert!(line.lettrage_date.is_some());
1208 lettrage_codes.push(line.lettrage.as_deref().unwrap());
1209 } else {
1210 assert!(
1211 line.lettrage.is_none(),
1212 "Non-AP line should not have lettrage"
1213 );
1214 }
1215 }
1216 }
1217
1218 assert!(!lettrage_codes.is_empty());
1220 assert!(
1221 lettrage_codes.iter().all(|c| *c == lettrage_codes[0]),
1222 "All AP lines should share the same lettrage code"
1223 );
1224 assert!(lettrage_codes[0].starts_with("LTR-"));
1225 }
1226
1227 #[test]
1228 fn test_incomplete_chain_has_no_lettrage() {
1229 use datasynth_core::models::documents::PurchaseOrder;
1230
1231 let mut generator =
1232 DocumentFlowJeGenerator::with_config_and_seed(DocumentFlowJeConfig::french_gaap(), 42);
1233
1234 let po = PurchaseOrder::new(
1235 "PO-002",
1236 "1000",
1237 "V-001",
1238 2024,
1239 1,
1240 NaiveDate::from_ymd_opt(2024, 1, 10).unwrap(),
1241 "JSMITH",
1242 );
1243
1244 let chain = P2PDocumentChain {
1246 purchase_order: po,
1247 goods_receipts: vec![create_test_gr()],
1248 vendor_invoice: Some(create_test_vendor_invoice()),
1249 payment: None,
1250 remainder_payments: Vec::new(),
1251 is_complete: false,
1252 three_way_match_passed: false,
1253 payment_timing: None,
1254 };
1255
1256 let entries = generator.generate_from_p2p_chain(&chain);
1257
1258 for entry in &entries {
1259 for line in &entry.lines {
1260 assert!(
1261 line.lettrage.is_none(),
1262 "Incomplete chain should have no lettrage"
1263 );
1264 }
1265 }
1266 }
1267
1268 #[test]
1269 fn test_default_config_no_fec_fields() {
1270 let mut generator = DocumentFlowJeGenerator::new();
1272
1273 let invoice = create_test_vendor_invoice();
1274 let je = generator.generate_from_vendor_invoice(&invoice).unwrap();
1275
1276 for line in &je.lines {
1277 assert!(line.auxiliary_account_number.is_none());
1278 assert!(line.auxiliary_account_label.is_none());
1279 assert!(line.lettrage.is_none());
1280 assert!(line.lettrage_date.is_none());
1281 }
1282 }
1283
1284 #[test]
1285 fn test_auxiliary_lookup_uses_gl_account_instead_of_partner_id() {
1286 let mut generator =
1289 DocumentFlowJeGenerator::with_config_and_seed(DocumentFlowJeConfig::french_gaap(), 42);
1290
1291 let mut lookup = HashMap::new();
1292 lookup.insert("V-001".to_string(), "4010001".to_string());
1293 generator.set_auxiliary_account_lookup(lookup);
1294
1295 let invoice = create_test_vendor_invoice();
1296 let je = generator.generate_from_vendor_invoice(&invoice).unwrap();
1297
1298 assert_eq!(
1300 je.lines[1].auxiliary_account_number.as_deref(),
1301 Some("4010001"),
1302 "AP line should use auxiliary GL account from lookup"
1303 );
1304 assert_eq!(
1306 je.lines[1].auxiliary_account_label.as_deref(),
1307 Some("V-001"),
1308 );
1309 }
1310
1311 #[test]
1312 fn test_auxiliary_lookup_fallback_to_partner_id() {
1313 let mut generator =
1316 DocumentFlowJeGenerator::with_config_and_seed(DocumentFlowJeConfig::french_gaap(), 42);
1317
1318 let mut lookup = HashMap::new();
1320 lookup.insert("V-999".to_string(), "4019999".to_string());
1321 generator.set_auxiliary_account_lookup(lookup);
1322
1323 let invoice = create_test_vendor_invoice();
1324 let je = generator.generate_from_vendor_invoice(&invoice).unwrap();
1325
1326 assert_eq!(
1328 je.lines[1].auxiliary_account_number.as_deref(),
1329 Some("V-001"),
1330 "Should fall back to partner ID when not in lookup"
1331 );
1332 }
1333
1334 #[test]
1335 fn test_auxiliary_lookup_works_for_customer_receipt() {
1336 let mut generator =
1338 DocumentFlowJeGenerator::with_config_and_seed(DocumentFlowJeConfig::french_gaap(), 42);
1339
1340 let mut lookup = HashMap::new();
1341 lookup.insert("C-001".to_string(), "4110001".to_string());
1342 generator.set_auxiliary_account_lookup(lookup);
1343
1344 let mut receipt = Payment::new_ar_receipt(
1345 "RCP-001".to_string(),
1346 "1000",
1347 "C-001",
1348 Decimal::from(3000),
1349 2024,
1350 3,
1351 NaiveDate::from_ymd_opt(2024, 3, 15).unwrap(),
1352 "JSMITH",
1353 );
1354 receipt.post("JSMITH", NaiveDate::from_ymd_opt(2024, 3, 15).unwrap());
1355
1356 let je = generator.generate_from_ar_receipt(&receipt).unwrap();
1357
1358 assert_eq!(
1360 je.lines[1].auxiliary_account_number.as_deref(),
1361 Some("4110001"),
1362 "AR line should use auxiliary GL account from lookup"
1363 );
1364 }
1365
1366 fn create_test_customer_invoice_with_tax() -> CustomerInvoice {
1372 use datasynth_core::models::documents::CustomerInvoiceItem;
1373
1374 let mut invoice = CustomerInvoice::new(
1375 "CI-001",
1376 "1000",
1377 "C-001",
1378 2024,
1379 1,
1380 NaiveDate::from_ymd_opt(2024, 1, 15).unwrap(),
1381 NaiveDate::from_ymd_opt(2024, 2, 14).unwrap(),
1382 "JSMITH",
1383 );
1384
1385 let mut item =
1387 CustomerInvoiceItem::new(1, "Product A", Decimal::from(10), Decimal::from(100));
1388 item.base.tax_amount = Decimal::from(100);
1389 invoice.add_item(item);
1390 invoice.post("JSMITH", NaiveDate::from_ymd_opt(2024, 1, 15).unwrap());
1391
1392 invoice
1393 }
1394
1395 fn create_test_customer_invoice_no_tax() -> CustomerInvoice {
1397 use datasynth_core::models::documents::CustomerInvoiceItem;
1398
1399 let mut invoice = CustomerInvoice::new(
1400 "CI-002",
1401 "1000",
1402 "C-002",
1403 2024,
1404 1,
1405 NaiveDate::from_ymd_opt(2024, 1, 15).unwrap(),
1406 NaiveDate::from_ymd_opt(2024, 2, 14).unwrap(),
1407 "JSMITH",
1408 );
1409
1410 let item = CustomerInvoiceItem::new(1, "Product B", Decimal::from(10), Decimal::from(100));
1411 invoice.add_item(item);
1412 invoice.post("JSMITH", NaiveDate::from_ymd_opt(2024, 1, 15).unwrap());
1413
1414 invoice
1415 }
1416
1417 fn create_test_vendor_invoice_with_tax() -> VendorInvoice {
1419 use datasynth_core::models::documents::VendorInvoiceItem;
1420
1421 let mut invoice = VendorInvoice::new(
1422 "VI-002".to_string(),
1423 "1000",
1424 "V-001",
1425 "INV-TAX-001".to_string(),
1426 2024,
1427 1,
1428 NaiveDate::from_ymd_opt(2024, 1, 20).unwrap(),
1429 "JSMITH",
1430 );
1431
1432 let item = VendorInvoiceItem::from_po_gr(
1434 10,
1435 "Test Material",
1436 Decimal::from(100),
1437 Decimal::from(50),
1438 "PO-001",
1439 10,
1440 Some("GR-001".to_string()),
1441 Some(10),
1442 )
1443 .with_tax("VAT10", Decimal::from(500));
1444
1445 invoice.add_item(item);
1446 invoice.post("JSMITH", NaiveDate::from_ymd_opt(2024, 1, 20).unwrap());
1447
1448 invoice
1449 }
1450
1451 #[test]
1452 fn test_customer_invoice_with_tax_produces_three_lines() {
1453 let mut generator = DocumentFlowJeGenerator::new();
1454 let invoice = create_test_customer_invoice_with_tax();
1455
1456 assert_eq!(invoice.total_net_amount, Decimal::from(1000));
1457 assert_eq!(invoice.total_tax_amount, Decimal::from(100));
1458 assert_eq!(invoice.total_gross_amount, Decimal::from(1100));
1459
1460 let je = generator.generate_from_customer_invoice(&invoice).unwrap();
1461
1462 assert_eq!(
1464 je.line_count(),
1465 3,
1466 "Expected 3 JE lines for invoice with tax"
1467 );
1468 assert!(je.is_balanced(), "Entry must be balanced");
1469
1470 assert_eq!(je.lines[0].gl_account, control_accounts::AR_CONTROL);
1472 assert_eq!(je.lines[0].debit_amount, Decimal::from(1100));
1473 assert_eq!(je.lines[0].credit_amount, Decimal::ZERO);
1474
1475 assert_eq!(je.lines[1].gl_account, revenue_accounts::PRODUCT_REVENUE);
1477 assert_eq!(je.lines[1].credit_amount, Decimal::from(1000));
1478 assert_eq!(je.lines[1].debit_amount, Decimal::ZERO);
1479
1480 assert_eq!(je.lines[2].gl_account, tax_accounts::VAT_PAYABLE);
1482 assert_eq!(je.lines[2].credit_amount, Decimal::from(100));
1483 assert_eq!(je.lines[2].debit_amount, Decimal::ZERO);
1484 }
1485
1486 #[test]
1487 fn test_customer_invoice_no_tax_produces_two_lines() {
1488 let mut generator = DocumentFlowJeGenerator::new();
1489 let invoice = create_test_customer_invoice_no_tax();
1490
1491 assert_eq!(invoice.total_tax_amount, Decimal::ZERO);
1492 assert_eq!(invoice.total_net_amount, Decimal::from(1000));
1493 assert_eq!(invoice.total_gross_amount, Decimal::from(1000));
1494
1495 let je = generator.generate_from_customer_invoice(&invoice).unwrap();
1496
1497 assert_eq!(
1499 je.line_count(),
1500 2,
1501 "Expected 2 JE lines for invoice without tax"
1502 );
1503 assert!(je.is_balanced(), "Entry must be balanced");
1504
1505 assert_eq!(je.lines[0].gl_account, control_accounts::AR_CONTROL);
1507 assert_eq!(je.lines[0].debit_amount, Decimal::from(1000));
1508
1509 assert_eq!(je.lines[1].gl_account, revenue_accounts::PRODUCT_REVENUE);
1511 assert_eq!(je.lines[1].credit_amount, Decimal::from(1000));
1512 }
1513
1514 #[test]
1515 fn test_vendor_invoice_with_tax_produces_three_lines() {
1516 let mut generator = DocumentFlowJeGenerator::new();
1517 let invoice = create_test_vendor_invoice_with_tax();
1518
1519 assert_eq!(invoice.net_amount, Decimal::from(5000));
1520 assert_eq!(invoice.tax_amount, Decimal::from(500));
1521 assert_eq!(invoice.gross_amount, Decimal::from(5500));
1522 assert_eq!(invoice.payable_amount, Decimal::from(5500));
1523
1524 let je = generator.generate_from_vendor_invoice(&invoice).unwrap();
1525
1526 assert_eq!(
1528 je.line_count(),
1529 3,
1530 "Expected 3 JE lines for vendor invoice with tax"
1531 );
1532 assert!(je.is_balanced(), "Entry must be balanced");
1533
1534 assert_eq!(je.lines[0].gl_account, control_accounts::GR_IR_CLEARING);
1536 assert_eq!(je.lines[0].debit_amount, Decimal::from(5000));
1537 assert_eq!(je.lines[0].credit_amount, Decimal::ZERO);
1538
1539 assert_eq!(je.lines[1].gl_account, tax_accounts::INPUT_VAT);
1541 assert_eq!(je.lines[1].debit_amount, Decimal::from(500));
1542 assert_eq!(je.lines[1].credit_amount, Decimal::ZERO);
1543
1544 assert_eq!(je.lines[2].gl_account, control_accounts::AP_CONTROL);
1546 assert_eq!(je.lines[2].credit_amount, Decimal::from(5500));
1547 assert_eq!(je.lines[2].debit_amount, Decimal::ZERO);
1548 }
1549
1550 #[test]
1551 fn test_vendor_invoice_no_tax_produces_two_lines() {
1552 let mut generator = DocumentFlowJeGenerator::new();
1554 let invoice = create_test_vendor_invoice();
1555
1556 assert_eq!(invoice.tax_amount, Decimal::ZERO);
1557
1558 let je = generator.generate_from_vendor_invoice(&invoice).unwrap();
1559
1560 assert_eq!(
1562 je.line_count(),
1563 2,
1564 "Expected 2 JE lines for vendor invoice without tax"
1565 );
1566 assert!(je.is_balanced(), "Entry must be balanced");
1567
1568 assert_eq!(je.lines[0].gl_account, control_accounts::GR_IR_CLEARING);
1570 assert_eq!(je.lines[0].debit_amount, invoice.payable_amount);
1571
1572 assert_eq!(je.lines[1].gl_account, control_accounts::AP_CONTROL);
1574 assert_eq!(je.lines[1].credit_amount, invoice.payable_amount);
1575 }
1576
1577 #[test]
1578 fn test_vat_accounts_configurable() {
1579 let config = DocumentFlowJeConfig {
1581 vat_output_account: "2999".to_string(),
1582 vat_input_account: "1999".to_string(),
1583 ..Default::default()
1584 };
1585
1586 let mut generator = DocumentFlowJeGenerator::with_config_and_seed(config, 42);
1587
1588 let ci = create_test_customer_invoice_with_tax();
1590 let je = generator.generate_from_customer_invoice(&ci).unwrap();
1591 assert_eq!(
1592 je.lines[2].gl_account, "2999",
1593 "VAT output account should be configurable"
1594 );
1595
1596 let vi = create_test_vendor_invoice_with_tax();
1598 let je = generator.generate_from_vendor_invoice(&vi).unwrap();
1599 assert_eq!(
1600 je.lines[1].gl_account, "1999",
1601 "VAT input account should be configurable"
1602 );
1603 }
1604
1605 #[test]
1606 fn test_vat_entries_from_framework_accounts() {
1607 let fa = datasynth_core::FrameworkAccounts::us_gaap();
1609 let config = DocumentFlowJeConfig::from(&fa);
1610
1611 assert_eq!(config.vat_output_account, tax_accounts::VAT_PAYABLE);
1612 assert_eq!(config.vat_input_account, tax_accounts::INPUT_VAT);
1613
1614 let fa_fr = datasynth_core::FrameworkAccounts::french_gaap();
1615 let config_fr = DocumentFlowJeConfig::from(&fa_fr);
1616
1617 assert_eq!(config_fr.vat_output_account, "445710");
1618 assert_eq!(config_fr.vat_input_account, "445660");
1619 }
1620
1621 #[test]
1622 fn test_french_gaap_vat_accounts() {
1623 let config = DocumentFlowJeConfig::french_gaap();
1624 assert_eq!(config.vat_output_account, "445710"); assert_eq!(config.vat_input_account, "445660"); }
1627
1628 #[test]
1629 fn test_vat_balanced_with_multiple_items() {
1630 use datasynth_core::models::documents::CustomerInvoiceItem;
1632
1633 let mut invoice = CustomerInvoice::new(
1634 "CI-003",
1635 "1000",
1636 "C-003",
1637 2024,
1638 1,
1639 NaiveDate::from_ymd_opt(2024, 1, 15).unwrap(),
1640 NaiveDate::from_ymd_opt(2024, 2, 14).unwrap(),
1641 "JSMITH",
1642 );
1643
1644 let mut item1 = CustomerInvoiceItem::new(1, "A", Decimal::from(5), Decimal::from(100));
1646 item1.base.tax_amount = Decimal::from(50);
1647 invoice.add_item(item1);
1648
1649 let mut item2 = CustomerInvoiceItem::new(2, "B", Decimal::from(3), Decimal::from(100));
1651 item2.base.tax_amount = Decimal::from(30);
1652 invoice.add_item(item2);
1653
1654 invoice.post("JSMITH", NaiveDate::from_ymd_opt(2024, 1, 15).unwrap());
1655
1656 assert_eq!(invoice.total_net_amount, Decimal::from(800));
1658 assert_eq!(invoice.total_tax_amount, Decimal::from(80));
1659 assert_eq!(invoice.total_gross_amount, Decimal::from(880));
1660
1661 let mut generator = DocumentFlowJeGenerator::new();
1662 let je = generator.generate_from_customer_invoice(&invoice).unwrap();
1663
1664 assert_eq!(je.line_count(), 3);
1665 assert!(je.is_balanced());
1666 assert_eq!(je.total_debit(), Decimal::from(880));
1667 assert_eq!(je.total_credit(), Decimal::from(880));
1668 }
1669
1670 #[test]
1671 fn test_document_types_per_source_document() {
1672 let mut generator = DocumentFlowJeGenerator::new();
1673
1674 let gr = create_test_gr();
1675 let invoice = create_test_vendor_invoice();
1676 let payment = create_test_payment();
1677
1678 let gr_je = generator.generate_from_goods_receipt(&gr).unwrap();
1679 assert_eq!(
1680 gr_je.header.document_type, "WE",
1681 "Goods receipt should be WE"
1682 );
1683
1684 let vi_je = generator.generate_from_vendor_invoice(&invoice).unwrap();
1685 assert_eq!(
1686 vi_je.header.document_type, "KR",
1687 "Vendor invoice should be KR"
1688 );
1689
1690 let pay_je = generator.generate_from_ap_payment(&payment).unwrap();
1691 assert_eq!(pay_je.header.document_type, "KZ", "AP payment should be KZ");
1692
1693 let types: std::collections::HashSet<&str> = [
1695 gr_je.header.document_type.as_str(),
1696 vi_je.header.document_type.as_str(),
1697 pay_je.header.document_type.as_str(),
1698 ]
1699 .into_iter()
1700 .collect();
1701
1702 assert!(
1703 types.len() >= 3,
1704 "Expected at least 3 distinct document types from P2P flow, got {:?}",
1705 types,
1706 );
1707 }
1708
1709 #[test]
1710 fn test_enrichment_account_descriptions_populated() {
1711 let mut generator = DocumentFlowJeGenerator::new();
1712 let gr = create_test_gr();
1713 let invoice = create_test_vendor_invoice();
1714 let payment = create_test_payment();
1715
1716 let gr_je = generator.generate_from_goods_receipt(&gr).unwrap();
1717 let vi_je = generator.generate_from_vendor_invoice(&invoice).unwrap();
1718 let pay_je = generator.generate_from_ap_payment(&payment).unwrap();
1719
1720 for je in [&gr_je, &vi_je, &pay_je] {
1722 for line in &je.lines {
1723 assert!(
1724 line.account_description.is_some(),
1725 "Line for account {} should have description, entry doc {}",
1726 line.gl_account,
1727 je.header.document_id,
1728 );
1729 }
1730 }
1731
1732 assert_eq!(
1734 gr_je.lines[0].account_description.as_deref(),
1735 Some("Inventory"),
1736 );
1737 assert_eq!(
1738 gr_je.lines[1].account_description.as_deref(),
1739 Some("GR/IR Clearing"),
1740 );
1741 }
1742
1743 #[test]
1744 fn test_enrichment_profit_center_and_line_text() {
1745 let mut generator = DocumentFlowJeGenerator::new();
1746 let gr = create_test_gr();
1747
1748 let je = generator.generate_from_goods_receipt(&gr).unwrap();
1749
1750 for line in &je.lines {
1751 assert!(
1753 line.profit_center.is_some(),
1754 "Line {} should have profit_center",
1755 line.gl_account,
1756 );
1757 let pc = line.profit_center.as_ref().unwrap();
1758 assert!(
1759 pc.starts_with("PC-"),
1760 "Profit center should start with PC-, got {}",
1761 pc,
1762 );
1763
1764 assert!(
1766 line.line_text.is_some(),
1767 "Line {} should have line_text",
1768 line.gl_account,
1769 );
1770 }
1771 }
1772
1773 #[test]
1774 fn test_enrichment_cost_center_for_expense_accounts() {
1775 let mut generator = DocumentFlowJeGenerator::new();
1776
1777 use datasynth_core::models::documents::{Delivery, DeliveryItem};
1779 let mut delivery = Delivery::new(
1780 "DEL-001".to_string(),
1781 "1000",
1782 "SO-001",
1783 "C-001",
1784 2024,
1785 1,
1786 NaiveDate::from_ymd_opt(2024, 1, 15).unwrap(),
1787 "JSMITH",
1788 );
1789 let item = DeliveryItem::from_sales_order(
1790 10,
1791 "Test Material",
1792 Decimal::from(100),
1793 Decimal::from(50),
1794 "SO-001",
1795 10,
1796 );
1797 delivery.add_item(item);
1798 delivery.post_goods_issue("JSMITH", NaiveDate::from_ymd_opt(2024, 1, 15).unwrap());
1799
1800 let je = generator.generate_from_delivery(&delivery).unwrap();
1801
1802 let cogs_line = je.lines.iter().find(|l| l.gl_account == "5000").unwrap();
1804 assert!(
1805 cogs_line.cost_center.is_some(),
1806 "COGS line should have cost_center assigned",
1807 );
1808 let cc = cogs_line.cost_center.as_ref().unwrap();
1809 assert!(
1810 cc.starts_with("CC"),
1811 "Cost center should start with CC, got {}",
1812 cc,
1813 );
1814
1815 let inv_line = je.lines.iter().find(|l| l.gl_account == "1200").unwrap();
1817 assert!(
1818 inv_line.cost_center.is_none(),
1819 "Non-expense line should not have cost_center",
1820 );
1821 }
1822
1823 #[test]
1824 fn test_enrichment_value_date_for_ap_ar() {
1825 let mut generator = DocumentFlowJeGenerator::new();
1826
1827 let invoice = create_test_vendor_invoice();
1828 let je = generator.generate_from_vendor_invoice(&invoice).unwrap();
1829
1830 let ap_line = je.lines.iter().find(|l| l.gl_account == "2000").unwrap();
1832 assert!(
1833 ap_line.value_date.is_some(),
1834 "AP line should have value_date set",
1835 );
1836 assert_eq!(ap_line.value_date, Some(je.header.posting_date));
1837
1838 let clearing_line = je.lines.iter().find(|l| l.gl_account == "2900").unwrap();
1840 assert!(
1841 clearing_line.value_date.is_none(),
1842 "Non-AP/AR line should not have value_date",
1843 );
1844 }
1845}