Skip to main content

datasynth_generators/document_flow/
document_flow_je_generator.rs

1//! Generate journal entries from document flows.
2//!
3//! This module creates proper GL entries from P2P and O2C document chains,
4//! ensuring that document flow activity is reflected in the general ledger.
5//!
6//! # P2P Flow JE Mappings
7//! - GoodsReceipt → DR Inventory, CR GR/IR Clearing
8//! - VendorInvoice → DR GR/IR Clearing, CR AP
9//! - Payment → DR AP, CR Cash
10//!
11//! # O2C Flow JE Mappings
12//! - Delivery → DR COGS, CR Inventory
13//! - CustomerInvoice → DR AR, CR Revenue
14//! - CustomerReceipt → DR Cash, CR AR
15
16use 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,
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/// Configuration for document flow JE generation.
33#[derive(Debug, Clone)]
34pub struct DocumentFlowJeConfig {
35    /// Inventory account (default: 1200 from control_accounts::INVENTORY)
36    pub inventory_account: String,
37    /// GR/IR clearing account (default: 2900 from control_accounts::GR_IR_CLEARING)
38    pub gr_ir_clearing_account: String,
39    /// Accounts payable control account (default: 2000 from control_accounts::AP_CONTROL)
40    pub ap_account: String,
41    /// Cash/bank account (default: 1000 from cash_accounts::OPERATING_CASH)
42    pub cash_account: String,
43    /// Accounts receivable control account (default: 1100 from control_accounts::AR_CONTROL)
44    pub ar_account: String,
45    /// Revenue account (default: 4000 from revenue_accounts::PRODUCT_REVENUE)
46    pub revenue_account: String,
47    /// COGS account (default: 5000 from expense_accounts::COGS)
48    pub cogs_account: String,
49    /// Whether to populate FEC auxiliary and lettrage fields on AP/AR lines.
50    /// Only relevant for French GAAP / FEC export.
51    pub populate_fec_fields: bool,
52}
53
54impl Default for DocumentFlowJeConfig {
55    fn default() -> Self {
56        Self {
57            inventory_account: control_accounts::INVENTORY.to_string(),
58            gr_ir_clearing_account: control_accounts::GR_IR_CLEARING.to_string(),
59            ap_account: control_accounts::AP_CONTROL.to_string(),
60            cash_account: cash_accounts::OPERATING_CASH.to_string(),
61            ar_account: control_accounts::AR_CONTROL.to_string(),
62            revenue_account: revenue_accounts::PRODUCT_REVENUE.to_string(),
63            cogs_account: expense_accounts::COGS.to_string(),
64            populate_fec_fields: false,
65        }
66    }
67}
68
69impl DocumentFlowJeConfig {
70    /// Create a config for French GAAP (PCG) with FEC field population enabled.
71    pub fn french_gaap() -> Self {
72        use datasynth_core::pcg;
73        Self {
74            inventory_account: pcg::control_accounts::INVENTORY.to_string(),
75            gr_ir_clearing_account: pcg::control_accounts::GR_IR_CLEARING.to_string(),
76            ap_account: pcg::control_accounts::AP_CONTROL.to_string(),
77            cash_account: pcg::cash_accounts::BANK_ACCOUNT.to_string(),
78            ar_account: pcg::control_accounts::AR_CONTROL.to_string(),
79            revenue_account: pcg::revenue_accounts::PRODUCT_REVENUE.to_string(),
80            cogs_account: pcg::expense_accounts::COGS.to_string(),
81            populate_fec_fields: true,
82        }
83    }
84}
85
86impl From<&datasynth_core::FrameworkAccounts> for DocumentFlowJeConfig {
87    fn from(fa: &datasynth_core::FrameworkAccounts) -> Self {
88        Self {
89            inventory_account: fa.inventory.clone(),
90            gr_ir_clearing_account: fa.gr_ir_clearing.clone(),
91            ap_account: fa.ap_control.clone(),
92            cash_account: fa.bank_account.clone(),
93            ar_account: fa.ar_control.clone(),
94            revenue_account: fa.product_revenue.clone(),
95            cogs_account: fa.cogs.clone(),
96            populate_fec_fields: fa.audit_export.fec_enabled,
97        }
98    }
99}
100
101/// Generator for creating JEs from document flows.
102pub struct DocumentFlowJeGenerator {
103    config: DocumentFlowJeConfig,
104    uuid_factory: DeterministicUuidFactory,
105    /// Lookup map: partner_id → auxiliary GL account number.
106    /// When populated (from vendor/customer master data), `set_auxiliary_fields`
107    /// uses the framework-specific auxiliary account (e.g., PCG "4010001", SKR04 "33000001")
108    /// instead of the raw partner ID.
109    auxiliary_account_lookup: HashMap<String, String>,
110}
111
112impl DocumentFlowJeGenerator {
113    /// Create a new document flow JE generator with default config and seed 0.
114    pub fn new() -> Self {
115        Self::with_config_and_seed(DocumentFlowJeConfig::default(), 0)
116    }
117
118    /// Create with custom account configuration and seed.
119    pub fn with_config_and_seed(config: DocumentFlowJeConfig, seed: u64) -> Self {
120        Self {
121            config,
122            uuid_factory: DeterministicUuidFactory::new(seed, GeneratorType::DocumentFlow),
123            auxiliary_account_lookup: HashMap::new(),
124        }
125    }
126
127    /// Set the auxiliary account lookup map (partner_id → auxiliary GL account).
128    ///
129    /// When populated, FEC `auxiliary_account_number` fields will use the
130    /// framework-specific auxiliary GL account (e.g., PCG "4010001") instead
131    /// of the raw partner ID.
132    pub fn set_auxiliary_account_lookup(&mut self, lookup: HashMap<String, String>) {
133        self.auxiliary_account_lookup = lookup;
134    }
135
136    /// Set auxiliary account fields on AP/AR lines when FEC population is enabled.
137    ///
138    /// Only sets the fields if `populate_fec_fields` is true and the line's
139    /// GL account matches the configured AP or AR control account.
140    ///
141    /// When an auxiliary account lookup is available, uses the framework-specific
142    /// auxiliary GL account (e.g., PCG "4010001", SKR04 "33000001") instead of
143    /// the raw partner ID.
144    fn set_auxiliary_fields(
145        &self,
146        line: &mut JournalEntryLine,
147        partner_id: &str,
148        partner_label: &str,
149    ) {
150        if !self.config.populate_fec_fields {
151            return;
152        }
153        if line.gl_account == self.config.ap_account || line.gl_account == self.config.ar_account {
154            // Prefer the framework-specific auxiliary GL account from the lookup map;
155            // fall back to the raw partner ID if not found.
156            let aux_account = self
157                .auxiliary_account_lookup
158                .get(partner_id)
159                .cloned()
160                .unwrap_or_else(|| partner_id.to_string());
161            line.auxiliary_account_number = Some(aux_account);
162            line.auxiliary_account_label = Some(partner_label.to_string());
163        }
164    }
165
166    /// Apply lettrage (matching) codes to all AP/AR lines in a set of entries.
167    ///
168    /// Only sets lettrage if `populate_fec_fields` is true. The lettrage code
169    /// is derived from the chain ID (e.g. PO or SO document ID) and the date
170    /// is typically the final payment's posting date.
171    fn apply_lettrage(
172        &self,
173        entries: &mut [JournalEntry],
174        chain_id: &str,
175        lettrage_date: NaiveDate,
176    ) {
177        if !self.config.populate_fec_fields {
178            return;
179        }
180        let code = format!("LTR-{}", &chain_id[..chain_id.len().min(8)]);
181        for entry in entries.iter_mut() {
182            for line in entry.lines.iter_mut() {
183                if line.gl_account == self.config.ap_account
184                    || line.gl_account == self.config.ar_account
185                {
186                    line.lettrage = Some(code.clone());
187                    line.lettrage_date = Some(lettrage_date);
188                }
189            }
190        }
191    }
192
193    /// Generate all JEs from a P2P document chain.
194    pub fn generate_from_p2p_chain(&mut self, chain: &P2PDocumentChain) -> Vec<JournalEntry> {
195        let mut entries = Vec::new();
196
197        // Generate JEs for goods receipts
198        for gr in &chain.goods_receipts {
199            if let Some(je) = self.generate_from_goods_receipt(gr) {
200                entries.push(je);
201            }
202        }
203
204        // Generate JE for vendor invoice
205        if let Some(ref invoice) = chain.vendor_invoice {
206            if let Some(je) = self.generate_from_vendor_invoice(invoice) {
207                entries.push(je);
208            }
209        }
210
211        // Generate JE for payment
212        if let Some(ref payment) = chain.payment {
213            if let Some(je) = self.generate_from_ap_payment(payment) {
214                entries.push(je);
215            }
216        }
217
218        // Apply lettrage on complete P2P chains (invoice + payment both present)
219        if self.config.populate_fec_fields && chain.is_complete {
220            if let Some(ref payment) = chain.payment {
221                let posting_date = payment
222                    .header
223                    .posting_date
224                    .unwrap_or(payment.header.document_date);
225                self.apply_lettrage(
226                    &mut entries,
227                    &chain.purchase_order.header.document_id,
228                    posting_date,
229                );
230            }
231        }
232
233        entries
234    }
235
236    /// Generate all JEs from an O2C document chain.
237    pub fn generate_from_o2c_chain(&mut self, chain: &O2CDocumentChain) -> Vec<JournalEntry> {
238        let mut entries = Vec::new();
239
240        // Generate JEs for deliveries
241        for delivery in &chain.deliveries {
242            if let Some(je) = self.generate_from_delivery(delivery) {
243                entries.push(je);
244            }
245        }
246
247        // Generate JE for customer invoice
248        if let Some(ref invoice) = chain.customer_invoice {
249            if let Some(je) = self.generate_from_customer_invoice(invoice) {
250                entries.push(je);
251            }
252        }
253
254        // Generate JE for customer receipt
255        if let Some(ref receipt) = chain.customer_receipt {
256            if let Some(je) = self.generate_from_ar_receipt(receipt) {
257                entries.push(je);
258            }
259        }
260
261        // Apply lettrage on complete O2C chains (invoice + receipt both present)
262        if self.config.populate_fec_fields && chain.customer_receipt.is_some() {
263            if let Some(ref receipt) = chain.customer_receipt {
264                let posting_date = receipt
265                    .header
266                    .posting_date
267                    .unwrap_or(receipt.header.document_date);
268                self.apply_lettrage(
269                    &mut entries,
270                    &chain.sales_order.header.document_id,
271                    posting_date,
272                );
273            }
274        }
275
276        entries
277    }
278
279    /// Generate JE from Goods Receipt.
280    /// DR Inventory, CR GR/IR Clearing
281    pub fn generate_from_goods_receipt(&mut self, gr: &GoodsReceipt) -> Option<JournalEntry> {
282        if gr.items.is_empty() {
283            return None;
284        }
285
286        let document_id = self.uuid_factory.next();
287
288        // Use the total_value from the GR, or calculate from line items
289        let total_amount = if gr.total_value > Decimal::ZERO {
290            gr.total_value
291        } else {
292            gr.items
293                .iter()
294                .map(|item| item.base.net_amount)
295                .sum::<Decimal>()
296        };
297
298        if total_amount == Decimal::ZERO {
299            return None;
300        }
301
302        // Use posting_date or fall back to document_date
303        let posting_date = gr.header.posting_date.unwrap_or(gr.header.document_date);
304
305        let mut header = JournalEntryHeader::with_deterministic_id(
306            gr.header.company_code.clone(),
307            posting_date,
308            document_id,
309        );
310        header.source = TransactionSource::Automated;
311        header.business_process = Some(BusinessProcess::P2P);
312        header.reference = Some(format!("GR:{}", gr.header.document_id));
313        header.header_text = Some(format!(
314            "Goods Receipt {} - {}",
315            gr.header.document_id,
316            gr.vendor_id.as_deref().unwrap_or("Unknown")
317        ));
318
319        let mut entry = JournalEntry::new(header);
320
321        // DR Inventory
322        let debit_line = JournalEntryLine::debit(
323            entry.header.document_id,
324            1,
325            self.config.inventory_account.clone(),
326            total_amount,
327        );
328        entry.add_line(debit_line);
329
330        // CR GR/IR Clearing
331        let credit_line = JournalEntryLine::credit(
332            entry.header.document_id,
333            2,
334            self.config.gr_ir_clearing_account.clone(),
335            total_amount,
336        );
337        entry.add_line(credit_line);
338
339        Some(entry)
340    }
341
342    /// Generate JE from Vendor Invoice.
343    /// DR GR/IR Clearing, CR AP
344    pub fn generate_from_vendor_invoice(
345        &mut self,
346        invoice: &VendorInvoice,
347    ) -> Option<JournalEntry> {
348        if invoice.payable_amount == Decimal::ZERO {
349            return None;
350        }
351
352        let document_id = self.uuid_factory.next();
353
354        // Use posting_date or fall back to document_date
355        let posting_date = invoice
356            .header
357            .posting_date
358            .unwrap_or(invoice.header.document_date);
359
360        let mut header = JournalEntryHeader::with_deterministic_id(
361            invoice.header.company_code.clone(),
362            posting_date,
363            document_id,
364        );
365        header.source = TransactionSource::Automated;
366        header.business_process = Some(BusinessProcess::P2P);
367        header.reference = Some(format!("VI:{}", invoice.header.document_id));
368        header.header_text = Some(format!(
369            "Vendor Invoice {} - {}",
370            invoice.vendor_invoice_number, invoice.vendor_id
371        ));
372
373        let mut entry = JournalEntry::new(header);
374
375        // DR GR/IR Clearing (or expense if no PO)
376        let debit_line = JournalEntryLine::debit(
377            entry.header.document_id,
378            1,
379            self.config.gr_ir_clearing_account.clone(),
380            invoice.payable_amount,
381        );
382        entry.add_line(debit_line);
383
384        // CR Accounts Payable
385        let mut credit_line = JournalEntryLine::credit(
386            entry.header.document_id,
387            2,
388            self.config.ap_account.clone(),
389            invoice.payable_amount,
390        );
391        self.set_auxiliary_fields(&mut credit_line, &invoice.vendor_id, &invoice.vendor_id);
392        entry.add_line(credit_line);
393
394        Some(entry)
395    }
396
397    /// Generate JE from AP Payment.
398    /// DR AP, CR Cash
399    pub fn generate_from_ap_payment(&mut self, payment: &Payment) -> Option<JournalEntry> {
400        if payment.amount == Decimal::ZERO {
401            return None;
402        }
403
404        let document_id = self.uuid_factory.next();
405
406        // Use posting_date or fall back to document_date
407        let posting_date = payment
408            .header
409            .posting_date
410            .unwrap_or(payment.header.document_date);
411
412        let mut header = JournalEntryHeader::with_deterministic_id(
413            payment.header.company_code.clone(),
414            posting_date,
415            document_id,
416        );
417        header.source = TransactionSource::Automated;
418        header.business_process = Some(BusinessProcess::P2P);
419        header.reference = Some(format!("PAY:{}", payment.header.document_id));
420        header.header_text = Some(format!(
421            "Payment {} - {}",
422            payment.header.document_id, payment.business_partner_id
423        ));
424
425        let mut entry = JournalEntry::new(header);
426
427        // DR Accounts Payable
428        let mut debit_line = JournalEntryLine::debit(
429            entry.header.document_id,
430            1,
431            self.config.ap_account.clone(),
432            payment.amount,
433        );
434        self.set_auxiliary_fields(
435            &mut debit_line,
436            &payment.business_partner_id,
437            &payment.business_partner_id,
438        );
439        entry.add_line(debit_line);
440
441        // CR Cash/Bank
442        let credit_line = JournalEntryLine::credit(
443            entry.header.document_id,
444            2,
445            self.config.cash_account.clone(),
446            payment.amount,
447        );
448        entry.add_line(credit_line);
449
450        Some(entry)
451    }
452
453    /// Generate JE from Delivery.
454    /// DR COGS, CR Inventory
455    pub fn generate_from_delivery(&mut self, delivery: &Delivery) -> Option<JournalEntry> {
456        if delivery.items.is_empty() {
457            return None;
458        }
459
460        let document_id = self.uuid_factory.next();
461
462        // Calculate total cost from line items
463        let total_cost = delivery
464            .items
465            .iter()
466            .map(|item| item.base.net_amount)
467            .sum::<Decimal>();
468
469        if total_cost == Decimal::ZERO {
470            return None;
471        }
472
473        // Use posting_date or fall back to document_date
474        let posting_date = delivery
475            .header
476            .posting_date
477            .unwrap_or(delivery.header.document_date);
478
479        let mut header = JournalEntryHeader::with_deterministic_id(
480            delivery.header.company_code.clone(),
481            posting_date,
482            document_id,
483        );
484        header.source = TransactionSource::Automated;
485        header.business_process = Some(BusinessProcess::O2C);
486        header.reference = Some(format!("DEL:{}", delivery.header.document_id));
487        header.header_text = Some(format!(
488            "Delivery {} - {}",
489            delivery.header.document_id, delivery.customer_id
490        ));
491
492        let mut entry = JournalEntry::new(header);
493
494        // DR COGS
495        let debit_line = JournalEntryLine::debit(
496            entry.header.document_id,
497            1,
498            self.config.cogs_account.clone(),
499            total_cost,
500        );
501        entry.add_line(debit_line);
502
503        // CR Inventory
504        let credit_line = JournalEntryLine::credit(
505            entry.header.document_id,
506            2,
507            self.config.inventory_account.clone(),
508            total_cost,
509        );
510        entry.add_line(credit_line);
511
512        Some(entry)
513    }
514
515    /// Generate JE from Customer Invoice.
516    /// DR AR, CR Revenue
517    pub fn generate_from_customer_invoice(
518        &mut self,
519        invoice: &CustomerInvoice,
520    ) -> Option<JournalEntry> {
521        if invoice.total_gross_amount == Decimal::ZERO {
522            return None;
523        }
524
525        let document_id = self.uuid_factory.next();
526
527        // Use posting_date or fall back to document_date
528        let posting_date = invoice
529            .header
530            .posting_date
531            .unwrap_or(invoice.header.document_date);
532
533        let mut header = JournalEntryHeader::with_deterministic_id(
534            invoice.header.company_code.clone(),
535            posting_date,
536            document_id,
537        );
538        header.source = TransactionSource::Automated;
539        header.business_process = Some(BusinessProcess::O2C);
540        header.reference = Some(format!("CI:{}", invoice.header.document_id));
541        header.header_text = Some(format!(
542            "Customer Invoice {} - {}",
543            invoice.header.document_id, invoice.customer_id
544        ));
545
546        let mut entry = JournalEntry::new(header);
547
548        // DR Accounts Receivable
549        let mut debit_line = JournalEntryLine::debit(
550            entry.header.document_id,
551            1,
552            self.config.ar_account.clone(),
553            invoice.total_gross_amount,
554        );
555        self.set_auxiliary_fields(&mut debit_line, &invoice.customer_id, &invoice.customer_id);
556        entry.add_line(debit_line);
557
558        // CR Revenue
559        let credit_line = JournalEntryLine::credit(
560            entry.header.document_id,
561            2,
562            self.config.revenue_account.clone(),
563            invoice.total_gross_amount,
564        );
565        entry.add_line(credit_line);
566
567        Some(entry)
568    }
569
570    /// Generate JE from AR Receipt (Customer Payment).
571    /// DR Cash, CR AR
572    pub fn generate_from_ar_receipt(&mut self, payment: &Payment) -> Option<JournalEntry> {
573        if payment.amount == Decimal::ZERO {
574            return None;
575        }
576
577        let document_id = self.uuid_factory.next();
578
579        // Use posting_date or fall back to document_date
580        let posting_date = payment
581            .header
582            .posting_date
583            .unwrap_or(payment.header.document_date);
584
585        let mut header = JournalEntryHeader::with_deterministic_id(
586            payment.header.company_code.clone(),
587            posting_date,
588            document_id,
589        );
590        header.source = TransactionSource::Automated;
591        header.business_process = Some(BusinessProcess::O2C);
592        header.reference = Some(format!("RCP:{}", payment.header.document_id));
593        header.header_text = Some(format!(
594            "Customer Receipt {} - {}",
595            payment.header.document_id, payment.business_partner_id
596        ));
597
598        let mut entry = JournalEntry::new(header);
599
600        // DR Cash/Bank
601        let debit_line = JournalEntryLine::debit(
602            entry.header.document_id,
603            1,
604            self.config.cash_account.clone(),
605            payment.amount,
606        );
607        entry.add_line(debit_line);
608
609        // CR Accounts Receivable
610        let mut credit_line = JournalEntryLine::credit(
611            entry.header.document_id,
612            2,
613            self.config.ar_account.clone(),
614            payment.amount,
615        );
616        self.set_auxiliary_fields(
617            &mut credit_line,
618            &payment.business_partner_id,
619            &payment.business_partner_id,
620        );
621        entry.add_line(credit_line);
622
623        Some(entry)
624    }
625}
626
627impl Default for DocumentFlowJeGenerator {
628    fn default() -> Self {
629        Self::new()
630    }
631}
632
633#[cfg(test)]
634#[allow(clippy::unwrap_used)]
635mod tests {
636    use super::*;
637    use chrono::NaiveDate;
638    use datasynth_core::models::documents::{GoodsReceiptItem, MovementType};
639
640    fn create_test_gr() -> GoodsReceipt {
641        let mut gr = GoodsReceipt::from_purchase_order(
642            "GR-001".to_string(),
643            "1000",
644            "PO-001",
645            "V-001",
646            "P1000",
647            "0001",
648            2024,
649            1,
650            NaiveDate::from_ymd_opt(2024, 1, 15).unwrap(),
651            "JSMITH",
652        );
653
654        let item = GoodsReceiptItem::from_po(
655            10,
656            "Test Material",
657            Decimal::from(100),
658            Decimal::from(50),
659            "PO-001",
660            10,
661        )
662        .with_movement_type(MovementType::GrForPo);
663
664        gr.add_item(item);
665        gr.post("JSMITH", NaiveDate::from_ymd_opt(2024, 1, 15).unwrap());
666
667        gr
668    }
669
670    fn create_test_vendor_invoice() -> VendorInvoice {
671        use datasynth_core::models::documents::VendorInvoiceItem;
672
673        let mut invoice = VendorInvoice::new(
674            "VI-001".to_string(),
675            "1000",
676            "V-001",
677            "INV-12345".to_string(),
678            2024,
679            1,
680            NaiveDate::from_ymd_opt(2024, 1, 20).unwrap(),
681            "JSMITH",
682        );
683
684        let item = VendorInvoiceItem::from_po_gr(
685            10,
686            "Test Material",
687            Decimal::from(100),
688            Decimal::from(50),
689            "PO-001",
690            10,
691            Some("GR-001".to_string()),
692            Some(10),
693        );
694
695        invoice.add_item(item);
696        invoice.post("JSMITH", NaiveDate::from_ymd_opt(2024, 1, 20).unwrap());
697
698        invoice
699    }
700
701    fn create_test_payment() -> Payment {
702        let mut payment = Payment::new_ap_payment(
703            "PAY-001".to_string(),
704            "1000",
705            "V-001",
706            Decimal::from(5000),
707            2024,
708            2,
709            NaiveDate::from_ymd_opt(2024, 2, 15).unwrap(),
710            "JSMITH",
711        );
712
713        payment.post("JSMITH", NaiveDate::from_ymd_opt(2024, 2, 15).unwrap());
714
715        payment
716    }
717
718    #[test]
719    fn test_generate_from_goods_receipt() {
720        let mut generator = DocumentFlowJeGenerator::new();
721        let gr = create_test_gr();
722
723        let je = generator.generate_from_goods_receipt(&gr);
724
725        assert!(je.is_some());
726        let je = je.unwrap();
727
728        // Should be balanced
729        assert!(je.is_balanced());
730
731        // Should have 2 lines
732        assert_eq!(je.line_count(), 2);
733
734        // DR should be inventory, CR should be GR/IR
735        assert!(je.total_debit() > Decimal::ZERO);
736        assert_eq!(je.total_debit(), je.total_credit());
737
738        // Should reference source document
739        assert!(je.header.reference.is_some());
740        assert!(je.header.reference.as_ref().unwrap().contains("GR:"));
741    }
742
743    #[test]
744    fn test_generate_from_vendor_invoice() {
745        let mut generator = DocumentFlowJeGenerator::new();
746        let invoice = create_test_vendor_invoice();
747
748        let je = generator.generate_from_vendor_invoice(&invoice);
749
750        assert!(je.is_some());
751        let je = je.unwrap();
752
753        assert!(je.is_balanced());
754        assert_eq!(je.line_count(), 2);
755        assert!(je.header.reference.as_ref().unwrap().contains("VI:"));
756    }
757
758    #[test]
759    fn test_generate_from_ap_payment() {
760        let mut generator = DocumentFlowJeGenerator::new();
761        let payment = create_test_payment();
762
763        let je = generator.generate_from_ap_payment(&payment);
764
765        assert!(je.is_some());
766        let je = je.unwrap();
767
768        assert!(je.is_balanced());
769        assert_eq!(je.line_count(), 2);
770        assert!(je.header.reference.as_ref().unwrap().contains("PAY:"));
771    }
772
773    #[test]
774    fn test_all_entries_are_balanced() {
775        let mut generator = DocumentFlowJeGenerator::new();
776
777        let gr = create_test_gr();
778        let invoice = create_test_vendor_invoice();
779        let payment = create_test_payment();
780
781        let entries = vec![
782            generator.generate_from_goods_receipt(&gr),
783            generator.generate_from_vendor_invoice(&invoice),
784            generator.generate_from_ap_payment(&payment),
785        ];
786
787        for entry in entries.into_iter().flatten() {
788            assert!(
789                entry.is_balanced(),
790                "Entry {} is not balanced",
791                entry.header.document_id
792            );
793        }
794    }
795
796    // ====================================================================
797    // FEC compliance tests
798    // ====================================================================
799
800    #[test]
801    fn test_french_gaap_auxiliary_on_ap_ar_lines_only() {
802        // French GAAP config sets auxiliary fields on AP/AR lines only
803        let mut generator =
804            DocumentFlowJeGenerator::with_config_and_seed(DocumentFlowJeConfig::french_gaap(), 42);
805
806        // Vendor invoice: AP line should have auxiliary, GR/IR line should not
807        let invoice = create_test_vendor_invoice();
808        let je = generator.generate_from_vendor_invoice(&invoice).unwrap();
809
810        // Line 1 = DR GR/IR Clearing → no auxiliary
811        assert!(
812            je.lines[0].auxiliary_account_number.is_none(),
813            "GR/IR clearing line should not have auxiliary"
814        );
815
816        // Line 2 = CR AP → has auxiliary
817        assert_eq!(
818            je.lines[1].auxiliary_account_number.as_deref(),
819            Some("V-001"),
820            "AP line should have vendor ID as auxiliary"
821        );
822        assert_eq!(
823            je.lines[1].auxiliary_account_label.as_deref(),
824            Some("V-001"),
825        );
826    }
827
828    #[test]
829    fn test_french_gaap_lettrage_on_complete_p2p_chain() {
830        use datasynth_core::models::documents::PurchaseOrder;
831
832        let mut generator =
833            DocumentFlowJeGenerator::with_config_and_seed(DocumentFlowJeConfig::french_gaap(), 42);
834
835        let po = PurchaseOrder::new(
836            "PO-001",
837            "1000",
838            "V-001",
839            2024,
840            1,
841            NaiveDate::from_ymd_opt(2024, 1, 10).unwrap(),
842            "JSMITH",
843        );
844
845        let chain = P2PDocumentChain {
846            purchase_order: po,
847            goods_receipts: vec![create_test_gr()],
848            vendor_invoice: Some(create_test_vendor_invoice()),
849            payment: Some(create_test_payment()),
850            is_complete: true,
851            three_way_match_passed: true,
852            payment_timing: None,
853        };
854
855        let entries = generator.generate_from_p2p_chain(&chain);
856        assert!(!entries.is_empty());
857
858        // All AP lines should share the same lettrage code
859        let ap_account = &generator.config.ap_account;
860        let mut lettrage_codes: Vec<&str> = Vec::new();
861        for entry in &entries {
862            for line in &entry.lines {
863                if &line.gl_account == ap_account {
864                    assert!(
865                        line.lettrage.is_some(),
866                        "AP line should have lettrage on complete chain"
867                    );
868                    assert!(line.lettrage_date.is_some());
869                    lettrage_codes.push(line.lettrage.as_deref().unwrap());
870                } else {
871                    assert!(
872                        line.lettrage.is_none(),
873                        "Non-AP line should not have lettrage"
874                    );
875                }
876            }
877        }
878
879        // All AP lettrage codes should be the same
880        assert!(!lettrage_codes.is_empty());
881        assert!(
882            lettrage_codes.iter().all(|c| *c == lettrage_codes[0]),
883            "All AP lines should share the same lettrage code"
884        );
885        assert!(lettrage_codes[0].starts_with("LTR-"));
886    }
887
888    #[test]
889    fn test_incomplete_chain_has_no_lettrage() {
890        use datasynth_core::models::documents::PurchaseOrder;
891
892        let mut generator =
893            DocumentFlowJeGenerator::with_config_and_seed(DocumentFlowJeConfig::french_gaap(), 42);
894
895        let po = PurchaseOrder::new(
896            "PO-002",
897            "1000",
898            "V-001",
899            2024,
900            1,
901            NaiveDate::from_ymd_opt(2024, 1, 10).unwrap(),
902            "JSMITH",
903        );
904
905        // Incomplete chain: no payment
906        let chain = P2PDocumentChain {
907            purchase_order: po,
908            goods_receipts: vec![create_test_gr()],
909            vendor_invoice: Some(create_test_vendor_invoice()),
910            payment: None,
911            is_complete: false,
912            three_way_match_passed: false,
913            payment_timing: None,
914        };
915
916        let entries = generator.generate_from_p2p_chain(&chain);
917
918        for entry in &entries {
919            for line in &entry.lines {
920                assert!(
921                    line.lettrage.is_none(),
922                    "Incomplete chain should have no lettrage"
923                );
924            }
925        }
926    }
927
928    #[test]
929    fn test_default_config_no_fec_fields() {
930        // Default config (non-French) should leave all FEC fields as None
931        let mut generator = DocumentFlowJeGenerator::new();
932
933        let invoice = create_test_vendor_invoice();
934        let je = generator.generate_from_vendor_invoice(&invoice).unwrap();
935
936        for line in &je.lines {
937            assert!(line.auxiliary_account_number.is_none());
938            assert!(line.auxiliary_account_label.is_none());
939            assert!(line.lettrage.is_none());
940            assert!(line.lettrage_date.is_none());
941        }
942    }
943
944    #[test]
945    fn test_auxiliary_lookup_uses_gl_account_instead_of_partner_id() {
946        // When auxiliary lookup is populated, FEC auxiliary_account_number should
947        // use the framework-specific GL account instead of the raw partner ID.
948        let mut generator =
949            DocumentFlowJeGenerator::with_config_and_seed(DocumentFlowJeConfig::french_gaap(), 42);
950
951        let mut lookup = HashMap::new();
952        lookup.insert("V-001".to_string(), "4010001".to_string());
953        generator.set_auxiliary_account_lookup(lookup);
954
955        let invoice = create_test_vendor_invoice();
956        let je = generator.generate_from_vendor_invoice(&invoice).unwrap();
957
958        // AP line should use the auxiliary GL account from lookup, not "V-001"
959        assert_eq!(
960            je.lines[1].auxiliary_account_number.as_deref(),
961            Some("4010001"),
962            "AP line should use auxiliary GL account from lookup"
963        );
964        // Label should still be the partner ID (human-readable)
965        assert_eq!(
966            je.lines[1].auxiliary_account_label.as_deref(),
967            Some("V-001"),
968        );
969    }
970
971    #[test]
972    fn test_auxiliary_lookup_fallback_to_partner_id() {
973        // When the auxiliary lookup exists but doesn't contain the partner,
974        // should fall back to raw partner ID.
975        let mut generator =
976            DocumentFlowJeGenerator::with_config_and_seed(DocumentFlowJeConfig::french_gaap(), 42);
977
978        // Lookup has a different vendor, not V-001
979        let mut lookup = HashMap::new();
980        lookup.insert("V-999".to_string(), "4019999".to_string());
981        generator.set_auxiliary_account_lookup(lookup);
982
983        let invoice = create_test_vendor_invoice();
984        let je = generator.generate_from_vendor_invoice(&invoice).unwrap();
985
986        // V-001 not in lookup, so should fall back to raw partner ID
987        assert_eq!(
988            je.lines[1].auxiliary_account_number.as_deref(),
989            Some("V-001"),
990            "Should fall back to partner ID when not in lookup"
991        );
992    }
993
994    #[test]
995    fn test_auxiliary_lookup_works_for_customer_receipt() {
996        // Verify the lookup also works for O2C AR receipt lines.
997        let mut generator =
998            DocumentFlowJeGenerator::with_config_and_seed(DocumentFlowJeConfig::french_gaap(), 42);
999
1000        let mut lookup = HashMap::new();
1001        lookup.insert("C-001".to_string(), "4110001".to_string());
1002        generator.set_auxiliary_account_lookup(lookup);
1003
1004        let mut receipt = Payment::new_ar_receipt(
1005            "RCP-001".to_string(),
1006            "1000",
1007            "C-001",
1008            Decimal::from(3000),
1009            2024,
1010            3,
1011            NaiveDate::from_ymd_opt(2024, 3, 15).unwrap(),
1012            "JSMITH",
1013        );
1014        receipt.post("JSMITH", NaiveDate::from_ymd_opt(2024, 3, 15).unwrap());
1015
1016        let je = generator.generate_from_ar_receipt(&receipt).unwrap();
1017
1018        // AR line (line 2 = CR AR) should use the auxiliary GL account from lookup
1019        assert_eq!(
1020            je.lines[1].auxiliary_account_number.as_deref(),
1021            Some("4110001"),
1022            "AR line should use auxiliary GL account from lookup"
1023        );
1024    }
1025}