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, CR AP
9//! - Payment → DR AP, CR Cash
10//!
11//! # O2C Flow JE Mappings
12//! - Delivery → DR COGS, CR Inventory
13//! - CustomerInvoice → DR AR, CR Revenue
14//! - CustomerReceipt → DR Cash, CR AR
15
16use rust_decimal::Decimal;
17
18use datasynth_core::models::{
19    documents::{CustomerInvoice, Delivery, GoodsReceipt, Payment, VendorInvoice},
20    BusinessProcess, JournalEntry, JournalEntryHeader, JournalEntryLine, TransactionSource,
21};
22use datasynth_core::uuid_factory::{DeterministicUuidFactory, GeneratorType};
23
24use super::{O2CDocumentChain, P2PDocumentChain};
25
26/// Configuration for document flow JE generation.
27#[derive(Debug, Clone)]
28pub struct DocumentFlowJeConfig {
29    /// Inventory account (default: 140000)
30    pub inventory_account: String,
31    /// GR/IR clearing account (default: 290000)
32    pub gr_ir_clearing_account: String,
33    /// Accounts payable control account (default: 210000)
34    pub ap_account: String,
35    /// Cash/bank account (default: 110000)
36    pub cash_account: String,
37    /// Accounts receivable control account (default: 120000)
38    pub ar_account: String,
39    /// Revenue account (default: 400000)
40    pub revenue_account: String,
41    /// COGS account (default: 500000)
42    pub cogs_account: String,
43}
44
45impl Default for DocumentFlowJeConfig {
46    fn default() -> Self {
47        Self {
48            inventory_account: "140000".to_string(),
49            gr_ir_clearing_account: "290000".to_string(),
50            ap_account: "210000".to_string(),
51            cash_account: "110000".to_string(),
52            ar_account: "120000".to_string(),
53            revenue_account: "400000".to_string(),
54            cogs_account: "500000".to_string(),
55        }
56    }
57}
58
59/// Generator for creating JEs from document flows.
60pub struct DocumentFlowJeGenerator {
61    config: DocumentFlowJeConfig,
62    uuid_factory: DeterministicUuidFactory,
63}
64
65impl DocumentFlowJeGenerator {
66    /// Create a new document flow JE generator.
67    pub fn new() -> Self {
68        Self::with_config(DocumentFlowJeConfig::default())
69    }
70
71    /// Create with custom account configuration.
72    pub fn with_config(config: DocumentFlowJeConfig) -> Self {
73        // Use a fixed seed for document flow JE generator (can be made configurable)
74        Self::with_config_and_seed(config, 0)
75    }
76
77    /// Create with custom account configuration and seed.
78    pub fn with_config_and_seed(config: DocumentFlowJeConfig, seed: u64) -> Self {
79        Self {
80            config,
81            uuid_factory: DeterministicUuidFactory::new(seed, GeneratorType::DocumentFlow),
82        }
83    }
84
85    /// Generate all JEs from a P2P document chain.
86    pub fn generate_from_p2p_chain(&mut self, chain: &P2PDocumentChain) -> Vec<JournalEntry> {
87        let mut entries = Vec::new();
88
89        // Generate JEs for goods receipts
90        for gr in &chain.goods_receipts {
91            if let Some(je) = self.generate_from_goods_receipt(gr) {
92                entries.push(je);
93            }
94        }
95
96        // Generate JE for vendor invoice
97        if let Some(ref invoice) = chain.vendor_invoice {
98            if let Some(je) = self.generate_from_vendor_invoice(invoice) {
99                entries.push(je);
100            }
101        }
102
103        // Generate JE for payment
104        if let Some(ref payment) = chain.payment {
105            if let Some(je) = self.generate_from_ap_payment(payment) {
106                entries.push(je);
107            }
108        }
109
110        entries
111    }
112
113    /// Generate all JEs from an O2C document chain.
114    pub fn generate_from_o2c_chain(&mut self, chain: &O2CDocumentChain) -> Vec<JournalEntry> {
115        let mut entries = Vec::new();
116
117        // Generate JEs for deliveries
118        for delivery in &chain.deliveries {
119            if let Some(je) = self.generate_from_delivery(delivery) {
120                entries.push(je);
121            }
122        }
123
124        // Generate JE for customer invoice
125        if let Some(ref invoice) = chain.customer_invoice {
126            if let Some(je) = self.generate_from_customer_invoice(invoice) {
127                entries.push(je);
128            }
129        }
130
131        // Generate JE for customer receipt
132        if let Some(ref receipt) = chain.customer_receipt {
133            if let Some(je) = self.generate_from_ar_receipt(receipt) {
134                entries.push(je);
135            }
136        }
137
138        entries
139    }
140
141    /// Generate JE from Goods Receipt.
142    /// DR Inventory, CR GR/IR Clearing
143    pub fn generate_from_goods_receipt(&mut self, gr: &GoodsReceipt) -> Option<JournalEntry> {
144        if gr.items.is_empty() {
145            return None;
146        }
147
148        let document_id = self.uuid_factory.next();
149
150        // Use the total_value from the GR, or calculate from line items
151        let total_amount = if gr.total_value > Decimal::ZERO {
152            gr.total_value
153        } else {
154            gr.items
155                .iter()
156                .map(|item| item.base.net_amount)
157                .sum::<Decimal>()
158        };
159
160        if total_amount == Decimal::ZERO {
161            return None;
162        }
163
164        // Use posting_date or fall back to document_date
165        let posting_date = gr.header.posting_date.unwrap_or(gr.header.document_date);
166
167        let mut header = JournalEntryHeader::with_deterministic_id(
168            gr.header.company_code.clone(),
169            posting_date,
170            document_id,
171        );
172        header.source = TransactionSource::Automated;
173        header.business_process = Some(BusinessProcess::P2P);
174        header.reference = Some(format!("GR:{}", gr.header.document_id));
175        header.header_text = Some(format!(
176            "Goods Receipt {} - {}",
177            gr.header.document_id,
178            gr.vendor_id.as_deref().unwrap_or("Unknown")
179        ));
180
181        let mut entry = JournalEntry::new(header);
182
183        // DR Inventory
184        let debit_line = JournalEntryLine::debit(
185            entry.header.document_id,
186            1,
187            self.config.inventory_account.clone(),
188            total_amount,
189        );
190        entry.add_line(debit_line);
191
192        // CR GR/IR Clearing
193        let credit_line = JournalEntryLine::credit(
194            entry.header.document_id,
195            2,
196            self.config.gr_ir_clearing_account.clone(),
197            total_amount,
198        );
199        entry.add_line(credit_line);
200
201        Some(entry)
202    }
203
204    /// Generate JE from Vendor Invoice.
205    /// DR GR/IR Clearing, CR AP
206    pub fn generate_from_vendor_invoice(
207        &mut self,
208        invoice: &VendorInvoice,
209    ) -> Option<JournalEntry> {
210        if invoice.payable_amount == Decimal::ZERO {
211            return None;
212        }
213
214        let document_id = self.uuid_factory.next();
215
216        // Use posting_date or fall back to document_date
217        let posting_date = invoice
218            .header
219            .posting_date
220            .unwrap_or(invoice.header.document_date);
221
222        let mut header = JournalEntryHeader::with_deterministic_id(
223            invoice.header.company_code.clone(),
224            posting_date,
225            document_id,
226        );
227        header.source = TransactionSource::Automated;
228        header.business_process = Some(BusinessProcess::P2P);
229        header.reference = Some(format!("VI:{}", invoice.header.document_id));
230        header.header_text = Some(format!(
231            "Vendor Invoice {} - {}",
232            invoice.vendor_invoice_number, invoice.vendor_id
233        ));
234
235        let mut entry = JournalEntry::new(header);
236
237        // DR GR/IR Clearing (or expense if no PO)
238        let debit_line = JournalEntryLine::debit(
239            entry.header.document_id,
240            1,
241            self.config.gr_ir_clearing_account.clone(),
242            invoice.payable_amount,
243        );
244        entry.add_line(debit_line);
245
246        // CR Accounts Payable
247        let credit_line = JournalEntryLine::credit(
248            entry.header.document_id,
249            2,
250            self.config.ap_account.clone(),
251            invoice.payable_amount,
252        );
253        entry.add_line(credit_line);
254
255        Some(entry)
256    }
257
258    /// Generate JE from AP Payment.
259    /// DR AP, CR Cash
260    pub fn generate_from_ap_payment(&mut self, payment: &Payment) -> Option<JournalEntry> {
261        if payment.amount == Decimal::ZERO {
262            return None;
263        }
264
265        let document_id = self.uuid_factory.next();
266
267        // Use posting_date or fall back to document_date
268        let posting_date = payment
269            .header
270            .posting_date
271            .unwrap_or(payment.header.document_date);
272
273        let mut header = JournalEntryHeader::with_deterministic_id(
274            payment.header.company_code.clone(),
275            posting_date,
276            document_id,
277        );
278        header.source = TransactionSource::Automated;
279        header.business_process = Some(BusinessProcess::P2P);
280        header.reference = Some(format!("PAY:{}", payment.header.document_id));
281        header.header_text = Some(format!(
282            "Payment {} - {}",
283            payment.header.document_id, payment.business_partner_id
284        ));
285
286        let mut entry = JournalEntry::new(header);
287
288        // DR Accounts Payable
289        let debit_line = JournalEntryLine::debit(
290            entry.header.document_id,
291            1,
292            self.config.ap_account.clone(),
293            payment.amount,
294        );
295        entry.add_line(debit_line);
296
297        // CR Cash/Bank
298        let credit_line = JournalEntryLine::credit(
299            entry.header.document_id,
300            2,
301            self.config.cash_account.clone(),
302            payment.amount,
303        );
304        entry.add_line(credit_line);
305
306        Some(entry)
307    }
308
309    /// Generate JE from Delivery.
310    /// DR COGS, CR Inventory
311    pub fn generate_from_delivery(&mut self, delivery: &Delivery) -> Option<JournalEntry> {
312        if delivery.items.is_empty() {
313            return None;
314        }
315
316        let document_id = self.uuid_factory.next();
317
318        // Calculate total cost from line items
319        let total_cost = delivery
320            .items
321            .iter()
322            .map(|item| item.base.net_amount)
323            .sum::<Decimal>();
324
325        if total_cost == Decimal::ZERO {
326            return None;
327        }
328
329        // Use posting_date or fall back to document_date
330        let posting_date = delivery
331            .header
332            .posting_date
333            .unwrap_or(delivery.header.document_date);
334
335        let mut header = JournalEntryHeader::with_deterministic_id(
336            delivery.header.company_code.clone(),
337            posting_date,
338            document_id,
339        );
340        header.source = TransactionSource::Automated;
341        header.business_process = Some(BusinessProcess::O2C);
342        header.reference = Some(format!("DEL:{}", delivery.header.document_id));
343        header.header_text = Some(format!(
344            "Delivery {} - {}",
345            delivery.header.document_id, delivery.customer_id
346        ));
347
348        let mut entry = JournalEntry::new(header);
349
350        // DR COGS
351        let debit_line = JournalEntryLine::debit(
352            entry.header.document_id,
353            1,
354            self.config.cogs_account.clone(),
355            total_cost,
356        );
357        entry.add_line(debit_line);
358
359        // CR Inventory
360        let credit_line = JournalEntryLine::credit(
361            entry.header.document_id,
362            2,
363            self.config.inventory_account.clone(),
364            total_cost,
365        );
366        entry.add_line(credit_line);
367
368        Some(entry)
369    }
370
371    /// Generate JE from Customer Invoice.
372    /// DR AR, CR Revenue
373    pub fn generate_from_customer_invoice(
374        &mut self,
375        invoice: &CustomerInvoice,
376    ) -> Option<JournalEntry> {
377        if invoice.total_gross_amount == Decimal::ZERO {
378            return None;
379        }
380
381        let document_id = self.uuid_factory.next();
382
383        // Use posting_date or fall back to document_date
384        let posting_date = invoice
385            .header
386            .posting_date
387            .unwrap_or(invoice.header.document_date);
388
389        let mut header = JournalEntryHeader::with_deterministic_id(
390            invoice.header.company_code.clone(),
391            posting_date,
392            document_id,
393        );
394        header.source = TransactionSource::Automated;
395        header.business_process = Some(BusinessProcess::O2C);
396        header.reference = Some(format!("CI:{}", invoice.header.document_id));
397        header.header_text = Some(format!(
398            "Customer Invoice {} - {}",
399            invoice.header.document_id, invoice.customer_id
400        ));
401
402        let mut entry = JournalEntry::new(header);
403
404        // DR Accounts Receivable
405        let debit_line = JournalEntryLine::debit(
406            entry.header.document_id,
407            1,
408            self.config.ar_account.clone(),
409            invoice.total_gross_amount,
410        );
411        entry.add_line(debit_line);
412
413        // CR Revenue
414        let credit_line = JournalEntryLine::credit(
415            entry.header.document_id,
416            2,
417            self.config.revenue_account.clone(),
418            invoice.total_gross_amount,
419        );
420        entry.add_line(credit_line);
421
422        Some(entry)
423    }
424
425    /// Generate JE from AR Receipt (Customer Payment).
426    /// DR Cash, CR AR
427    pub fn generate_from_ar_receipt(&mut self, payment: &Payment) -> Option<JournalEntry> {
428        if payment.amount == Decimal::ZERO {
429            return None;
430        }
431
432        let document_id = self.uuid_factory.next();
433
434        // Use posting_date or fall back to document_date
435        let posting_date = payment
436            .header
437            .posting_date
438            .unwrap_or(payment.header.document_date);
439
440        let mut header = JournalEntryHeader::with_deterministic_id(
441            payment.header.company_code.clone(),
442            posting_date,
443            document_id,
444        );
445        header.source = TransactionSource::Automated;
446        header.business_process = Some(BusinessProcess::O2C);
447        header.reference = Some(format!("RCP:{}", payment.header.document_id));
448        header.header_text = Some(format!(
449            "Customer Receipt {} - {}",
450            payment.header.document_id, payment.business_partner_id
451        ));
452
453        let mut entry = JournalEntry::new(header);
454
455        // DR Cash/Bank
456        let debit_line = JournalEntryLine::debit(
457            entry.header.document_id,
458            1,
459            self.config.cash_account.clone(),
460            payment.amount,
461        );
462        entry.add_line(debit_line);
463
464        // CR Accounts Receivable
465        let credit_line = JournalEntryLine::credit(
466            entry.header.document_id,
467            2,
468            self.config.ar_account.clone(),
469            payment.amount,
470        );
471        entry.add_line(credit_line);
472
473        Some(entry)
474    }
475}
476
477impl Default for DocumentFlowJeGenerator {
478    fn default() -> Self {
479        Self::new()
480    }
481}
482
483#[cfg(test)]
484mod tests {
485    use super::*;
486    use chrono::NaiveDate;
487    use datasynth_core::models::documents::{GoodsReceiptItem, MovementType};
488
489    fn create_test_gr() -> GoodsReceipt {
490        let mut gr = GoodsReceipt::from_purchase_order(
491            "GR-001".to_string(),
492            "1000",
493            "PO-001",
494            "V-001",
495            "P1000",
496            "0001",
497            2024,
498            1,
499            NaiveDate::from_ymd_opt(2024, 1, 15).unwrap(),
500            "JSMITH",
501        );
502
503        let item = GoodsReceiptItem::from_po(
504            10,
505            "Test Material",
506            Decimal::from(100),
507            Decimal::from(50),
508            "PO-001",
509            10,
510        )
511        .with_movement_type(MovementType::GrForPo);
512
513        gr.add_item(item);
514        gr.post("JSMITH", NaiveDate::from_ymd_opt(2024, 1, 15).unwrap());
515
516        gr
517    }
518
519    fn create_test_vendor_invoice() -> VendorInvoice {
520        use datasynth_core::models::documents::VendorInvoiceItem;
521
522        let mut invoice = VendorInvoice::new(
523            "VI-001".to_string(),
524            "1000",
525            "V-001",
526            "INV-12345".to_string(),
527            2024,
528            1,
529            NaiveDate::from_ymd_opt(2024, 1, 20).unwrap(),
530            "JSMITH",
531        );
532
533        let item = VendorInvoiceItem::from_po_gr(
534            10,
535            "Test Material",
536            Decimal::from(100),
537            Decimal::from(50),
538            "PO-001",
539            10,
540            Some("GR-001".to_string()),
541            Some(10),
542        );
543
544        invoice.add_item(item);
545        invoice.post("JSMITH", NaiveDate::from_ymd_opt(2024, 1, 20).unwrap());
546
547        invoice
548    }
549
550    fn create_test_payment() -> Payment {
551        let mut payment = Payment::new_ap_payment(
552            "PAY-001".to_string(),
553            "1000",
554            "V-001",
555            Decimal::from(5000),
556            2024,
557            2,
558            NaiveDate::from_ymd_opt(2024, 2, 15).unwrap(),
559            "JSMITH",
560        );
561
562        payment.post("JSMITH", NaiveDate::from_ymd_opt(2024, 2, 15).unwrap());
563
564        payment
565    }
566
567    #[test]
568    fn test_generate_from_goods_receipt() {
569        let mut generator = DocumentFlowJeGenerator::new();
570        let gr = create_test_gr();
571
572        let je = generator.generate_from_goods_receipt(&gr);
573
574        assert!(je.is_some());
575        let je = je.unwrap();
576
577        // Should be balanced
578        assert!(je.is_balanced());
579
580        // Should have 2 lines
581        assert_eq!(je.line_count(), 2);
582
583        // DR should be inventory, CR should be GR/IR
584        assert!(je.total_debit() > Decimal::ZERO);
585        assert_eq!(je.total_debit(), je.total_credit());
586
587        // Should reference source document
588        assert!(je.header.reference.is_some());
589        assert!(je.header.reference.as_ref().unwrap().contains("GR:"));
590    }
591
592    #[test]
593    fn test_generate_from_vendor_invoice() {
594        let mut generator = DocumentFlowJeGenerator::new();
595        let invoice = create_test_vendor_invoice();
596
597        let je = generator.generate_from_vendor_invoice(&invoice);
598
599        assert!(je.is_some());
600        let je = je.unwrap();
601
602        assert!(je.is_balanced());
603        assert_eq!(je.line_count(), 2);
604        assert!(je.header.reference.as_ref().unwrap().contains("VI:"));
605    }
606
607    #[test]
608    fn test_generate_from_ap_payment() {
609        let mut generator = DocumentFlowJeGenerator::new();
610        let payment = create_test_payment();
611
612        let je = generator.generate_from_ap_payment(&payment);
613
614        assert!(je.is_some());
615        let je = je.unwrap();
616
617        assert!(je.is_balanced());
618        assert_eq!(je.line_count(), 2);
619        assert!(je.header.reference.as_ref().unwrap().contains("PAY:"));
620    }
621
622    #[test]
623    fn test_all_entries_are_balanced() {
624        let mut generator = DocumentFlowJeGenerator::new();
625
626        let gr = create_test_gr();
627        let invoice = create_test_vendor_invoice();
628        let payment = create_test_payment();
629
630        let entries = vec![
631            generator.generate_from_goods_receipt(&gr),
632            generator.generate_from_vendor_invoice(&invoice),
633            generator.generate_from_ap_payment(&payment),
634        ];
635
636        for entry in entries.into_iter().flatten() {
637            assert!(
638                entry.is_balanced(),
639                "Entry {} is not balanced",
640                entry.header.document_id
641            );
642        }
643    }
644}