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 (net), DR Input VAT (tax), CR AP (gross)
9//! - Payment → DR AP, CR Cash
10//!
11//! # O2C Flow JE Mappings
12//! - Delivery → DR COGS, CR Inventory
13//! - CustomerInvoice → DR AR (gross), CR Revenue (net), CR VAT Payable (tax)
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, 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/// 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    /// VAT output (payable) account for O2C (default: 2110 from tax_accounts::VAT_PAYABLE)
50    pub vat_output_account: String,
51    /// VAT input (receivable) account for P2P (default: 1160 from tax_accounts::INPUT_VAT)
52    pub vat_input_account: String,
53    /// Whether to populate FEC auxiliary and lettrage fields on AP/AR lines.
54    /// Only relevant for French GAAP / FEC export.
55    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    /// Create a config for French GAAP (PCG) with FEC field population enabled.
77    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
111/// Generator for creating JEs from document flows.
112pub struct DocumentFlowJeGenerator {
113    config: DocumentFlowJeConfig,
114    uuid_factory: DeterministicUuidFactory,
115    /// Lookup map: partner_id → auxiliary GL account number.
116    /// When populated (from vendor/customer master data), `set_auxiliary_fields`
117    /// uses the framework-specific auxiliary account (e.g., PCG "4010001", SKR04 "33000001")
118    /// instead of the raw partner ID.
119    auxiliary_account_lookup: HashMap<String, String>,
120}
121
122impl DocumentFlowJeGenerator {
123    /// Create a new document flow JE generator with default config and seed 0.
124    pub fn new() -> Self {
125        Self::with_config_and_seed(DocumentFlowJeConfig::default(), 0)
126    }
127
128    /// Create with custom account configuration and seed.
129    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    /// Set the auxiliary account lookup map (partner_id → auxiliary GL account).
138    ///
139    /// When populated, FEC `auxiliary_account_number` fields will use the
140    /// framework-specific auxiliary GL account (e.g., PCG "4010001") instead
141    /// of the raw partner ID.
142    pub fn set_auxiliary_account_lookup(&mut self, lookup: HashMap<String, String>) {
143        self.auxiliary_account_lookup = lookup;
144    }
145
146    /// Build an account description lookup from the configured accounts.
147    fn account_description_map(&self) -> HashMap<String, String> {
148        let mut map = HashMap::new();
149        map.insert(
150            self.config.inventory_account.clone(),
151            "Inventory".to_string(),
152        );
153        map.insert(
154            self.config.gr_ir_clearing_account.clone(),
155            "GR/IR Clearing".to_string(),
156        );
157        map.insert(
158            self.config.ap_account.clone(),
159            "Accounts Payable".to_string(),
160        );
161        map.insert(
162            self.config.cash_account.clone(),
163            "Cash and Cash Equivalents".to_string(),
164        );
165        map.insert(
166            self.config.ar_account.clone(),
167            "Accounts Receivable".to_string(),
168        );
169        map.insert(
170            self.config.revenue_account.clone(),
171            "Product Revenue".to_string(),
172        );
173        map.insert(
174            self.config.cogs_account.clone(),
175            "Cost of Goods Sold".to_string(),
176        );
177        map.insert(
178            self.config.vat_output_account.clone(),
179            "VAT Payable".to_string(),
180        );
181        map.insert(
182            self.config.vat_input_account.clone(),
183            "Input VAT".to_string(),
184        );
185        map
186    }
187
188    /// Cost center pool used for expense account enrichment.
189    const COST_CENTER_POOL: &'static [&'static str] =
190        &["CC1000", "CC2000", "CC3000", "CC4000", "CC5000"];
191
192    /// Enrich journal entry line items with account descriptions, cost centers,
193    /// profit centers, value dates, line text, and assignment fields.
194    ///
195    /// Uses the configured accounts to derive descriptions, since the document
196    /// flow JE generator does not have access to the full chart of accounts.
197    fn enrich_line_items(&self, entry: &mut JournalEntry) {
198        let desc_map = self.account_description_map();
199        let posting_date = entry.header.posting_date;
200        let company_code = &entry.header.company_code;
201        let header_text = entry.header.header_text.clone();
202        let business_process = entry.header.business_process;
203
204        // Derive a deterministic index from document_id for cost center selection
205        let doc_id_bytes = entry.header.document_id.as_bytes();
206        let mut cc_seed: usize = 0;
207        for &b in doc_id_bytes {
208            cc_seed = cc_seed.wrapping_add(b as usize);
209        }
210
211        for (i, line) in entry.lines.iter_mut().enumerate() {
212            // 1. account_description from known accounts
213            if line.account_description.is_none() {
214                line.account_description = desc_map.get(&line.gl_account).cloned();
215            }
216
217            // 2. cost_center for expense accounts (5xxx/6xxx)
218            if line.cost_center.is_none() {
219                let first_char = line.gl_account.chars().next().unwrap_or('0');
220                if first_char == '5' || first_char == '6' {
221                    let idx = cc_seed.wrapping_add(i) % Self::COST_CENTER_POOL.len();
222                    line.cost_center = Some(Self::COST_CENTER_POOL[idx].to_string());
223                }
224            }
225
226            // 3. profit_center from company code + business process
227            if line.profit_center.is_none() {
228                let suffix = match business_process {
229                    Some(BusinessProcess::P2P) => "-P2P",
230                    Some(BusinessProcess::O2C) => "-O2C",
231                    _ => "",
232                };
233                line.profit_center = Some(format!("PC-{}{}", company_code, suffix));
234            }
235
236            // 4. line_text: fall back to header_text
237            if line.line_text.is_none() {
238                line.line_text = header_text.clone();
239            }
240
241            // 5. value_date for AR/AP accounts
242            if line.value_date.is_none()
243                && (line.gl_account == self.config.ar_account
244                    || line.gl_account == self.config.ap_account)
245            {
246                line.value_date = Some(posting_date);
247            }
248
249            // 6. assignment for AP/AR lines - extract partner ID from header text
250            if line.assignment.is_none()
251                && (line.gl_account == self.config.ap_account
252                    || line.gl_account == self.config.ar_account)
253            {
254                if let Some(ref ht) = header_text {
255                    if let Some(partner_part) = ht.rsplit(" - ").next() {
256                        line.assignment = Some(partner_part.to_string());
257                    }
258                }
259            }
260        }
261    }
262
263    /// Set auxiliary account fields on AP/AR lines when FEC population is enabled.
264    ///
265    /// Only sets the fields if `populate_fec_fields` is true and the line's
266    /// GL account matches the configured AP or AR control account.
267    ///
268    /// When an auxiliary account lookup is available, uses the framework-specific
269    /// auxiliary GL account (e.g., PCG "4010001", SKR04 "33000001") instead of
270    /// the raw partner ID.
271    fn set_auxiliary_fields(
272        &self,
273        line: &mut JournalEntryLine,
274        partner_id: &str,
275        partner_label: &str,
276    ) {
277        if !self.config.populate_fec_fields {
278            return;
279        }
280        if line.gl_account == self.config.ap_account || line.gl_account == self.config.ar_account {
281            // Prefer the framework-specific auxiliary GL account from the lookup map;
282            // fall back to the raw partner ID if not found.
283            let aux_account = self
284                .auxiliary_account_lookup
285                .get(partner_id)
286                .cloned()
287                .unwrap_or_else(|| partner_id.to_string());
288            line.auxiliary_account_number = Some(aux_account);
289            line.auxiliary_account_label = Some(partner_label.to_string());
290        }
291    }
292
293    /// Apply lettrage (matching) codes to all AP/AR lines in a set of entries.
294    ///
295    /// Only sets lettrage if `populate_fec_fields` is true. The lettrage code
296    /// is derived from the chain ID (e.g. PO or SO document ID) and the date
297    /// is typically the final payment's posting date.
298    fn apply_lettrage(
299        &self,
300        entries: &mut [JournalEntry],
301        chain_id: &str,
302        lettrage_date: NaiveDate,
303    ) {
304        if !self.config.populate_fec_fields {
305            return;
306        }
307        let code = format!("LTR-{}", &chain_id[..chain_id.len().min(8)]);
308        for entry in entries.iter_mut() {
309            for line in entry.lines.iter_mut() {
310                if line.gl_account == self.config.ap_account
311                    || line.gl_account == self.config.ar_account
312                {
313                    line.lettrage = Some(code.clone());
314                    line.lettrage_date = Some(lettrage_date);
315                }
316            }
317        }
318    }
319
320    /// Generate all JEs from a P2P document chain.
321    pub fn generate_from_p2p_chain(&mut self, chain: &P2PDocumentChain) -> Vec<JournalEntry> {
322        let mut entries = Vec::new();
323
324        // Generate JEs for goods receipts
325        for gr in &chain.goods_receipts {
326            if let Some(je) = self.generate_from_goods_receipt(gr) {
327                entries.push(je);
328            }
329        }
330
331        // Generate JE for vendor invoice
332        if let Some(ref invoice) = chain.vendor_invoice {
333            if let Some(je) = self.generate_from_vendor_invoice(invoice) {
334                entries.push(je);
335            }
336        }
337
338        // Generate JE for payment
339        if let Some(ref payment) = chain.payment {
340            if let Some(je) = self.generate_from_ap_payment(payment) {
341                entries.push(je);
342            }
343        }
344
345        // Generate JEs for remainder payments
346        for payment in &chain.remainder_payments {
347            if let Some(je) = self.generate_from_ap_payment(payment) {
348                entries.push(je);
349            }
350        }
351
352        // Apply lettrage on complete P2P chains (invoice + payment both present)
353        if self.config.populate_fec_fields && chain.is_complete {
354            if let Some(ref payment) = chain.payment {
355                let posting_date = payment
356                    .header
357                    .posting_date
358                    .unwrap_or(payment.header.document_date);
359                self.apply_lettrage(
360                    &mut entries,
361                    &chain.purchase_order.header.document_id,
362                    posting_date,
363                );
364            }
365        }
366
367        entries
368    }
369
370    /// Generate all JEs from an O2C document chain.
371    pub fn generate_from_o2c_chain(&mut self, chain: &O2CDocumentChain) -> Vec<JournalEntry> {
372        let mut entries = Vec::new();
373
374        // Generate JEs for deliveries
375        for delivery in &chain.deliveries {
376            if let Some(je) = self.generate_from_delivery(delivery) {
377                entries.push(je);
378            }
379        }
380
381        // Generate JE for customer invoice
382        if let Some(ref invoice) = chain.customer_invoice {
383            if let Some(je) = self.generate_from_customer_invoice(invoice) {
384                entries.push(je);
385            }
386        }
387
388        // Generate JE for customer receipt
389        if let Some(ref receipt) = chain.customer_receipt {
390            if let Some(je) = self.generate_from_ar_receipt(receipt) {
391                entries.push(je);
392            }
393        }
394
395        // Generate JEs for remainder receipts (follow-up to partial payments)
396        for receipt in &chain.remainder_receipts {
397            if let Some(je) = self.generate_from_ar_receipt(receipt) {
398                entries.push(je);
399            }
400        }
401
402        // Apply lettrage on complete O2C chains (invoice + receipt both present)
403        if self.config.populate_fec_fields && chain.customer_receipt.is_some() {
404            if let Some(ref receipt) = chain.customer_receipt {
405                let posting_date = receipt
406                    .header
407                    .posting_date
408                    .unwrap_or(receipt.header.document_date);
409                self.apply_lettrage(
410                    &mut entries,
411                    &chain.sales_order.header.document_id,
412                    posting_date,
413                );
414            }
415        }
416
417        entries
418    }
419
420    /// Generate JE from Goods Receipt.
421    /// DR Inventory, CR GR/IR Clearing
422    pub fn generate_from_goods_receipt(&mut self, gr: &GoodsReceipt) -> Option<JournalEntry> {
423        if gr.items.is_empty() {
424            return None;
425        }
426
427        let document_id = self.uuid_factory.next();
428
429        // Use the total_value from the GR, or calculate from line items
430        let total_amount = if gr.total_value > Decimal::ZERO {
431            gr.total_value
432        } else {
433            gr.items
434                .iter()
435                .map(|item| item.base.net_amount)
436                .sum::<Decimal>()
437        };
438
439        if total_amount == Decimal::ZERO {
440            return None;
441        }
442
443        // Use posting_date or fall back to document_date
444        let posting_date = gr.header.posting_date.unwrap_or(gr.header.document_date);
445
446        let mut header = JournalEntryHeader::with_deterministic_id(
447            gr.header.company_code.clone(),
448            posting_date,
449            document_id,
450        );
451        header.source = TransactionSource::Automated;
452        header.business_process = Some(BusinessProcess::P2P);
453        header.document_type = "WE".to_string();
454        header.reference = Some(format!("GR:{}", gr.header.document_id));
455        header.header_text = Some(format!(
456            "Goods Receipt {} - {}",
457            gr.header.document_id,
458            gr.vendor_id.as_deref().unwrap_or("Unknown")
459        ));
460
461        let mut entry = JournalEntry::new(header);
462
463        // DR Inventory
464        let debit_line = JournalEntryLine::debit(
465            entry.header.document_id,
466            1,
467            self.config.inventory_account.clone(),
468            total_amount,
469        );
470        entry.add_line(debit_line);
471
472        // CR GR/IR Clearing
473        let credit_line = JournalEntryLine::credit(
474            entry.header.document_id,
475            2,
476            self.config.gr_ir_clearing_account.clone(),
477            total_amount,
478        );
479        entry.add_line(credit_line);
480
481        self.enrich_line_items(&mut entry);
482        Some(entry)
483    }
484
485    /// Generate JE from Vendor Invoice.
486    ///
487    /// When the invoice carries tax (`tax_amount > 0`), the entry is split:
488    /// - DR GR/IR Clearing = net amount
489    /// - DR Input VAT      = tax amount
490    /// - CR AP              = gross (payable) amount
491    ///
492    /// When there is no tax, the original two-line entry is produced:
493    /// - DR GR/IR Clearing = payable amount
494    /// - CR AP              = payable amount
495    pub fn generate_from_vendor_invoice(
496        &mut self,
497        invoice: &VendorInvoice,
498    ) -> Option<JournalEntry> {
499        if invoice.payable_amount == Decimal::ZERO {
500            return None;
501        }
502
503        let document_id = self.uuid_factory.next();
504
505        // Use posting_date or fall back to document_date
506        let posting_date = invoice
507            .header
508            .posting_date
509            .unwrap_or(invoice.header.document_date);
510
511        let mut header = JournalEntryHeader::with_deterministic_id(
512            invoice.header.company_code.clone(),
513            posting_date,
514            document_id,
515        );
516        header.source = TransactionSource::Automated;
517        header.business_process = Some(BusinessProcess::P2P);
518        header.document_type = "KR".to_string();
519        header.reference = Some(format!("VI:{}", invoice.header.document_id));
520        header.header_text = Some(format!(
521            "Vendor Invoice {} - {}",
522            invoice.vendor_invoice_number, invoice.vendor_id
523        ));
524
525        let mut entry = JournalEntry::new(header);
526
527        let has_vat = invoice.tax_amount > Decimal::ZERO;
528        let clearing_amount = if has_vat {
529            invoice.net_amount
530        } else {
531            invoice.payable_amount
532        };
533
534        // DR GR/IR Clearing (net amount when VAT present, else payable)
535        let debit_line = JournalEntryLine::debit(
536            entry.header.document_id,
537            1,
538            self.config.gr_ir_clearing_account.clone(),
539            clearing_amount,
540        );
541        entry.add_line(debit_line);
542
543        // DR Input VAT (only when tax is non-zero)
544        if has_vat {
545            let vat_line = JournalEntryLine::debit(
546                entry.header.document_id,
547                2,
548                self.config.vat_input_account.clone(),
549                invoice.tax_amount,
550            );
551            entry.add_line(vat_line);
552        }
553
554        // CR Accounts Payable (gross / payable amount)
555        let mut credit_line = JournalEntryLine::credit(
556            entry.header.document_id,
557            if has_vat { 3 } else { 2 },
558            self.config.ap_account.clone(),
559            invoice.payable_amount,
560        );
561        self.set_auxiliary_fields(&mut credit_line, &invoice.vendor_id, &invoice.vendor_id);
562        entry.add_line(credit_line);
563
564        self.enrich_line_items(&mut entry);
565        Some(entry)
566    }
567
568    /// Generate JE from AP Payment.
569    /// DR AP, CR Cash
570    pub fn generate_from_ap_payment(&mut self, payment: &Payment) -> Option<JournalEntry> {
571        if payment.amount == Decimal::ZERO {
572            return None;
573        }
574
575        let document_id = self.uuid_factory.next();
576
577        // Use posting_date or fall back to document_date
578        let posting_date = payment
579            .header
580            .posting_date
581            .unwrap_or(payment.header.document_date);
582
583        let mut header = JournalEntryHeader::with_deterministic_id(
584            payment.header.company_code.clone(),
585            posting_date,
586            document_id,
587        );
588        header.source = TransactionSource::Automated;
589        header.business_process = Some(BusinessProcess::P2P);
590        header.document_type = "KZ".to_string();
591        header.reference = Some(format!("PAY:{}", payment.header.document_id));
592        header.header_text = Some(format!(
593            "Payment {} - {}",
594            payment.header.document_id, payment.business_partner_id
595        ));
596
597        let mut entry = JournalEntry::new(header);
598
599        // DR Accounts Payable
600        let mut debit_line = JournalEntryLine::debit(
601            entry.header.document_id,
602            1,
603            self.config.ap_account.clone(),
604            payment.amount,
605        );
606        self.set_auxiliary_fields(
607            &mut debit_line,
608            &payment.business_partner_id,
609            &payment.business_partner_id,
610        );
611        entry.add_line(debit_line);
612
613        // CR Cash/Bank
614        let credit_line = JournalEntryLine::credit(
615            entry.header.document_id,
616            2,
617            self.config.cash_account.clone(),
618            payment.amount,
619        );
620        entry.add_line(credit_line);
621
622        self.enrich_line_items(&mut entry);
623        Some(entry)
624    }
625
626    /// Generate JE from Delivery.
627    /// DR COGS, CR Inventory
628    pub fn generate_from_delivery(&mut self, delivery: &Delivery) -> Option<JournalEntry> {
629        if delivery.items.is_empty() {
630            return None;
631        }
632
633        let document_id = self.uuid_factory.next();
634
635        // Calculate total cost from line items
636        let total_cost = delivery
637            .items
638            .iter()
639            .map(|item| item.base.net_amount)
640            .sum::<Decimal>();
641
642        if total_cost == Decimal::ZERO {
643            return None;
644        }
645
646        // Use posting_date or fall back to document_date
647        let posting_date = delivery
648            .header
649            .posting_date
650            .unwrap_or(delivery.header.document_date);
651
652        let mut header = JournalEntryHeader::with_deterministic_id(
653            delivery.header.company_code.clone(),
654            posting_date,
655            document_id,
656        );
657        header.source = TransactionSource::Automated;
658        header.business_process = Some(BusinessProcess::O2C);
659        header.document_type = "WL".to_string();
660        header.reference = Some(format!("DEL:{}", delivery.header.document_id));
661        header.header_text = Some(format!(
662            "Delivery {} - {}",
663            delivery.header.document_id, delivery.customer_id
664        ));
665
666        let mut entry = JournalEntry::new(header);
667
668        // DR COGS
669        let debit_line = JournalEntryLine::debit(
670            entry.header.document_id,
671            1,
672            self.config.cogs_account.clone(),
673            total_cost,
674        );
675        entry.add_line(debit_line);
676
677        // CR Inventory
678        let credit_line = JournalEntryLine::credit(
679            entry.header.document_id,
680            2,
681            self.config.inventory_account.clone(),
682            total_cost,
683        );
684        entry.add_line(credit_line);
685
686        self.enrich_line_items(&mut entry);
687        Some(entry)
688    }
689
690    /// Generate JE from Customer Invoice.
691    ///
692    /// When the invoice carries tax (`total_tax_amount > 0`), the entry is split:
693    /// - DR AR          = gross amount
694    /// - CR Revenue     = net amount
695    /// - CR VAT Payable = tax amount
696    ///
697    /// When there is no tax, the original two-line entry is produced:
698    /// - DR AR      = gross amount
699    /// - CR Revenue = gross amount
700    pub fn generate_from_customer_invoice(
701        &mut self,
702        invoice: &CustomerInvoice,
703    ) -> Option<JournalEntry> {
704        if invoice.total_gross_amount == Decimal::ZERO {
705            return None;
706        }
707
708        let document_id = self.uuid_factory.next();
709
710        // Use posting_date or fall back to document_date
711        let posting_date = invoice
712            .header
713            .posting_date
714            .unwrap_or(invoice.header.document_date);
715
716        let mut header = JournalEntryHeader::with_deterministic_id(
717            invoice.header.company_code.clone(),
718            posting_date,
719            document_id,
720        );
721        header.source = TransactionSource::Automated;
722        header.business_process = Some(BusinessProcess::O2C);
723        header.document_type = "DR".to_string();
724        header.reference = Some(format!("CI:{}", invoice.header.document_id));
725        header.header_text = Some(format!(
726            "Customer Invoice {} - {}",
727            invoice.header.document_id, invoice.customer_id
728        ));
729
730        let mut entry = JournalEntry::new(header);
731
732        // DR Accounts Receivable (gross amount)
733        let mut debit_line = JournalEntryLine::debit(
734            entry.header.document_id,
735            1,
736            self.config.ar_account.clone(),
737            invoice.total_gross_amount,
738        );
739        self.set_auxiliary_fields(&mut debit_line, &invoice.customer_id, &invoice.customer_id);
740        entry.add_line(debit_line);
741
742        // CR Revenue (net amount when VAT present, else gross)
743        let revenue_amount = if invoice.total_tax_amount > Decimal::ZERO {
744            invoice.total_net_amount
745        } else {
746            invoice.total_gross_amount
747        };
748        let credit_line = JournalEntryLine::credit(
749            entry.header.document_id,
750            2,
751            self.config.revenue_account.clone(),
752            revenue_amount,
753        );
754        entry.add_line(credit_line);
755
756        // CR VAT Payable (only when tax is non-zero)
757        if invoice.total_tax_amount > Decimal::ZERO {
758            let vat_line = JournalEntryLine::credit(
759                entry.header.document_id,
760                3,
761                self.config.vat_output_account.clone(),
762                invoice.total_tax_amount,
763            );
764            entry.add_line(vat_line);
765        }
766
767        self.enrich_line_items(&mut entry);
768        Some(entry)
769    }
770
771    /// Generate JE from AR Receipt (Customer Payment).
772    /// DR Cash, CR AR
773    pub fn generate_from_ar_receipt(&mut self, payment: &Payment) -> Option<JournalEntry> {
774        if payment.amount == Decimal::ZERO {
775            return None;
776        }
777
778        let document_id = self.uuid_factory.next();
779
780        // Use posting_date or fall back to document_date
781        let posting_date = payment
782            .header
783            .posting_date
784            .unwrap_or(payment.header.document_date);
785
786        let mut header = JournalEntryHeader::with_deterministic_id(
787            payment.header.company_code.clone(),
788            posting_date,
789            document_id,
790        );
791        header.source = TransactionSource::Automated;
792        header.business_process = Some(BusinessProcess::O2C);
793        header.document_type = "DZ".to_string();
794        header.reference = Some(format!("RCP:{}", payment.header.document_id));
795        header.header_text = Some(format!(
796            "Customer Receipt {} - {}",
797            payment.header.document_id, payment.business_partner_id
798        ));
799
800        let mut entry = JournalEntry::new(header);
801
802        // DR Cash/Bank
803        let debit_line = JournalEntryLine::debit(
804            entry.header.document_id,
805            1,
806            self.config.cash_account.clone(),
807            payment.amount,
808        );
809        entry.add_line(debit_line);
810
811        // CR Accounts Receivable
812        let mut credit_line = JournalEntryLine::credit(
813            entry.header.document_id,
814            2,
815            self.config.ar_account.clone(),
816            payment.amount,
817        );
818        self.set_auxiliary_fields(
819            &mut credit_line,
820            &payment.business_partner_id,
821            &payment.business_partner_id,
822        );
823        entry.add_line(credit_line);
824
825        self.enrich_line_items(&mut entry);
826        Some(entry)
827    }
828}
829
830impl Default for DocumentFlowJeGenerator {
831    fn default() -> Self {
832        Self::new()
833    }
834}
835
836#[cfg(test)]
837#[allow(clippy::unwrap_used)]
838mod tests {
839    use super::*;
840    use chrono::NaiveDate;
841    use datasynth_core::models::documents::{GoodsReceiptItem, MovementType};
842
843    fn create_test_gr() -> GoodsReceipt {
844        let mut gr = GoodsReceipt::from_purchase_order(
845            "GR-001".to_string(),
846            "1000",
847            "PO-001",
848            "V-001",
849            "P1000",
850            "0001",
851            2024,
852            1,
853            NaiveDate::from_ymd_opt(2024, 1, 15).unwrap(),
854            "JSMITH",
855        );
856
857        let item = GoodsReceiptItem::from_po(
858            10,
859            "Test Material",
860            Decimal::from(100),
861            Decimal::from(50),
862            "PO-001",
863            10,
864        )
865        .with_movement_type(MovementType::GrForPo);
866
867        gr.add_item(item);
868        gr.post("JSMITH", NaiveDate::from_ymd_opt(2024, 1, 15).unwrap());
869
870        gr
871    }
872
873    fn create_test_vendor_invoice() -> VendorInvoice {
874        use datasynth_core::models::documents::VendorInvoiceItem;
875
876        let mut invoice = VendorInvoice::new(
877            "VI-001".to_string(),
878            "1000",
879            "V-001",
880            "INV-12345".to_string(),
881            2024,
882            1,
883            NaiveDate::from_ymd_opt(2024, 1, 20).unwrap(),
884            "JSMITH",
885        );
886
887        let item = VendorInvoiceItem::from_po_gr(
888            10,
889            "Test Material",
890            Decimal::from(100),
891            Decimal::from(50),
892            "PO-001",
893            10,
894            Some("GR-001".to_string()),
895            Some(10),
896        );
897
898        invoice.add_item(item);
899        invoice.post("JSMITH", NaiveDate::from_ymd_opt(2024, 1, 20).unwrap());
900
901        invoice
902    }
903
904    fn create_test_payment() -> Payment {
905        let mut payment = Payment::new_ap_payment(
906            "PAY-001".to_string(),
907            "1000",
908            "V-001",
909            Decimal::from(5000),
910            2024,
911            2,
912            NaiveDate::from_ymd_opt(2024, 2, 15).unwrap(),
913            "JSMITH",
914        );
915
916        payment.post("JSMITH", NaiveDate::from_ymd_opt(2024, 2, 15).unwrap());
917
918        payment
919    }
920
921    #[test]
922    fn test_generate_from_goods_receipt() {
923        let mut generator = DocumentFlowJeGenerator::new();
924        let gr = create_test_gr();
925
926        let je = generator.generate_from_goods_receipt(&gr);
927
928        assert!(je.is_some());
929        let je = je.unwrap();
930
931        // Should be balanced
932        assert!(je.is_balanced());
933
934        // Should have 2 lines
935        assert_eq!(je.line_count(), 2);
936
937        // DR should be inventory, CR should be GR/IR
938        assert!(je.total_debit() > Decimal::ZERO);
939        assert_eq!(je.total_debit(), je.total_credit());
940
941        // Should reference source document
942        assert!(je.header.reference.is_some());
943        assert!(je.header.reference.as_ref().unwrap().contains("GR:"));
944    }
945
946    #[test]
947    fn test_generate_from_vendor_invoice() {
948        let mut generator = DocumentFlowJeGenerator::new();
949        let invoice = create_test_vendor_invoice();
950
951        let je = generator.generate_from_vendor_invoice(&invoice);
952
953        assert!(je.is_some());
954        let je = je.unwrap();
955
956        assert!(je.is_balanced());
957        assert_eq!(je.line_count(), 2);
958        assert!(je.header.reference.as_ref().unwrap().contains("VI:"));
959    }
960
961    #[test]
962    fn test_generate_from_ap_payment() {
963        let mut generator = DocumentFlowJeGenerator::new();
964        let payment = create_test_payment();
965
966        let je = generator.generate_from_ap_payment(&payment);
967
968        assert!(je.is_some());
969        let je = je.unwrap();
970
971        assert!(je.is_balanced());
972        assert_eq!(je.line_count(), 2);
973        assert!(je.header.reference.as_ref().unwrap().contains("PAY:"));
974    }
975
976    #[test]
977    fn test_all_entries_are_balanced() {
978        let mut generator = DocumentFlowJeGenerator::new();
979
980        let gr = create_test_gr();
981        let invoice = create_test_vendor_invoice();
982        let payment = create_test_payment();
983
984        let entries = vec![
985            generator.generate_from_goods_receipt(&gr),
986            generator.generate_from_vendor_invoice(&invoice),
987            generator.generate_from_ap_payment(&payment),
988        ];
989
990        for entry in entries.into_iter().flatten() {
991            assert!(
992                entry.is_balanced(),
993                "Entry {} is not balanced",
994                entry.header.document_id
995            );
996        }
997    }
998
999    // ====================================================================
1000    // FEC compliance tests
1001    // ====================================================================
1002
1003    #[test]
1004    fn test_french_gaap_auxiliary_on_ap_ar_lines_only() {
1005        // French GAAP config sets auxiliary fields on AP/AR lines only
1006        let mut generator =
1007            DocumentFlowJeGenerator::with_config_and_seed(DocumentFlowJeConfig::french_gaap(), 42);
1008
1009        // Vendor invoice: AP line should have auxiliary, GR/IR line should not
1010        let invoice = create_test_vendor_invoice();
1011        let je = generator.generate_from_vendor_invoice(&invoice).unwrap();
1012
1013        // Line 1 = DR GR/IR Clearing → no auxiliary
1014        assert!(
1015            je.lines[0].auxiliary_account_number.is_none(),
1016            "GR/IR clearing line should not have auxiliary"
1017        );
1018
1019        // Line 2 = CR AP → has auxiliary
1020        assert_eq!(
1021            je.lines[1].auxiliary_account_number.as_deref(),
1022            Some("V-001"),
1023            "AP line should have vendor ID as auxiliary"
1024        );
1025        assert_eq!(
1026            je.lines[1].auxiliary_account_label.as_deref(),
1027            Some("V-001"),
1028        );
1029    }
1030
1031    #[test]
1032    fn test_french_gaap_lettrage_on_complete_p2p_chain() {
1033        use datasynth_core::models::documents::PurchaseOrder;
1034
1035        let mut generator =
1036            DocumentFlowJeGenerator::with_config_and_seed(DocumentFlowJeConfig::french_gaap(), 42);
1037
1038        let po = PurchaseOrder::new(
1039            "PO-001",
1040            "1000",
1041            "V-001",
1042            2024,
1043            1,
1044            NaiveDate::from_ymd_opt(2024, 1, 10).unwrap(),
1045            "JSMITH",
1046        );
1047
1048        let chain = P2PDocumentChain {
1049            purchase_order: po,
1050            goods_receipts: vec![create_test_gr()],
1051            vendor_invoice: Some(create_test_vendor_invoice()),
1052            payment: Some(create_test_payment()),
1053            remainder_payments: Vec::new(),
1054            is_complete: true,
1055            three_way_match_passed: true,
1056            payment_timing: None,
1057        };
1058
1059        let entries = generator.generate_from_p2p_chain(&chain);
1060        assert!(!entries.is_empty());
1061
1062        // All AP lines should share the same lettrage code
1063        let ap_account = &generator.config.ap_account;
1064        let mut lettrage_codes: Vec<&str> = Vec::new();
1065        for entry in &entries {
1066            for line in &entry.lines {
1067                if &line.gl_account == ap_account {
1068                    assert!(
1069                        line.lettrage.is_some(),
1070                        "AP line should have lettrage on complete chain"
1071                    );
1072                    assert!(line.lettrage_date.is_some());
1073                    lettrage_codes.push(line.lettrage.as_deref().unwrap());
1074                } else {
1075                    assert!(
1076                        line.lettrage.is_none(),
1077                        "Non-AP line should not have lettrage"
1078                    );
1079                }
1080            }
1081        }
1082
1083        // All AP lettrage codes should be the same
1084        assert!(!lettrage_codes.is_empty());
1085        assert!(
1086            lettrage_codes.iter().all(|c| *c == lettrage_codes[0]),
1087            "All AP lines should share the same lettrage code"
1088        );
1089        assert!(lettrage_codes[0].starts_with("LTR-"));
1090    }
1091
1092    #[test]
1093    fn test_incomplete_chain_has_no_lettrage() {
1094        use datasynth_core::models::documents::PurchaseOrder;
1095
1096        let mut generator =
1097            DocumentFlowJeGenerator::with_config_and_seed(DocumentFlowJeConfig::french_gaap(), 42);
1098
1099        let po = PurchaseOrder::new(
1100            "PO-002",
1101            "1000",
1102            "V-001",
1103            2024,
1104            1,
1105            NaiveDate::from_ymd_opt(2024, 1, 10).unwrap(),
1106            "JSMITH",
1107        );
1108
1109        // Incomplete chain: no payment
1110        let chain = P2PDocumentChain {
1111            purchase_order: po,
1112            goods_receipts: vec![create_test_gr()],
1113            vendor_invoice: Some(create_test_vendor_invoice()),
1114            payment: None,
1115            remainder_payments: Vec::new(),
1116            is_complete: false,
1117            three_way_match_passed: false,
1118            payment_timing: None,
1119        };
1120
1121        let entries = generator.generate_from_p2p_chain(&chain);
1122
1123        for entry in &entries {
1124            for line in &entry.lines {
1125                assert!(
1126                    line.lettrage.is_none(),
1127                    "Incomplete chain should have no lettrage"
1128                );
1129            }
1130        }
1131    }
1132
1133    #[test]
1134    fn test_default_config_no_fec_fields() {
1135        // Default config (non-French) should leave all FEC fields as None
1136        let mut generator = DocumentFlowJeGenerator::new();
1137
1138        let invoice = create_test_vendor_invoice();
1139        let je = generator.generate_from_vendor_invoice(&invoice).unwrap();
1140
1141        for line in &je.lines {
1142            assert!(line.auxiliary_account_number.is_none());
1143            assert!(line.auxiliary_account_label.is_none());
1144            assert!(line.lettrage.is_none());
1145            assert!(line.lettrage_date.is_none());
1146        }
1147    }
1148
1149    #[test]
1150    fn test_auxiliary_lookup_uses_gl_account_instead_of_partner_id() {
1151        // When auxiliary lookup is populated, FEC auxiliary_account_number should
1152        // use the framework-specific GL account instead of the raw partner ID.
1153        let mut generator =
1154            DocumentFlowJeGenerator::with_config_and_seed(DocumentFlowJeConfig::french_gaap(), 42);
1155
1156        let mut lookup = HashMap::new();
1157        lookup.insert("V-001".to_string(), "4010001".to_string());
1158        generator.set_auxiliary_account_lookup(lookup);
1159
1160        let invoice = create_test_vendor_invoice();
1161        let je = generator.generate_from_vendor_invoice(&invoice).unwrap();
1162
1163        // AP line should use the auxiliary GL account from lookup, not "V-001"
1164        assert_eq!(
1165            je.lines[1].auxiliary_account_number.as_deref(),
1166            Some("4010001"),
1167            "AP line should use auxiliary GL account from lookup"
1168        );
1169        // Label should still be the partner ID (human-readable)
1170        assert_eq!(
1171            je.lines[1].auxiliary_account_label.as_deref(),
1172            Some("V-001"),
1173        );
1174    }
1175
1176    #[test]
1177    fn test_auxiliary_lookup_fallback_to_partner_id() {
1178        // When the auxiliary lookup exists but doesn't contain the partner,
1179        // should fall back to raw partner ID.
1180        let mut generator =
1181            DocumentFlowJeGenerator::with_config_and_seed(DocumentFlowJeConfig::french_gaap(), 42);
1182
1183        // Lookup has a different vendor, not V-001
1184        let mut lookup = HashMap::new();
1185        lookup.insert("V-999".to_string(), "4019999".to_string());
1186        generator.set_auxiliary_account_lookup(lookup);
1187
1188        let invoice = create_test_vendor_invoice();
1189        let je = generator.generate_from_vendor_invoice(&invoice).unwrap();
1190
1191        // V-001 not in lookup, so should fall back to raw partner ID
1192        assert_eq!(
1193            je.lines[1].auxiliary_account_number.as_deref(),
1194            Some("V-001"),
1195            "Should fall back to partner ID when not in lookup"
1196        );
1197    }
1198
1199    #[test]
1200    fn test_auxiliary_lookup_works_for_customer_receipt() {
1201        // Verify the lookup also works for O2C AR receipt lines.
1202        let mut generator =
1203            DocumentFlowJeGenerator::with_config_and_seed(DocumentFlowJeConfig::french_gaap(), 42);
1204
1205        let mut lookup = HashMap::new();
1206        lookup.insert("C-001".to_string(), "4110001".to_string());
1207        generator.set_auxiliary_account_lookup(lookup);
1208
1209        let mut receipt = Payment::new_ar_receipt(
1210            "RCP-001".to_string(),
1211            "1000",
1212            "C-001",
1213            Decimal::from(3000),
1214            2024,
1215            3,
1216            NaiveDate::from_ymd_opt(2024, 3, 15).unwrap(),
1217            "JSMITH",
1218        );
1219        receipt.post("JSMITH", NaiveDate::from_ymd_opt(2024, 3, 15).unwrap());
1220
1221        let je = generator.generate_from_ar_receipt(&receipt).unwrap();
1222
1223        // AR line (line 2 = CR AR) should use the auxiliary GL account from lookup
1224        assert_eq!(
1225            je.lines[1].auxiliary_account_number.as_deref(),
1226            Some("4110001"),
1227            "AR line should use auxiliary GL account from lookup"
1228        );
1229    }
1230
1231    // ====================================================================
1232    // VAT / tax splitting tests
1233    // ====================================================================
1234
1235    /// Helper: create a customer invoice with tax on its line items.
1236    fn create_test_customer_invoice_with_tax() -> CustomerInvoice {
1237        use datasynth_core::models::documents::CustomerInvoiceItem;
1238
1239        let mut invoice = CustomerInvoice::new(
1240            "CI-001",
1241            "1000",
1242            "C-001",
1243            2024,
1244            1,
1245            NaiveDate::from_ymd_opt(2024, 1, 15).unwrap(),
1246            NaiveDate::from_ymd_opt(2024, 2, 14).unwrap(),
1247            "JSMITH",
1248        );
1249
1250        // 10 units * 100 = 1000 net, 100 tax => 1100 gross
1251        let mut item =
1252            CustomerInvoiceItem::new(1, "Product A", Decimal::from(10), Decimal::from(100));
1253        item.base.tax_amount = Decimal::from(100);
1254        invoice.add_item(item);
1255        invoice.post("JSMITH", NaiveDate::from_ymd_opt(2024, 1, 15).unwrap());
1256
1257        invoice
1258    }
1259
1260    /// Helper: create a customer invoice without any tax.
1261    fn create_test_customer_invoice_no_tax() -> CustomerInvoice {
1262        use datasynth_core::models::documents::CustomerInvoiceItem;
1263
1264        let mut invoice = CustomerInvoice::new(
1265            "CI-002",
1266            "1000",
1267            "C-002",
1268            2024,
1269            1,
1270            NaiveDate::from_ymd_opt(2024, 1, 15).unwrap(),
1271            NaiveDate::from_ymd_opt(2024, 2, 14).unwrap(),
1272            "JSMITH",
1273        );
1274
1275        let item = CustomerInvoiceItem::new(1, "Product B", Decimal::from(10), Decimal::from(100));
1276        invoice.add_item(item);
1277        invoice.post("JSMITH", NaiveDate::from_ymd_opt(2024, 1, 15).unwrap());
1278
1279        invoice
1280    }
1281
1282    /// Helper: create a vendor invoice with tax on its line items.
1283    fn create_test_vendor_invoice_with_tax() -> VendorInvoice {
1284        use datasynth_core::models::documents::VendorInvoiceItem;
1285
1286        let mut invoice = VendorInvoice::new(
1287            "VI-002".to_string(),
1288            "1000",
1289            "V-001",
1290            "INV-TAX-001".to_string(),
1291            2024,
1292            1,
1293            NaiveDate::from_ymd_opt(2024, 1, 20).unwrap(),
1294            "JSMITH",
1295        );
1296
1297        // 100 qty * 50 price = 5000 net, 500 tax => 5500 gross = payable
1298        let item = VendorInvoiceItem::from_po_gr(
1299            10,
1300            "Test Material",
1301            Decimal::from(100),
1302            Decimal::from(50),
1303            "PO-001",
1304            10,
1305            Some("GR-001".to_string()),
1306            Some(10),
1307        )
1308        .with_tax("VAT10", Decimal::from(500));
1309
1310        invoice.add_item(item);
1311        invoice.post("JSMITH", NaiveDate::from_ymd_opt(2024, 1, 20).unwrap());
1312
1313        invoice
1314    }
1315
1316    #[test]
1317    fn test_customer_invoice_with_tax_produces_three_lines() {
1318        let mut generator = DocumentFlowJeGenerator::new();
1319        let invoice = create_test_customer_invoice_with_tax();
1320
1321        assert_eq!(invoice.total_net_amount, Decimal::from(1000));
1322        assert_eq!(invoice.total_tax_amount, Decimal::from(100));
1323        assert_eq!(invoice.total_gross_amount, Decimal::from(1100));
1324
1325        let je = generator.generate_from_customer_invoice(&invoice).unwrap();
1326
1327        // Should have 3 lines: DR AR, CR Revenue, CR VAT
1328        assert_eq!(
1329            je.line_count(),
1330            3,
1331            "Expected 3 JE lines for invoice with tax"
1332        );
1333        assert!(je.is_balanced(), "Entry must be balanced");
1334
1335        // Line 1: DR AR = gross (1100)
1336        assert_eq!(je.lines[0].gl_account, control_accounts::AR_CONTROL);
1337        assert_eq!(je.lines[0].debit_amount, Decimal::from(1100));
1338        assert_eq!(je.lines[0].credit_amount, Decimal::ZERO);
1339
1340        // Line 2: CR Revenue = net (1000)
1341        assert_eq!(je.lines[1].gl_account, revenue_accounts::PRODUCT_REVENUE);
1342        assert_eq!(je.lines[1].credit_amount, Decimal::from(1000));
1343        assert_eq!(je.lines[1].debit_amount, Decimal::ZERO);
1344
1345        // Line 3: CR VAT Payable = tax (100)
1346        assert_eq!(je.lines[2].gl_account, tax_accounts::VAT_PAYABLE);
1347        assert_eq!(je.lines[2].credit_amount, Decimal::from(100));
1348        assert_eq!(je.lines[2].debit_amount, Decimal::ZERO);
1349    }
1350
1351    #[test]
1352    fn test_customer_invoice_no_tax_produces_two_lines() {
1353        let mut generator = DocumentFlowJeGenerator::new();
1354        let invoice = create_test_customer_invoice_no_tax();
1355
1356        assert_eq!(invoice.total_tax_amount, Decimal::ZERO);
1357        assert_eq!(invoice.total_net_amount, Decimal::from(1000));
1358        assert_eq!(invoice.total_gross_amount, Decimal::from(1000));
1359
1360        let je = generator.generate_from_customer_invoice(&invoice).unwrap();
1361
1362        // Should have 2 lines (no VAT line)
1363        assert_eq!(
1364            je.line_count(),
1365            2,
1366            "Expected 2 JE lines for invoice without tax"
1367        );
1368        assert!(je.is_balanced(), "Entry must be balanced");
1369
1370        // Line 1: DR AR = gross (1000)
1371        assert_eq!(je.lines[0].gl_account, control_accounts::AR_CONTROL);
1372        assert_eq!(je.lines[0].debit_amount, Decimal::from(1000));
1373
1374        // Line 2: CR Revenue = gross (1000)  — same as gross when no tax
1375        assert_eq!(je.lines[1].gl_account, revenue_accounts::PRODUCT_REVENUE);
1376        assert_eq!(je.lines[1].credit_amount, Decimal::from(1000));
1377    }
1378
1379    #[test]
1380    fn test_vendor_invoice_with_tax_produces_three_lines() {
1381        let mut generator = DocumentFlowJeGenerator::new();
1382        let invoice = create_test_vendor_invoice_with_tax();
1383
1384        assert_eq!(invoice.net_amount, Decimal::from(5000));
1385        assert_eq!(invoice.tax_amount, Decimal::from(500));
1386        assert_eq!(invoice.gross_amount, Decimal::from(5500));
1387        assert_eq!(invoice.payable_amount, Decimal::from(5500));
1388
1389        let je = generator.generate_from_vendor_invoice(&invoice).unwrap();
1390
1391        // Should have 3 lines: DR GR/IR, DR Input VAT, CR AP
1392        assert_eq!(
1393            je.line_count(),
1394            3,
1395            "Expected 3 JE lines for vendor invoice with tax"
1396        );
1397        assert!(je.is_balanced(), "Entry must be balanced");
1398
1399        // Line 1: DR GR/IR Clearing = net (5000)
1400        assert_eq!(je.lines[0].gl_account, control_accounts::GR_IR_CLEARING);
1401        assert_eq!(je.lines[0].debit_amount, Decimal::from(5000));
1402        assert_eq!(je.lines[0].credit_amount, Decimal::ZERO);
1403
1404        // Line 2: DR Input VAT = tax (500)
1405        assert_eq!(je.lines[1].gl_account, tax_accounts::INPUT_VAT);
1406        assert_eq!(je.lines[1].debit_amount, Decimal::from(500));
1407        assert_eq!(je.lines[1].credit_amount, Decimal::ZERO);
1408
1409        // Line 3: CR AP = gross (5500)
1410        assert_eq!(je.lines[2].gl_account, control_accounts::AP_CONTROL);
1411        assert_eq!(je.lines[2].credit_amount, Decimal::from(5500));
1412        assert_eq!(je.lines[2].debit_amount, Decimal::ZERO);
1413    }
1414
1415    #[test]
1416    fn test_vendor_invoice_no_tax_produces_two_lines() {
1417        // The existing create_test_vendor_invoice() has no tax
1418        let mut generator = DocumentFlowJeGenerator::new();
1419        let invoice = create_test_vendor_invoice();
1420
1421        assert_eq!(invoice.tax_amount, Decimal::ZERO);
1422
1423        let je = generator.generate_from_vendor_invoice(&invoice).unwrap();
1424
1425        // Should have 2 lines (unchanged behavior)
1426        assert_eq!(
1427            je.line_count(),
1428            2,
1429            "Expected 2 JE lines for vendor invoice without tax"
1430        );
1431        assert!(je.is_balanced(), "Entry must be balanced");
1432
1433        // Line 1: DR GR/IR Clearing = payable
1434        assert_eq!(je.lines[0].gl_account, control_accounts::GR_IR_CLEARING);
1435        assert_eq!(je.lines[0].debit_amount, invoice.payable_amount);
1436
1437        // Line 2: CR AP = payable
1438        assert_eq!(je.lines[1].gl_account, control_accounts::AP_CONTROL);
1439        assert_eq!(je.lines[1].credit_amount, invoice.payable_amount);
1440    }
1441
1442    #[test]
1443    fn test_vat_accounts_configurable() {
1444        // Verify that VAT accounts can be customized via config
1445        let mut config = DocumentFlowJeConfig::default();
1446        config.vat_output_account = "2999".to_string();
1447        config.vat_input_account = "1999".to_string();
1448
1449        let mut generator = DocumentFlowJeGenerator::with_config_and_seed(config, 42);
1450
1451        // Customer invoice with tax
1452        let ci = create_test_customer_invoice_with_tax();
1453        let je = generator.generate_from_customer_invoice(&ci).unwrap();
1454        assert_eq!(
1455            je.lines[2].gl_account, "2999",
1456            "VAT output account should be configurable"
1457        );
1458
1459        // Vendor invoice with tax
1460        let vi = create_test_vendor_invoice_with_tax();
1461        let je = generator.generate_from_vendor_invoice(&vi).unwrap();
1462        assert_eq!(
1463            je.lines[1].gl_account, "1999",
1464            "VAT input account should be configurable"
1465        );
1466    }
1467
1468    #[test]
1469    fn test_vat_entries_from_framework_accounts() {
1470        // FrameworkAccounts should propagate VAT accounts into DocumentFlowJeConfig
1471        let fa = datasynth_core::FrameworkAccounts::us_gaap();
1472        let config = DocumentFlowJeConfig::from(&fa);
1473
1474        assert_eq!(config.vat_output_account, tax_accounts::VAT_PAYABLE);
1475        assert_eq!(config.vat_input_account, tax_accounts::INPUT_VAT);
1476
1477        let fa_fr = datasynth_core::FrameworkAccounts::french_gaap();
1478        let config_fr = DocumentFlowJeConfig::from(&fa_fr);
1479
1480        assert_eq!(config_fr.vat_output_account, "445710");
1481        assert_eq!(config_fr.vat_input_account, "445660");
1482    }
1483
1484    #[test]
1485    fn test_french_gaap_vat_accounts() {
1486        let config = DocumentFlowJeConfig::french_gaap();
1487        assert_eq!(config.vat_output_account, "445710"); // PCG OUTPUT_VAT
1488        assert_eq!(config.vat_input_account, "445660"); // PCG INPUT_VAT
1489    }
1490
1491    #[test]
1492    fn test_vat_balanced_with_multiple_items() {
1493        // Multiple line items with different tax amounts must still balance
1494        use datasynth_core::models::documents::CustomerInvoiceItem;
1495
1496        let mut invoice = CustomerInvoice::new(
1497            "CI-003",
1498            "1000",
1499            "C-003",
1500            2024,
1501            1,
1502            NaiveDate::from_ymd_opt(2024, 1, 15).unwrap(),
1503            NaiveDate::from_ymd_opt(2024, 2, 14).unwrap(),
1504            "JSMITH",
1505        );
1506
1507        // Item 1: 500 net, 50 tax
1508        let mut item1 = CustomerInvoiceItem::new(1, "A", Decimal::from(5), Decimal::from(100));
1509        item1.base.tax_amount = Decimal::from(50);
1510        invoice.add_item(item1);
1511
1512        // Item 2: 300 net, 30 tax
1513        let mut item2 = CustomerInvoiceItem::new(2, "B", Decimal::from(3), Decimal::from(100));
1514        item2.base.tax_amount = Decimal::from(30);
1515        invoice.add_item(item2);
1516
1517        invoice.post("JSMITH", NaiveDate::from_ymd_opt(2024, 1, 15).unwrap());
1518
1519        // net=800, tax=80, gross=880
1520        assert_eq!(invoice.total_net_amount, Decimal::from(800));
1521        assert_eq!(invoice.total_tax_amount, Decimal::from(80));
1522        assert_eq!(invoice.total_gross_amount, Decimal::from(880));
1523
1524        let mut generator = DocumentFlowJeGenerator::new();
1525        let je = generator.generate_from_customer_invoice(&invoice).unwrap();
1526
1527        assert_eq!(je.line_count(), 3);
1528        assert!(je.is_balanced());
1529        assert_eq!(je.total_debit(), Decimal::from(880));
1530        assert_eq!(je.total_credit(), Decimal::from(880));
1531    }
1532
1533    #[test]
1534    fn test_document_types_per_source_document() {
1535        let mut generator = DocumentFlowJeGenerator::new();
1536
1537        let gr = create_test_gr();
1538        let invoice = create_test_vendor_invoice();
1539        let payment = create_test_payment();
1540
1541        let gr_je = generator.generate_from_goods_receipt(&gr).unwrap();
1542        assert_eq!(
1543            gr_je.header.document_type, "WE",
1544            "Goods receipt should be WE"
1545        );
1546
1547        let vi_je = generator.generate_from_vendor_invoice(&invoice).unwrap();
1548        assert_eq!(
1549            vi_je.header.document_type, "KR",
1550            "Vendor invoice should be KR"
1551        );
1552
1553        let pay_je = generator.generate_from_ap_payment(&payment).unwrap();
1554        assert_eq!(pay_je.header.document_type, "KZ", "AP payment should be KZ");
1555
1556        // Collect distinct document types
1557        let types: std::collections::HashSet<&str> = [
1558            gr_je.header.document_type.as_str(),
1559            vi_je.header.document_type.as_str(),
1560            pay_je.header.document_type.as_str(),
1561        ]
1562        .into_iter()
1563        .collect();
1564
1565        assert!(
1566            types.len() >= 3,
1567            "Expected at least 3 distinct document types from P2P flow, got {:?}",
1568            types,
1569        );
1570    }
1571
1572    #[test]
1573    fn test_enrichment_account_descriptions_populated() {
1574        let mut generator = DocumentFlowJeGenerator::new();
1575        let gr = create_test_gr();
1576        let invoice = create_test_vendor_invoice();
1577        let payment = create_test_payment();
1578
1579        let gr_je = generator.generate_from_goods_receipt(&gr).unwrap();
1580        let vi_je = generator.generate_from_vendor_invoice(&invoice).unwrap();
1581        let pay_je = generator.generate_from_ap_payment(&payment).unwrap();
1582
1583        // All lines in all JEs should have account descriptions
1584        for je in [&gr_je, &vi_je, &pay_je] {
1585            for line in &je.lines {
1586                assert!(
1587                    line.account_description.is_some(),
1588                    "Line for account {} should have description, entry doc {}",
1589                    line.gl_account,
1590                    je.header.document_id,
1591                );
1592            }
1593        }
1594
1595        // GR JE: Inventory and GR/IR Clearing
1596        assert_eq!(
1597            gr_je.lines[0].account_description.as_deref(),
1598            Some("Inventory"),
1599        );
1600        assert_eq!(
1601            gr_je.lines[1].account_description.as_deref(),
1602            Some("GR/IR Clearing"),
1603        );
1604    }
1605
1606    #[test]
1607    fn test_enrichment_profit_center_and_line_text() {
1608        let mut generator = DocumentFlowJeGenerator::new();
1609        let gr = create_test_gr();
1610
1611        let je = generator.generate_from_goods_receipt(&gr).unwrap();
1612
1613        for line in &je.lines {
1614            // All lines should have profit_center
1615            assert!(
1616                line.profit_center.is_some(),
1617                "Line {} should have profit_center",
1618                line.gl_account,
1619            );
1620            let pc = line.profit_center.as_ref().unwrap();
1621            assert!(
1622                pc.starts_with("PC-"),
1623                "Profit center should start with PC-, got {}",
1624                pc,
1625            );
1626
1627            // All lines should have line_text (from header fallback)
1628            assert!(
1629                line.line_text.is_some(),
1630                "Line {} should have line_text",
1631                line.gl_account,
1632            );
1633        }
1634    }
1635
1636    #[test]
1637    fn test_enrichment_cost_center_for_expense_accounts() {
1638        let mut generator = DocumentFlowJeGenerator::new();
1639
1640        // Create a delivery which produces COGS (5000) entries
1641        use datasynth_core::models::documents::{Delivery, DeliveryItem};
1642        let mut delivery = Delivery::new(
1643            "DEL-001".to_string(),
1644            "1000",
1645            "SO-001",
1646            "C-001",
1647            2024,
1648            1,
1649            NaiveDate::from_ymd_opt(2024, 1, 15).unwrap(),
1650            "JSMITH",
1651        );
1652        let item = DeliveryItem::from_sales_order(
1653            10,
1654            "Test Material",
1655            Decimal::from(100),
1656            Decimal::from(50),
1657            "SO-001",
1658            10,
1659        );
1660        delivery.add_item(item);
1661        delivery.post_goods_issue("JSMITH", NaiveDate::from_ymd_opt(2024, 1, 15).unwrap());
1662
1663        let je = generator.generate_from_delivery(&delivery).unwrap();
1664
1665        // COGS line (5000) should have cost_center
1666        let cogs_line = je.lines.iter().find(|l| l.gl_account == "5000").unwrap();
1667        assert!(
1668            cogs_line.cost_center.is_some(),
1669            "COGS line should have cost_center assigned",
1670        );
1671        let cc = cogs_line.cost_center.as_ref().unwrap();
1672        assert!(
1673            cc.starts_with("CC"),
1674            "Cost center should start with CC, got {}",
1675            cc,
1676        );
1677
1678        // Inventory line (1200) should NOT have cost_center
1679        let inv_line = je.lines.iter().find(|l| l.gl_account == "1200").unwrap();
1680        assert!(
1681            inv_line.cost_center.is_none(),
1682            "Non-expense line should not have cost_center",
1683        );
1684    }
1685
1686    #[test]
1687    fn test_enrichment_value_date_for_ap_ar() {
1688        let mut generator = DocumentFlowJeGenerator::new();
1689
1690        let invoice = create_test_vendor_invoice();
1691        let je = generator.generate_from_vendor_invoice(&invoice).unwrap();
1692
1693        // AP line should have value_date
1694        let ap_line = je.lines.iter().find(|l| l.gl_account == "2000").unwrap();
1695        assert!(
1696            ap_line.value_date.is_some(),
1697            "AP line should have value_date set",
1698        );
1699        assert_eq!(ap_line.value_date, Some(je.header.posting_date));
1700
1701        // GR/IR clearing line should NOT have value_date
1702        let clearing_line = je.lines.iter().find(|l| l.gl_account == "2900").unwrap();
1703        assert!(
1704            clearing_line.value_date.is_none(),
1705            "Non-AP/AR line should not have value_date",
1706        );
1707    }
1708}