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