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