Skip to main content

datasynth_generators/document_flow/
document_flow_je_generator.rs

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