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