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, JournalEntry, JournalEntryHeader, JournalEntryLine, TransactionSource,
27};
28use datasynth_core::uuid_factory::{DeterministicUuidFactory, GeneratorType};
29
30use super::{O2CDocumentChain, P2PDocumentChain};
31
32#[derive(Debug, Clone)]
34pub struct DocumentFlowJeConfig {
35 pub inventory_account: String,
37 pub gr_ir_clearing_account: String,
39 pub ap_account: String,
41 pub cash_account: String,
43 pub ar_account: String,
45 pub revenue_account: String,
47 pub cogs_account: String,
49 pub vat_output_account: String,
51 pub vat_input_account: String,
53 pub populate_fec_fields: bool,
56}
57
58impl Default for DocumentFlowJeConfig {
59 fn default() -> Self {
60 Self {
61 inventory_account: control_accounts::INVENTORY.to_string(),
62 gr_ir_clearing_account: control_accounts::GR_IR_CLEARING.to_string(),
63 ap_account: control_accounts::AP_CONTROL.to_string(),
64 cash_account: cash_accounts::OPERATING_CASH.to_string(),
65 ar_account: control_accounts::AR_CONTROL.to_string(),
66 revenue_account: revenue_accounts::PRODUCT_REVENUE.to_string(),
67 cogs_account: expense_accounts::COGS.to_string(),
68 vat_output_account: tax_accounts::VAT_PAYABLE.to_string(),
69 vat_input_account: tax_accounts::INPUT_VAT.to_string(),
70 populate_fec_fields: false,
71 }
72 }
73}
74
75impl DocumentFlowJeConfig {
76 pub fn french_gaap() -> Self {
78 use datasynth_core::pcg;
79 Self {
80 inventory_account: pcg::control_accounts::INVENTORY.to_string(),
81 gr_ir_clearing_account: pcg::control_accounts::GR_IR_CLEARING.to_string(),
82 ap_account: pcg::control_accounts::AP_CONTROL.to_string(),
83 cash_account: pcg::cash_accounts::BANK_ACCOUNT.to_string(),
84 ar_account: pcg::control_accounts::AR_CONTROL.to_string(),
85 revenue_account: pcg::revenue_accounts::PRODUCT_REVENUE.to_string(),
86 cogs_account: pcg::expense_accounts::COGS.to_string(),
87 vat_output_account: pcg::tax_accounts::OUTPUT_VAT.to_string(),
88 vat_input_account: pcg::tax_accounts::INPUT_VAT.to_string(),
89 populate_fec_fields: true,
90 }
91 }
92}
93
94impl From<&datasynth_core::FrameworkAccounts> for DocumentFlowJeConfig {
95 fn from(fa: &datasynth_core::FrameworkAccounts) -> Self {
96 Self {
97 inventory_account: fa.inventory.clone(),
98 gr_ir_clearing_account: fa.gr_ir_clearing.clone(),
99 ap_account: fa.ap_control.clone(),
100 cash_account: fa.bank_account.clone(),
101 ar_account: fa.ar_control.clone(),
102 revenue_account: fa.product_revenue.clone(),
103 cogs_account: fa.cogs.clone(),
104 vat_output_account: fa.vat_payable.clone(),
105 vat_input_account: fa.input_vat.clone(),
106 populate_fec_fields: fa.audit_export.fec_enabled,
107 }
108 }
109}
110
111pub struct DocumentFlowJeGenerator {
113 config: DocumentFlowJeConfig,
114 uuid_factory: DeterministicUuidFactory,
115 auxiliary_account_lookup: HashMap<String, String>,
120}
121
122impl DocumentFlowJeGenerator {
123 pub fn new() -> Self {
125 Self::with_config_and_seed(DocumentFlowJeConfig::default(), 0)
126 }
127
128 pub fn with_config_and_seed(config: DocumentFlowJeConfig, seed: u64) -> Self {
130 Self {
131 config,
132 uuid_factory: DeterministicUuidFactory::new(seed, GeneratorType::DocumentFlow),
133 auxiliary_account_lookup: HashMap::new(),
134 }
135 }
136
137 pub fn set_auxiliary_account_lookup(&mut self, lookup: HashMap<String, String>) {
143 self.auxiliary_account_lookup = lookup;
144 }
145
146 fn set_auxiliary_fields(
155 &self,
156 line: &mut JournalEntryLine,
157 partner_id: &str,
158 partner_label: &str,
159 ) {
160 if !self.config.populate_fec_fields {
161 return;
162 }
163 if line.gl_account == self.config.ap_account || line.gl_account == self.config.ar_account {
164 let aux_account = self
167 .auxiliary_account_lookup
168 .get(partner_id)
169 .cloned()
170 .unwrap_or_else(|| partner_id.to_string());
171 line.auxiliary_account_number = Some(aux_account);
172 line.auxiliary_account_label = Some(partner_label.to_string());
173 }
174 }
175
176 fn apply_lettrage(
182 &self,
183 entries: &mut [JournalEntry],
184 chain_id: &str,
185 lettrage_date: NaiveDate,
186 ) {
187 if !self.config.populate_fec_fields {
188 return;
189 }
190 let code = format!("LTR-{}", &chain_id[..chain_id.len().min(8)]);
191 for entry in entries.iter_mut() {
192 for line in entry.lines.iter_mut() {
193 if line.gl_account == self.config.ap_account
194 || line.gl_account == self.config.ar_account
195 {
196 line.lettrage = Some(code.clone());
197 line.lettrage_date = Some(lettrage_date);
198 }
199 }
200 }
201 }
202
203 pub fn generate_from_p2p_chain(&mut self, chain: &P2PDocumentChain) -> Vec<JournalEntry> {
205 let mut entries = Vec::new();
206
207 for gr in &chain.goods_receipts {
209 if let Some(je) = self.generate_from_goods_receipt(gr) {
210 entries.push(je);
211 }
212 }
213
214 if let Some(ref invoice) = chain.vendor_invoice {
216 if let Some(je) = self.generate_from_vendor_invoice(invoice) {
217 entries.push(je);
218 }
219 }
220
221 if let Some(ref payment) = chain.payment {
223 if let Some(je) = self.generate_from_ap_payment(payment) {
224 entries.push(je);
225 }
226 }
227
228 for payment in &chain.remainder_payments {
230 if let Some(je) = self.generate_from_ap_payment(payment) {
231 entries.push(je);
232 }
233 }
234
235 if self.config.populate_fec_fields && chain.is_complete {
237 if let Some(ref payment) = chain.payment {
238 let posting_date = payment
239 .header
240 .posting_date
241 .unwrap_or(payment.header.document_date);
242 self.apply_lettrage(
243 &mut entries,
244 &chain.purchase_order.header.document_id,
245 posting_date,
246 );
247 }
248 }
249
250 entries
251 }
252
253 pub fn generate_from_o2c_chain(&mut self, chain: &O2CDocumentChain) -> Vec<JournalEntry> {
255 let mut entries = Vec::new();
256
257 for delivery in &chain.deliveries {
259 if let Some(je) = self.generate_from_delivery(delivery) {
260 entries.push(je);
261 }
262 }
263
264 if let Some(ref invoice) = chain.customer_invoice {
266 if let Some(je) = self.generate_from_customer_invoice(invoice) {
267 entries.push(je);
268 }
269 }
270
271 if let Some(ref receipt) = chain.customer_receipt {
273 if let Some(je) = self.generate_from_ar_receipt(receipt) {
274 entries.push(je);
275 }
276 }
277
278 for receipt in &chain.remainder_receipts {
280 if let Some(je) = self.generate_from_ar_receipt(receipt) {
281 entries.push(je);
282 }
283 }
284
285 if self.config.populate_fec_fields && chain.customer_receipt.is_some() {
287 if let Some(ref receipt) = chain.customer_receipt {
288 let posting_date = receipt
289 .header
290 .posting_date
291 .unwrap_or(receipt.header.document_date);
292 self.apply_lettrage(
293 &mut entries,
294 &chain.sales_order.header.document_id,
295 posting_date,
296 );
297 }
298 }
299
300 entries
301 }
302
303 pub fn generate_from_goods_receipt(&mut self, gr: &GoodsReceipt) -> Option<JournalEntry> {
306 if gr.items.is_empty() {
307 return None;
308 }
309
310 let document_id = self.uuid_factory.next();
311
312 let total_amount = if gr.total_value > Decimal::ZERO {
314 gr.total_value
315 } else {
316 gr.items
317 .iter()
318 .map(|item| item.base.net_amount)
319 .sum::<Decimal>()
320 };
321
322 if total_amount == Decimal::ZERO {
323 return None;
324 }
325
326 let posting_date = gr.header.posting_date.unwrap_or(gr.header.document_date);
328
329 let mut header = JournalEntryHeader::with_deterministic_id(
330 gr.header.company_code.clone(),
331 posting_date,
332 document_id,
333 );
334 header.source = TransactionSource::Automated;
335 header.business_process = Some(BusinessProcess::P2P);
336 header.reference = Some(format!("GR:{}", gr.header.document_id));
337 header.header_text = Some(format!(
338 "Goods Receipt {} - {}",
339 gr.header.document_id,
340 gr.vendor_id.as_deref().unwrap_or("Unknown")
341 ));
342
343 let mut entry = JournalEntry::new(header);
344
345 let debit_line = JournalEntryLine::debit(
347 entry.header.document_id,
348 1,
349 self.config.inventory_account.clone(),
350 total_amount,
351 );
352 entry.add_line(debit_line);
353
354 let credit_line = JournalEntryLine::credit(
356 entry.header.document_id,
357 2,
358 self.config.gr_ir_clearing_account.clone(),
359 total_amount,
360 );
361 entry.add_line(credit_line);
362
363 Some(entry)
364 }
365
366 pub fn generate_from_vendor_invoice(
377 &mut self,
378 invoice: &VendorInvoice,
379 ) -> Option<JournalEntry> {
380 if invoice.payable_amount == Decimal::ZERO {
381 return None;
382 }
383
384 let document_id = self.uuid_factory.next();
385
386 let posting_date = invoice
388 .header
389 .posting_date
390 .unwrap_or(invoice.header.document_date);
391
392 let mut header = JournalEntryHeader::with_deterministic_id(
393 invoice.header.company_code.clone(),
394 posting_date,
395 document_id,
396 );
397 header.source = TransactionSource::Automated;
398 header.business_process = Some(BusinessProcess::P2P);
399 header.reference = Some(format!("VI:{}", invoice.header.document_id));
400 header.header_text = Some(format!(
401 "Vendor Invoice {} - {}",
402 invoice.vendor_invoice_number, invoice.vendor_id
403 ));
404
405 let mut entry = JournalEntry::new(header);
406
407 let has_vat = invoice.tax_amount > Decimal::ZERO;
408 let clearing_amount = if has_vat {
409 invoice.net_amount
410 } else {
411 invoice.payable_amount
412 };
413
414 let debit_line = JournalEntryLine::debit(
416 entry.header.document_id,
417 1,
418 self.config.gr_ir_clearing_account.clone(),
419 clearing_amount,
420 );
421 entry.add_line(debit_line);
422
423 if has_vat {
425 let vat_line = JournalEntryLine::debit(
426 entry.header.document_id,
427 2,
428 self.config.vat_input_account.clone(),
429 invoice.tax_amount,
430 );
431 entry.add_line(vat_line);
432 }
433
434 let mut credit_line = JournalEntryLine::credit(
436 entry.header.document_id,
437 if has_vat { 3 } else { 2 },
438 self.config.ap_account.clone(),
439 invoice.payable_amount,
440 );
441 self.set_auxiliary_fields(&mut credit_line, &invoice.vendor_id, &invoice.vendor_id);
442 entry.add_line(credit_line);
443
444 Some(entry)
445 }
446
447 pub fn generate_from_ap_payment(&mut self, payment: &Payment) -> Option<JournalEntry> {
450 if payment.amount == Decimal::ZERO {
451 return None;
452 }
453
454 let document_id = self.uuid_factory.next();
455
456 let posting_date = payment
458 .header
459 .posting_date
460 .unwrap_or(payment.header.document_date);
461
462 let mut header = JournalEntryHeader::with_deterministic_id(
463 payment.header.company_code.clone(),
464 posting_date,
465 document_id,
466 );
467 header.source = TransactionSource::Automated;
468 header.business_process = Some(BusinessProcess::P2P);
469 header.reference = Some(format!("PAY:{}", payment.header.document_id));
470 header.header_text = Some(format!(
471 "Payment {} - {}",
472 payment.header.document_id, payment.business_partner_id
473 ));
474
475 let mut entry = JournalEntry::new(header);
476
477 let mut debit_line = JournalEntryLine::debit(
479 entry.header.document_id,
480 1,
481 self.config.ap_account.clone(),
482 payment.amount,
483 );
484 self.set_auxiliary_fields(
485 &mut debit_line,
486 &payment.business_partner_id,
487 &payment.business_partner_id,
488 );
489 entry.add_line(debit_line);
490
491 let credit_line = JournalEntryLine::credit(
493 entry.header.document_id,
494 2,
495 self.config.cash_account.clone(),
496 payment.amount,
497 );
498 entry.add_line(credit_line);
499
500 Some(entry)
501 }
502
503 pub fn generate_from_delivery(&mut self, delivery: &Delivery) -> Option<JournalEntry> {
506 if delivery.items.is_empty() {
507 return None;
508 }
509
510 let document_id = self.uuid_factory.next();
511
512 let total_cost = delivery
514 .items
515 .iter()
516 .map(|item| item.base.net_amount)
517 .sum::<Decimal>();
518
519 if total_cost == Decimal::ZERO {
520 return None;
521 }
522
523 let posting_date = delivery
525 .header
526 .posting_date
527 .unwrap_or(delivery.header.document_date);
528
529 let mut header = JournalEntryHeader::with_deterministic_id(
530 delivery.header.company_code.clone(),
531 posting_date,
532 document_id,
533 );
534 header.source = TransactionSource::Automated;
535 header.business_process = Some(BusinessProcess::O2C);
536 header.reference = Some(format!("DEL:{}", delivery.header.document_id));
537 header.header_text = Some(format!(
538 "Delivery {} - {}",
539 delivery.header.document_id, delivery.customer_id
540 ));
541
542 let mut entry = JournalEntry::new(header);
543
544 let debit_line = JournalEntryLine::debit(
546 entry.header.document_id,
547 1,
548 self.config.cogs_account.clone(),
549 total_cost,
550 );
551 entry.add_line(debit_line);
552
553 let credit_line = JournalEntryLine::credit(
555 entry.header.document_id,
556 2,
557 self.config.inventory_account.clone(),
558 total_cost,
559 );
560 entry.add_line(credit_line);
561
562 Some(entry)
563 }
564
565 pub fn generate_from_customer_invoice(
576 &mut self,
577 invoice: &CustomerInvoice,
578 ) -> Option<JournalEntry> {
579 if invoice.total_gross_amount == Decimal::ZERO {
580 return None;
581 }
582
583 let document_id = self.uuid_factory.next();
584
585 let posting_date = invoice
587 .header
588 .posting_date
589 .unwrap_or(invoice.header.document_date);
590
591 let mut header = JournalEntryHeader::with_deterministic_id(
592 invoice.header.company_code.clone(),
593 posting_date,
594 document_id,
595 );
596 header.source = TransactionSource::Automated;
597 header.business_process = Some(BusinessProcess::O2C);
598 header.reference = Some(format!("CI:{}", invoice.header.document_id));
599 header.header_text = Some(format!(
600 "Customer Invoice {} - {}",
601 invoice.header.document_id, invoice.customer_id
602 ));
603
604 let mut entry = JournalEntry::new(header);
605
606 let mut debit_line = JournalEntryLine::debit(
608 entry.header.document_id,
609 1,
610 self.config.ar_account.clone(),
611 invoice.total_gross_amount,
612 );
613 self.set_auxiliary_fields(&mut debit_line, &invoice.customer_id, &invoice.customer_id);
614 entry.add_line(debit_line);
615
616 let revenue_amount = if invoice.total_tax_amount > Decimal::ZERO {
618 invoice.total_net_amount
619 } else {
620 invoice.total_gross_amount
621 };
622 let credit_line = JournalEntryLine::credit(
623 entry.header.document_id,
624 2,
625 self.config.revenue_account.clone(),
626 revenue_amount,
627 );
628 entry.add_line(credit_line);
629
630 if invoice.total_tax_amount > Decimal::ZERO {
632 let vat_line = JournalEntryLine::credit(
633 entry.header.document_id,
634 3,
635 self.config.vat_output_account.clone(),
636 invoice.total_tax_amount,
637 );
638 entry.add_line(vat_line);
639 }
640
641 Some(entry)
642 }
643
644 pub fn generate_from_ar_receipt(&mut self, payment: &Payment) -> Option<JournalEntry> {
647 if payment.amount == Decimal::ZERO {
648 return None;
649 }
650
651 let document_id = self.uuid_factory.next();
652
653 let posting_date = payment
655 .header
656 .posting_date
657 .unwrap_or(payment.header.document_date);
658
659 let mut header = JournalEntryHeader::with_deterministic_id(
660 payment.header.company_code.clone(),
661 posting_date,
662 document_id,
663 );
664 header.source = TransactionSource::Automated;
665 header.business_process = Some(BusinessProcess::O2C);
666 header.reference = Some(format!("RCP:{}", payment.header.document_id));
667 header.header_text = Some(format!(
668 "Customer Receipt {} - {}",
669 payment.header.document_id, payment.business_partner_id
670 ));
671
672 let mut entry = JournalEntry::new(header);
673
674 let debit_line = JournalEntryLine::debit(
676 entry.header.document_id,
677 1,
678 self.config.cash_account.clone(),
679 payment.amount,
680 );
681 entry.add_line(debit_line);
682
683 let mut credit_line = JournalEntryLine::credit(
685 entry.header.document_id,
686 2,
687 self.config.ar_account.clone(),
688 payment.amount,
689 );
690 self.set_auxiliary_fields(
691 &mut credit_line,
692 &payment.business_partner_id,
693 &payment.business_partner_id,
694 );
695 entry.add_line(credit_line);
696
697 Some(entry)
698 }
699}
700
701impl Default for DocumentFlowJeGenerator {
702 fn default() -> Self {
703 Self::new()
704 }
705}
706
707#[cfg(test)]
708#[allow(clippy::unwrap_used)]
709mod tests {
710 use super::*;
711 use chrono::NaiveDate;
712 use datasynth_core::models::documents::{GoodsReceiptItem, MovementType};
713
714 fn create_test_gr() -> GoodsReceipt {
715 let mut gr = GoodsReceipt::from_purchase_order(
716 "GR-001".to_string(),
717 "1000",
718 "PO-001",
719 "V-001",
720 "P1000",
721 "0001",
722 2024,
723 1,
724 NaiveDate::from_ymd_opt(2024, 1, 15).unwrap(),
725 "JSMITH",
726 );
727
728 let item = GoodsReceiptItem::from_po(
729 10,
730 "Test Material",
731 Decimal::from(100),
732 Decimal::from(50),
733 "PO-001",
734 10,
735 )
736 .with_movement_type(MovementType::GrForPo);
737
738 gr.add_item(item);
739 gr.post("JSMITH", NaiveDate::from_ymd_opt(2024, 1, 15).unwrap());
740
741 gr
742 }
743
744 fn create_test_vendor_invoice() -> VendorInvoice {
745 use datasynth_core::models::documents::VendorInvoiceItem;
746
747 let mut invoice = VendorInvoice::new(
748 "VI-001".to_string(),
749 "1000",
750 "V-001",
751 "INV-12345".to_string(),
752 2024,
753 1,
754 NaiveDate::from_ymd_opt(2024, 1, 20).unwrap(),
755 "JSMITH",
756 );
757
758 let item = VendorInvoiceItem::from_po_gr(
759 10,
760 "Test Material",
761 Decimal::from(100),
762 Decimal::from(50),
763 "PO-001",
764 10,
765 Some("GR-001".to_string()),
766 Some(10),
767 );
768
769 invoice.add_item(item);
770 invoice.post("JSMITH", NaiveDate::from_ymd_opt(2024, 1, 20).unwrap());
771
772 invoice
773 }
774
775 fn create_test_payment() -> Payment {
776 let mut payment = Payment::new_ap_payment(
777 "PAY-001".to_string(),
778 "1000",
779 "V-001",
780 Decimal::from(5000),
781 2024,
782 2,
783 NaiveDate::from_ymd_opt(2024, 2, 15).unwrap(),
784 "JSMITH",
785 );
786
787 payment.post("JSMITH", NaiveDate::from_ymd_opt(2024, 2, 15).unwrap());
788
789 payment
790 }
791
792 #[test]
793 fn test_generate_from_goods_receipt() {
794 let mut generator = DocumentFlowJeGenerator::new();
795 let gr = create_test_gr();
796
797 let je = generator.generate_from_goods_receipt(&gr);
798
799 assert!(je.is_some());
800 let je = je.unwrap();
801
802 assert!(je.is_balanced());
804
805 assert_eq!(je.line_count(), 2);
807
808 assert!(je.total_debit() > Decimal::ZERO);
810 assert_eq!(je.total_debit(), je.total_credit());
811
812 assert!(je.header.reference.is_some());
814 assert!(je.header.reference.as_ref().unwrap().contains("GR:"));
815 }
816
817 #[test]
818 fn test_generate_from_vendor_invoice() {
819 let mut generator = DocumentFlowJeGenerator::new();
820 let invoice = create_test_vendor_invoice();
821
822 let je = generator.generate_from_vendor_invoice(&invoice);
823
824 assert!(je.is_some());
825 let je = je.unwrap();
826
827 assert!(je.is_balanced());
828 assert_eq!(je.line_count(), 2);
829 assert!(je.header.reference.as_ref().unwrap().contains("VI:"));
830 }
831
832 #[test]
833 fn test_generate_from_ap_payment() {
834 let mut generator = DocumentFlowJeGenerator::new();
835 let payment = create_test_payment();
836
837 let je = generator.generate_from_ap_payment(&payment);
838
839 assert!(je.is_some());
840 let je = je.unwrap();
841
842 assert!(je.is_balanced());
843 assert_eq!(je.line_count(), 2);
844 assert!(je.header.reference.as_ref().unwrap().contains("PAY:"));
845 }
846
847 #[test]
848 fn test_all_entries_are_balanced() {
849 let mut generator = DocumentFlowJeGenerator::new();
850
851 let gr = create_test_gr();
852 let invoice = create_test_vendor_invoice();
853 let payment = create_test_payment();
854
855 let entries = vec![
856 generator.generate_from_goods_receipt(&gr),
857 generator.generate_from_vendor_invoice(&invoice),
858 generator.generate_from_ap_payment(&payment),
859 ];
860
861 for entry in entries.into_iter().flatten() {
862 assert!(
863 entry.is_balanced(),
864 "Entry {} is not balanced",
865 entry.header.document_id
866 );
867 }
868 }
869
870 #[test]
875 fn test_french_gaap_auxiliary_on_ap_ar_lines_only() {
876 let mut generator =
878 DocumentFlowJeGenerator::with_config_and_seed(DocumentFlowJeConfig::french_gaap(), 42);
879
880 let invoice = create_test_vendor_invoice();
882 let je = generator.generate_from_vendor_invoice(&invoice).unwrap();
883
884 assert!(
886 je.lines[0].auxiliary_account_number.is_none(),
887 "GR/IR clearing line should not have auxiliary"
888 );
889
890 assert_eq!(
892 je.lines[1].auxiliary_account_number.as_deref(),
893 Some("V-001"),
894 "AP line should have vendor ID as auxiliary"
895 );
896 assert_eq!(
897 je.lines[1].auxiliary_account_label.as_deref(),
898 Some("V-001"),
899 );
900 }
901
902 #[test]
903 fn test_french_gaap_lettrage_on_complete_p2p_chain() {
904 use datasynth_core::models::documents::PurchaseOrder;
905
906 let mut generator =
907 DocumentFlowJeGenerator::with_config_and_seed(DocumentFlowJeConfig::french_gaap(), 42);
908
909 let po = PurchaseOrder::new(
910 "PO-001",
911 "1000",
912 "V-001",
913 2024,
914 1,
915 NaiveDate::from_ymd_opt(2024, 1, 10).unwrap(),
916 "JSMITH",
917 );
918
919 let chain = P2PDocumentChain {
920 purchase_order: po,
921 goods_receipts: vec![create_test_gr()],
922 vendor_invoice: Some(create_test_vendor_invoice()),
923 payment: Some(create_test_payment()),
924 remainder_payments: Vec::new(),
925 is_complete: true,
926 three_way_match_passed: true,
927 payment_timing: None,
928 };
929
930 let entries = generator.generate_from_p2p_chain(&chain);
931 assert!(!entries.is_empty());
932
933 let ap_account = &generator.config.ap_account;
935 let mut lettrage_codes: Vec<&str> = Vec::new();
936 for entry in &entries {
937 for line in &entry.lines {
938 if &line.gl_account == ap_account {
939 assert!(
940 line.lettrage.is_some(),
941 "AP line should have lettrage on complete chain"
942 );
943 assert!(line.lettrage_date.is_some());
944 lettrage_codes.push(line.lettrage.as_deref().unwrap());
945 } else {
946 assert!(
947 line.lettrage.is_none(),
948 "Non-AP line should not have lettrage"
949 );
950 }
951 }
952 }
953
954 assert!(!lettrage_codes.is_empty());
956 assert!(
957 lettrage_codes.iter().all(|c| *c == lettrage_codes[0]),
958 "All AP lines should share the same lettrage code"
959 );
960 assert!(lettrage_codes[0].starts_with("LTR-"));
961 }
962
963 #[test]
964 fn test_incomplete_chain_has_no_lettrage() {
965 use datasynth_core::models::documents::PurchaseOrder;
966
967 let mut generator =
968 DocumentFlowJeGenerator::with_config_and_seed(DocumentFlowJeConfig::french_gaap(), 42);
969
970 let po = PurchaseOrder::new(
971 "PO-002",
972 "1000",
973 "V-001",
974 2024,
975 1,
976 NaiveDate::from_ymd_opt(2024, 1, 10).unwrap(),
977 "JSMITH",
978 );
979
980 let chain = P2PDocumentChain {
982 purchase_order: po,
983 goods_receipts: vec![create_test_gr()],
984 vendor_invoice: Some(create_test_vendor_invoice()),
985 payment: None,
986 remainder_payments: Vec::new(),
987 is_complete: false,
988 three_way_match_passed: false,
989 payment_timing: None,
990 };
991
992 let entries = generator.generate_from_p2p_chain(&chain);
993
994 for entry in &entries {
995 for line in &entry.lines {
996 assert!(
997 line.lettrage.is_none(),
998 "Incomplete chain should have no lettrage"
999 );
1000 }
1001 }
1002 }
1003
1004 #[test]
1005 fn test_default_config_no_fec_fields() {
1006 let mut generator = DocumentFlowJeGenerator::new();
1008
1009 let invoice = create_test_vendor_invoice();
1010 let je = generator.generate_from_vendor_invoice(&invoice).unwrap();
1011
1012 for line in &je.lines {
1013 assert!(line.auxiliary_account_number.is_none());
1014 assert!(line.auxiliary_account_label.is_none());
1015 assert!(line.lettrage.is_none());
1016 assert!(line.lettrage_date.is_none());
1017 }
1018 }
1019
1020 #[test]
1021 fn test_auxiliary_lookup_uses_gl_account_instead_of_partner_id() {
1022 let mut generator =
1025 DocumentFlowJeGenerator::with_config_and_seed(DocumentFlowJeConfig::french_gaap(), 42);
1026
1027 let mut lookup = HashMap::new();
1028 lookup.insert("V-001".to_string(), "4010001".to_string());
1029 generator.set_auxiliary_account_lookup(lookup);
1030
1031 let invoice = create_test_vendor_invoice();
1032 let je = generator.generate_from_vendor_invoice(&invoice).unwrap();
1033
1034 assert_eq!(
1036 je.lines[1].auxiliary_account_number.as_deref(),
1037 Some("4010001"),
1038 "AP line should use auxiliary GL account from lookup"
1039 );
1040 assert_eq!(
1042 je.lines[1].auxiliary_account_label.as_deref(),
1043 Some("V-001"),
1044 );
1045 }
1046
1047 #[test]
1048 fn test_auxiliary_lookup_fallback_to_partner_id() {
1049 let mut generator =
1052 DocumentFlowJeGenerator::with_config_and_seed(DocumentFlowJeConfig::french_gaap(), 42);
1053
1054 let mut lookup = HashMap::new();
1056 lookup.insert("V-999".to_string(), "4019999".to_string());
1057 generator.set_auxiliary_account_lookup(lookup);
1058
1059 let invoice = create_test_vendor_invoice();
1060 let je = generator.generate_from_vendor_invoice(&invoice).unwrap();
1061
1062 assert_eq!(
1064 je.lines[1].auxiliary_account_number.as_deref(),
1065 Some("V-001"),
1066 "Should fall back to partner ID when not in lookup"
1067 );
1068 }
1069
1070 #[test]
1071 fn test_auxiliary_lookup_works_for_customer_receipt() {
1072 let mut generator =
1074 DocumentFlowJeGenerator::with_config_and_seed(DocumentFlowJeConfig::french_gaap(), 42);
1075
1076 let mut lookup = HashMap::new();
1077 lookup.insert("C-001".to_string(), "4110001".to_string());
1078 generator.set_auxiliary_account_lookup(lookup);
1079
1080 let mut receipt = Payment::new_ar_receipt(
1081 "RCP-001".to_string(),
1082 "1000",
1083 "C-001",
1084 Decimal::from(3000),
1085 2024,
1086 3,
1087 NaiveDate::from_ymd_opt(2024, 3, 15).unwrap(),
1088 "JSMITH",
1089 );
1090 receipt.post("JSMITH", NaiveDate::from_ymd_opt(2024, 3, 15).unwrap());
1091
1092 let je = generator.generate_from_ar_receipt(&receipt).unwrap();
1093
1094 assert_eq!(
1096 je.lines[1].auxiliary_account_number.as_deref(),
1097 Some("4110001"),
1098 "AR line should use auxiliary GL account from lookup"
1099 );
1100 }
1101
1102 fn create_test_customer_invoice_with_tax() -> CustomerInvoice {
1108 use datasynth_core::models::documents::CustomerInvoiceItem;
1109
1110 let mut invoice = CustomerInvoice::new(
1111 "CI-001",
1112 "1000",
1113 "C-001",
1114 2024,
1115 1,
1116 NaiveDate::from_ymd_opt(2024, 1, 15).unwrap(),
1117 NaiveDate::from_ymd_opt(2024, 2, 14).unwrap(),
1118 "JSMITH",
1119 );
1120
1121 let mut item =
1123 CustomerInvoiceItem::new(1, "Product A", Decimal::from(10), Decimal::from(100));
1124 item.base.tax_amount = Decimal::from(100);
1125 invoice.add_item(item);
1126 invoice.post("JSMITH", NaiveDate::from_ymd_opt(2024, 1, 15).unwrap());
1127
1128 invoice
1129 }
1130
1131 fn create_test_customer_invoice_no_tax() -> CustomerInvoice {
1133 use datasynth_core::models::documents::CustomerInvoiceItem;
1134
1135 let mut invoice = CustomerInvoice::new(
1136 "CI-002",
1137 "1000",
1138 "C-002",
1139 2024,
1140 1,
1141 NaiveDate::from_ymd_opt(2024, 1, 15).unwrap(),
1142 NaiveDate::from_ymd_opt(2024, 2, 14).unwrap(),
1143 "JSMITH",
1144 );
1145
1146 let item = CustomerInvoiceItem::new(1, "Product B", Decimal::from(10), Decimal::from(100));
1147 invoice.add_item(item);
1148 invoice.post("JSMITH", NaiveDate::from_ymd_opt(2024, 1, 15).unwrap());
1149
1150 invoice
1151 }
1152
1153 fn create_test_vendor_invoice_with_tax() -> VendorInvoice {
1155 use datasynth_core::models::documents::VendorInvoiceItem;
1156
1157 let mut invoice = VendorInvoice::new(
1158 "VI-002".to_string(),
1159 "1000",
1160 "V-001",
1161 "INV-TAX-001".to_string(),
1162 2024,
1163 1,
1164 NaiveDate::from_ymd_opt(2024, 1, 20).unwrap(),
1165 "JSMITH",
1166 );
1167
1168 let item = VendorInvoiceItem::from_po_gr(
1170 10,
1171 "Test Material",
1172 Decimal::from(100),
1173 Decimal::from(50),
1174 "PO-001",
1175 10,
1176 Some("GR-001".to_string()),
1177 Some(10),
1178 )
1179 .with_tax("VAT10", Decimal::from(500));
1180
1181 invoice.add_item(item);
1182 invoice.post("JSMITH", NaiveDate::from_ymd_opt(2024, 1, 20).unwrap());
1183
1184 invoice
1185 }
1186
1187 #[test]
1188 fn test_customer_invoice_with_tax_produces_three_lines() {
1189 let mut generator = DocumentFlowJeGenerator::new();
1190 let invoice = create_test_customer_invoice_with_tax();
1191
1192 assert_eq!(invoice.total_net_amount, Decimal::from(1000));
1193 assert_eq!(invoice.total_tax_amount, Decimal::from(100));
1194 assert_eq!(invoice.total_gross_amount, Decimal::from(1100));
1195
1196 let je = generator.generate_from_customer_invoice(&invoice).unwrap();
1197
1198 assert_eq!(
1200 je.line_count(),
1201 3,
1202 "Expected 3 JE lines for invoice with tax"
1203 );
1204 assert!(je.is_balanced(), "Entry must be balanced");
1205
1206 assert_eq!(je.lines[0].gl_account, control_accounts::AR_CONTROL);
1208 assert_eq!(je.lines[0].debit_amount, Decimal::from(1100));
1209 assert_eq!(je.lines[0].credit_amount, Decimal::ZERO);
1210
1211 assert_eq!(je.lines[1].gl_account, revenue_accounts::PRODUCT_REVENUE);
1213 assert_eq!(je.lines[1].credit_amount, Decimal::from(1000));
1214 assert_eq!(je.lines[1].debit_amount, Decimal::ZERO);
1215
1216 assert_eq!(je.lines[2].gl_account, tax_accounts::VAT_PAYABLE);
1218 assert_eq!(je.lines[2].credit_amount, Decimal::from(100));
1219 assert_eq!(je.lines[2].debit_amount, Decimal::ZERO);
1220 }
1221
1222 #[test]
1223 fn test_customer_invoice_no_tax_produces_two_lines() {
1224 let mut generator = DocumentFlowJeGenerator::new();
1225 let invoice = create_test_customer_invoice_no_tax();
1226
1227 assert_eq!(invoice.total_tax_amount, Decimal::ZERO);
1228 assert_eq!(invoice.total_net_amount, Decimal::from(1000));
1229 assert_eq!(invoice.total_gross_amount, Decimal::from(1000));
1230
1231 let je = generator.generate_from_customer_invoice(&invoice).unwrap();
1232
1233 assert_eq!(
1235 je.line_count(),
1236 2,
1237 "Expected 2 JE lines for invoice without tax"
1238 );
1239 assert!(je.is_balanced(), "Entry must be balanced");
1240
1241 assert_eq!(je.lines[0].gl_account, control_accounts::AR_CONTROL);
1243 assert_eq!(je.lines[0].debit_amount, Decimal::from(1000));
1244
1245 assert_eq!(je.lines[1].gl_account, revenue_accounts::PRODUCT_REVENUE);
1247 assert_eq!(je.lines[1].credit_amount, Decimal::from(1000));
1248 }
1249
1250 #[test]
1251 fn test_vendor_invoice_with_tax_produces_three_lines() {
1252 let mut generator = DocumentFlowJeGenerator::new();
1253 let invoice = create_test_vendor_invoice_with_tax();
1254
1255 assert_eq!(invoice.net_amount, Decimal::from(5000));
1256 assert_eq!(invoice.tax_amount, Decimal::from(500));
1257 assert_eq!(invoice.gross_amount, Decimal::from(5500));
1258 assert_eq!(invoice.payable_amount, Decimal::from(5500));
1259
1260 let je = generator.generate_from_vendor_invoice(&invoice).unwrap();
1261
1262 assert_eq!(
1264 je.line_count(),
1265 3,
1266 "Expected 3 JE lines for vendor invoice with tax"
1267 );
1268 assert!(je.is_balanced(), "Entry must be balanced");
1269
1270 assert_eq!(je.lines[0].gl_account, control_accounts::GR_IR_CLEARING);
1272 assert_eq!(je.lines[0].debit_amount, Decimal::from(5000));
1273 assert_eq!(je.lines[0].credit_amount, Decimal::ZERO);
1274
1275 assert_eq!(je.lines[1].gl_account, tax_accounts::INPUT_VAT);
1277 assert_eq!(je.lines[1].debit_amount, Decimal::from(500));
1278 assert_eq!(je.lines[1].credit_amount, Decimal::ZERO);
1279
1280 assert_eq!(je.lines[2].gl_account, control_accounts::AP_CONTROL);
1282 assert_eq!(je.lines[2].credit_amount, Decimal::from(5500));
1283 assert_eq!(je.lines[2].debit_amount, Decimal::ZERO);
1284 }
1285
1286 #[test]
1287 fn test_vendor_invoice_no_tax_produces_two_lines() {
1288 let mut generator = DocumentFlowJeGenerator::new();
1290 let invoice = create_test_vendor_invoice();
1291
1292 assert_eq!(invoice.tax_amount, Decimal::ZERO);
1293
1294 let je = generator.generate_from_vendor_invoice(&invoice).unwrap();
1295
1296 assert_eq!(
1298 je.line_count(),
1299 2,
1300 "Expected 2 JE lines for vendor invoice without tax"
1301 );
1302 assert!(je.is_balanced(), "Entry must be balanced");
1303
1304 assert_eq!(je.lines[0].gl_account, control_accounts::GR_IR_CLEARING);
1306 assert_eq!(je.lines[0].debit_amount, invoice.payable_amount);
1307
1308 assert_eq!(je.lines[1].gl_account, control_accounts::AP_CONTROL);
1310 assert_eq!(je.lines[1].credit_amount, invoice.payable_amount);
1311 }
1312
1313 #[test]
1314 fn test_vat_accounts_configurable() {
1315 let mut config = DocumentFlowJeConfig::default();
1317 config.vat_output_account = "2999".to_string();
1318 config.vat_input_account = "1999".to_string();
1319
1320 let mut generator = DocumentFlowJeGenerator::with_config_and_seed(config, 42);
1321
1322 let ci = create_test_customer_invoice_with_tax();
1324 let je = generator.generate_from_customer_invoice(&ci).unwrap();
1325 assert_eq!(
1326 je.lines[2].gl_account, "2999",
1327 "VAT output account should be configurable"
1328 );
1329
1330 let vi = create_test_vendor_invoice_with_tax();
1332 let je = generator.generate_from_vendor_invoice(&vi).unwrap();
1333 assert_eq!(
1334 je.lines[1].gl_account, "1999",
1335 "VAT input account should be configurable"
1336 );
1337 }
1338
1339 #[test]
1340 fn test_vat_entries_from_framework_accounts() {
1341 let fa = datasynth_core::FrameworkAccounts::us_gaap();
1343 let config = DocumentFlowJeConfig::from(&fa);
1344
1345 assert_eq!(config.vat_output_account, tax_accounts::VAT_PAYABLE);
1346 assert_eq!(config.vat_input_account, tax_accounts::INPUT_VAT);
1347
1348 let fa_fr = datasynth_core::FrameworkAccounts::french_gaap();
1349 let config_fr = DocumentFlowJeConfig::from(&fa_fr);
1350
1351 assert_eq!(config_fr.vat_output_account, "445710");
1352 assert_eq!(config_fr.vat_input_account, "445660");
1353 }
1354
1355 #[test]
1356 fn test_french_gaap_vat_accounts() {
1357 let config = DocumentFlowJeConfig::french_gaap();
1358 assert_eq!(config.vat_output_account, "445710"); assert_eq!(config.vat_input_account, "445660"); }
1361
1362 #[test]
1363 fn test_vat_balanced_with_multiple_items() {
1364 use datasynth_core::models::documents::CustomerInvoiceItem;
1366
1367 let mut invoice = CustomerInvoice::new(
1368 "CI-003",
1369 "1000",
1370 "C-003",
1371 2024,
1372 1,
1373 NaiveDate::from_ymd_opt(2024, 1, 15).unwrap(),
1374 NaiveDate::from_ymd_opt(2024, 2, 14).unwrap(),
1375 "JSMITH",
1376 );
1377
1378 let mut item1 = CustomerInvoiceItem::new(1, "A", Decimal::from(5), Decimal::from(100));
1380 item1.base.tax_amount = Decimal::from(50);
1381 invoice.add_item(item1);
1382
1383 let mut item2 = CustomerInvoiceItem::new(2, "B", Decimal::from(3), Decimal::from(100));
1385 item2.base.tax_amount = Decimal::from(30);
1386 invoice.add_item(item2);
1387
1388 invoice.post("JSMITH", NaiveDate::from_ymd_opt(2024, 1, 15).unwrap());
1389
1390 assert_eq!(invoice.total_net_amount, Decimal::from(800));
1392 assert_eq!(invoice.total_tax_amount, Decimal::from(80));
1393 assert_eq!(invoice.total_gross_amount, Decimal::from(880));
1394
1395 let mut generator = DocumentFlowJeGenerator::new();
1396 let je = generator.generate_from_customer_invoice(&invoice).unwrap();
1397
1398 assert_eq!(je.line_count(), 3);
1399 assert!(je.is_balanced());
1400 assert_eq!(je.total_debit(), Decimal::from(880));
1401 assert_eq!(je.total_credit(), Decimal::from(880));
1402 }
1403}