Skip to main content

datasynth_generators/subledger/
document_flow_linker.rs

1//! Links document flows to subledger records.
2//!
3//! This module provides conversion functions to create subledger records
4//! from document flow documents, ensuring data coherence between:
5//! - P2P document flow (VendorInvoice) -> AP subledger (APInvoice)
6//! - O2C document flow (CustomerInvoice) -> AR subledger (ARInvoice)
7
8use std::collections::HashMap;
9
10use rust_decimal::Decimal;
11
12use datasynth_core::models::documents::{CustomerInvoice, VendorInvoice};
13use datasynth_core::models::subledger::ap::{APInvoice, APInvoiceLine, MatchStatus};
14use datasynth_core::models::subledger::ar::{ARInvoice, ARInvoiceLine};
15use datasynth_core::models::subledger::PaymentTerms;
16
17/// Links document flow invoices to subledger records.
18#[derive(Default)]
19pub struct DocumentFlowLinker {
20    ap_counter: u64,
21    ar_counter: u64,
22    /// Vendor ID → vendor name lookup for realistic AP invoice names.
23    vendor_names: HashMap<String, String>,
24    /// Customer ID → customer name lookup for realistic AR invoice names.
25    customer_names: HashMap<String, String>,
26}
27
28impl DocumentFlowLinker {
29    /// Create a new document flow linker.
30    pub fn new() -> Self {
31        Self {
32            ap_counter: 0,
33            ar_counter: 0,
34            vendor_names: HashMap::new(),
35            customer_names: HashMap::new(),
36        }
37    }
38
39    /// Set vendor name lookup map for realistic AP invoice vendor names.
40    pub fn with_vendor_names(mut self, names: HashMap<String, String>) -> Self {
41        self.vendor_names = names;
42        self
43    }
44
45    /// Set customer name lookup map for realistic AR invoice customer names.
46    pub fn with_customer_names(mut self, names: HashMap<String, String>) -> Self {
47        self.customer_names = names;
48        self
49    }
50
51    /// Convert a document flow VendorInvoice to an AP subledger APInvoice.
52    ///
53    /// This ensures that vendor invoices from the P2P flow create corresponding
54    /// AP subledger records for complete data coherence.
55    pub fn create_ap_invoice_from_vendor_invoice(
56        &mut self,
57        vendor_invoice: &VendorInvoice,
58    ) -> APInvoice {
59        self.ap_counter += 1;
60
61        // Generate AP invoice number based on vendor invoice
62        let invoice_number = format!("APINV{:08}", self.ap_counter);
63
64        // Create the AP invoice
65        let mut ap_invoice = APInvoice::new(
66            invoice_number,
67            vendor_invoice.vendor_invoice_number.clone(),
68            vendor_invoice.header.company_code.clone(),
69            vendor_invoice.vendor_id.clone(),
70            self.vendor_names
71                .get(&vendor_invoice.vendor_id)
72                .cloned()
73                .unwrap_or_else(|| format!("Vendor {}", vendor_invoice.vendor_id)),
74            vendor_invoice.invoice_date,
75            parse_payment_terms(&vendor_invoice.payment_terms),
76            vendor_invoice.header.currency.clone(),
77        );
78
79        // Set PO reference if available
80        if let Some(po_id) = &vendor_invoice.purchase_order_id {
81            ap_invoice.reference_po = Some(po_id.clone());
82            ap_invoice.match_status = match vendor_invoice.verification_status {
83                datasynth_core::models::documents::InvoiceVerificationStatus::ThreeWayMatchPassed => {
84                    MatchStatus::Matched
85                }
86                datasynth_core::models::documents::InvoiceVerificationStatus::ThreeWayMatchFailed => {
87                    // Compute non-zero variance from invoice line items.
88                    // When three-way match fails, there is a meaningful price/quantity difference.
89                    let total_line_amount: Decimal = vendor_invoice
90                        .items
91                        .iter()
92                        .map(|item| item.base.unit_price * item.base.quantity)
93                        .sum();
94                    // Price variance: ~2-5% of invoice total
95                    let price_var = (total_line_amount * Decimal::new(3, 2)).round_dp(2);
96                    // Quantity variance: ~1-3% of invoice total
97                    let qty_var = (total_line_amount * Decimal::new(15, 3)).round_dp(2);
98                    MatchStatus::MatchedWithVariance {
99                        price_variance: price_var,
100                        quantity_variance: qty_var,
101                    }
102                }
103                _ => MatchStatus::NotRequired,
104            };
105        }
106
107        // Add line items
108        for (idx, item) in vendor_invoice.items.iter().enumerate() {
109            let line = APInvoiceLine::new(
110                (idx + 1) as u32,
111                item.base.description.clone(),
112                item.base.quantity,
113                item.base.uom.clone(),
114                item.base.unit_price,
115                item.base
116                    .gl_account
117                    .clone()
118                    .unwrap_or_else(|| "5000".to_string()),
119            )
120            .with_tax(
121                item.tax_code.clone().unwrap_or_else(|| "VAT".to_string()),
122                item.base.tax_amount,
123            );
124
125            ap_invoice.add_line(line);
126        }
127
128        ap_invoice
129    }
130
131    /// Convert a document flow CustomerInvoice to an AR subledger ARInvoice.
132    ///
133    /// This ensures that customer invoices from the O2C flow create corresponding
134    /// AR subledger records for complete data coherence.
135    pub fn create_ar_invoice_from_customer_invoice(
136        &mut self,
137        customer_invoice: &CustomerInvoice,
138    ) -> ARInvoice {
139        self.ar_counter += 1;
140
141        // Generate AR invoice number based on customer invoice
142        let invoice_number = format!("ARINV{:08}", self.ar_counter);
143
144        // Create the AR invoice
145        let mut ar_invoice = ARInvoice::new(
146            invoice_number,
147            customer_invoice.header.company_code.clone(),
148            customer_invoice.customer_id.clone(),
149            self.customer_names
150                .get(&customer_invoice.customer_id)
151                .cloned()
152                .unwrap_or_else(|| format!("Customer {}", customer_invoice.customer_id)),
153            customer_invoice.header.document_date,
154            parse_payment_terms(&customer_invoice.payment_terms),
155            customer_invoice.header.currency.clone(),
156        );
157
158        // Add line items
159        for (idx, item) in customer_invoice.items.iter().enumerate() {
160            let line = ARInvoiceLine::new(
161                (idx + 1) as u32,
162                item.base.description.clone(),
163                item.base.quantity,
164                item.base.uom.clone(),
165                item.base.unit_price,
166                item.revenue_account
167                    .clone()
168                    .unwrap_or_else(|| "4000".to_string()),
169            )
170            .with_tax("VAT".to_string(), item.base.tax_amount);
171
172            ar_invoice.add_line(line);
173        }
174
175        ar_invoice
176    }
177
178    /// Batch convert multiple vendor invoices to AP invoices.
179    pub fn batch_create_ap_invoices(
180        &mut self,
181        vendor_invoices: &[VendorInvoice],
182    ) -> Vec<APInvoice> {
183        vendor_invoices
184            .iter()
185            .map(|vi| self.create_ap_invoice_from_vendor_invoice(vi))
186            .collect()
187    }
188
189    /// Batch convert multiple customer invoices to AR invoices.
190    pub fn batch_create_ar_invoices(
191        &mut self,
192        customer_invoices: &[CustomerInvoice],
193    ) -> Vec<ARInvoice> {
194        customer_invoices
195            .iter()
196            .map(|ci| self.create_ar_invoice_from_customer_invoice(ci))
197            .collect()
198    }
199}
200
201/// Parse payment terms string into PaymentTerms struct.
202fn parse_payment_terms(terms_str: &str) -> PaymentTerms {
203    // Try to parse common payment terms formats
204    match terms_str.to_uppercase().as_str() {
205        "NET30" | "N30" => PaymentTerms::net_30(),
206        "NET60" | "N60" => PaymentTerms::net_60(),
207        "NET90" | "N90" => PaymentTerms::net_90(),
208        "DUE ON RECEIPT" | "COD" => PaymentTerms::net(0), // Due immediately
209        _ => {
210            // Default to NET30 if parsing fails
211            PaymentTerms::net_30()
212        }
213    }
214}
215
216/// Result of linking document flows to subledgers.
217#[derive(Debug, Clone, Default)]
218pub struct SubledgerLinkResult {
219    /// AP invoices created from vendor invoices.
220    pub ap_invoices: Vec<APInvoice>,
221    /// AR invoices created from customer invoices.
222    pub ar_invoices: Vec<ARInvoice>,
223    /// Number of vendor invoices processed.
224    pub vendor_invoices_processed: usize,
225    /// Number of customer invoices processed.
226    pub customer_invoices_processed: usize,
227}
228
229#[cfg(test)]
230#[allow(clippy::unwrap_used)]
231mod tests {
232    use super::*;
233    use chrono::NaiveDate;
234    use datasynth_core::models::documents::VendorInvoiceItem;
235    use rust_decimal_macros::dec;
236
237    #[test]
238    fn test_create_ap_invoice_from_vendor_invoice() {
239        let mut vendor_invoice = VendorInvoice::new(
240            "VI-001",
241            "1000",
242            "VEND001",
243            "V-INV-001",
244            2024,
245            1,
246            NaiveDate::from_ymd_opt(2024, 1, 15).unwrap(),
247            "SYSTEM",
248        );
249
250        vendor_invoice.add_item(VendorInvoiceItem::new(1, "Test Item", dec!(10), dec!(100)));
251
252        let mut linker = DocumentFlowLinker::new();
253        let ap_invoice = linker.create_ap_invoice_from_vendor_invoice(&vendor_invoice);
254
255        assert_eq!(ap_invoice.vendor_id, "VEND001");
256        assert_eq!(ap_invoice.vendor_invoice_number, "V-INV-001");
257        assert_eq!(ap_invoice.lines.len(), 1);
258        assert!(ap_invoice.gross_amount.document_amount > Decimal::ZERO);
259    }
260
261    #[test]
262    fn test_create_ar_invoice_from_customer_invoice() {
263        use datasynth_core::models::documents::CustomerInvoiceItem;
264
265        let mut customer_invoice = CustomerInvoice::new(
266            "CI-001",
267            "1000",
268            "CUST001",
269            2024,
270            1,
271            NaiveDate::from_ymd_opt(2024, 1, 15).unwrap(),
272            NaiveDate::from_ymd_opt(2024, 2, 14).unwrap(),
273            "SYSTEM",
274        );
275
276        customer_invoice.add_item(CustomerInvoiceItem::new(1, "Product A", dec!(5), dec!(200)));
277
278        let mut linker = DocumentFlowLinker::new();
279        let ar_invoice = linker.create_ar_invoice_from_customer_invoice(&customer_invoice);
280
281        assert_eq!(ar_invoice.customer_id, "CUST001");
282        assert_eq!(ar_invoice.lines.len(), 1);
283        assert!(ar_invoice.gross_amount.document_amount > Decimal::ZERO);
284    }
285
286    #[test]
287    fn test_batch_conversion() {
288        let vendor_invoice = VendorInvoice::new(
289            "VI-001",
290            "1000",
291            "VEND001",
292            "V-INV-001",
293            2024,
294            1,
295            NaiveDate::from_ymd_opt(2024, 1, 15).unwrap(),
296            "SYSTEM",
297        );
298
299        let mut linker = DocumentFlowLinker::new();
300        let ap_invoices =
301            linker.batch_create_ap_invoices(&[vendor_invoice.clone(), vendor_invoice]);
302
303        assert_eq!(ap_invoices.len(), 2);
304        assert_eq!(ap_invoices[0].invoice_number, "APINV00000001");
305        assert_eq!(ap_invoices[1].invoice_number, "APINV00000002");
306    }
307
308    #[test]
309    fn test_parse_payment_terms() {
310        let terms = parse_payment_terms("NET30");
311        assert_eq!(terms.net_due_days, 30);
312
313        let terms = parse_payment_terms("NET60");
314        assert_eq!(terms.net_due_days, 60);
315
316        let terms = parse_payment_terms("DUE ON RECEIPT");
317        assert_eq!(terms.net_due_days, 0);
318
319        // Unknown terms default to NET30
320        let terms = parse_payment_terms("CUSTOM");
321        assert_eq!(terms.net_due_days, 30);
322    }
323}