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