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, Payment, PaymentType, VendorInvoice};
13use datasynth_core::models::subledger::ap::{APInvoice, APInvoiceLine, MatchStatus};
14use datasynth_core::models::subledger::ar::{ARInvoice, ARInvoiceLine};
15use datasynth_core::models::subledger::{PaymentTerms, SubledgerDocumentStatus};
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        // Use the document-flow document ID as vendor_invoice_number so that
66        // PaymentAllocation.invoice_id (which references the same document ID)
67        // can be matched during settlement.
68        let mut ap_invoice = APInvoice::new(
69            invoice_number,
70            vendor_invoice.header.document_id.clone(),
71            vendor_invoice.header.company_code.clone(),
72            vendor_invoice.vendor_id.clone(),
73            self.vendor_names
74                .get(&vendor_invoice.vendor_id)
75                .cloned()
76                .unwrap_or_else(|| format!("Vendor {}", vendor_invoice.vendor_id)),
77            vendor_invoice.invoice_date,
78            parse_payment_terms(&vendor_invoice.payment_terms),
79            vendor_invoice.header.currency.clone(),
80        );
81
82        // Set PO reference if available
83        if let Some(po_id) = &vendor_invoice.purchase_order_id {
84            ap_invoice.reference_po = Some(po_id.clone());
85            ap_invoice.match_status = match vendor_invoice.verification_status {
86                datasynth_core::models::documents::InvoiceVerificationStatus::ThreeWayMatchPassed => {
87                    MatchStatus::Matched
88                }
89                datasynth_core::models::documents::InvoiceVerificationStatus::ThreeWayMatchFailed => {
90                    // Compute non-zero variance from invoice line items.
91                    // When three-way match fails, there is a meaningful price/quantity difference.
92                    let total_line_amount: Decimal = vendor_invoice
93                        .items
94                        .iter()
95                        .map(|item| item.base.unit_price * item.base.quantity)
96                        .sum();
97                    // Price variance: ~2-5% of invoice total
98                    let price_var = (total_line_amount * Decimal::new(3, 2)).round_dp(2);
99                    // Quantity variance: ~1-3% of invoice total
100                    let qty_var = (total_line_amount * Decimal::new(15, 3)).round_dp(2);
101                    MatchStatus::MatchedWithVariance {
102                        price_variance: price_var,
103                        quantity_variance: qty_var,
104                    }
105                }
106                _ => MatchStatus::NotRequired,
107            };
108        }
109
110        // Add line items
111        for (idx, item) in vendor_invoice.items.iter().enumerate() {
112            let line = APInvoiceLine::new(
113                (idx + 1) as u32,
114                item.base.description.clone(),
115                item.base.quantity,
116                item.base.uom.clone(),
117                item.base.unit_price,
118                item.base
119                    .gl_account
120                    .clone()
121                    .unwrap_or_else(|| "5000".to_string()),
122            )
123            .with_tax(
124                item.tax_code.clone().unwrap_or_else(|| "VAT".to_string()),
125                item.base.tax_amount,
126            );
127
128            ap_invoice.add_line(line);
129        }
130
131        ap_invoice
132    }
133
134    /// Convert a document flow CustomerInvoice to an AR subledger ARInvoice.
135    ///
136    /// This ensures that customer invoices from the O2C flow create corresponding
137    /// AR subledger records for complete data coherence.
138    pub fn create_ar_invoice_from_customer_invoice(
139        &mut self,
140        customer_invoice: &CustomerInvoice,
141    ) -> ARInvoice {
142        self.ar_counter += 1;
143
144        // Use the document-flow document ID as the AR invoice number so that
145        // PaymentAllocation.invoice_id (which references the same document ID)
146        // can be matched during settlement.
147        let invoice_number = customer_invoice.header.document_id.clone();
148
149        // Create the AR invoice
150        let mut ar_invoice = ARInvoice::new(
151            invoice_number,
152            customer_invoice.header.company_code.clone(),
153            customer_invoice.customer_id.clone(),
154            self.customer_names
155                .get(&customer_invoice.customer_id)
156                .cloned()
157                .unwrap_or_else(|| format!("Customer {}", customer_invoice.customer_id)),
158            customer_invoice.header.document_date,
159            parse_payment_terms(&customer_invoice.payment_terms),
160            customer_invoice.header.currency.clone(),
161        );
162
163        // Add line items
164        for (idx, item) in customer_invoice.items.iter().enumerate() {
165            let line = ARInvoiceLine::new(
166                (idx + 1) as u32,
167                item.base.description.clone(),
168                item.base.quantity,
169                item.base.uom.clone(),
170                item.base.unit_price,
171                item.revenue_account
172                    .clone()
173                    .unwrap_or_else(|| "4000".to_string()),
174            )
175            .with_tax("VAT".to_string(), item.base.tax_amount);
176
177            ar_invoice.add_line(line);
178        }
179
180        ar_invoice
181    }
182
183    /// Batch convert multiple vendor invoices to AP invoices.
184    pub fn batch_create_ap_invoices(
185        &mut self,
186        vendor_invoices: &[VendorInvoice],
187    ) -> Vec<APInvoice> {
188        vendor_invoices
189            .iter()
190            .map(|vi| self.create_ap_invoice_from_vendor_invoice(vi))
191            .collect()
192    }
193
194    /// Batch convert multiple customer invoices to AR invoices.
195    pub fn batch_create_ar_invoices(
196        &mut self,
197        customer_invoices: &[CustomerInvoice],
198    ) -> Vec<ARInvoice> {
199        customer_invoices
200            .iter()
201            .map(|ci| self.create_ar_invoice_from_customer_invoice(ci))
202            .collect()
203    }
204}
205
206/// Reduces `amount_remaining` on AP invoices by the amounts applied in each payment.
207///
208/// For each `Payment` whose `payment_type` is `ApPayment`, iterates over its
209/// `allocations` and matches them to AP invoices by `allocation.invoice_id` ==
210/// `ap_invoice.vendor_invoice_number`. `amount_remaining` is clamped to zero so
211/// over-payments do not produce negative balances.
212pub fn apply_ap_settlements(ap_invoices: &mut [APInvoice], payments: &[Payment]) {
213    // Build a lookup: vendor_invoice_number → list of indices in ap_invoices.
214    // Uses owned String keys so the map does not hold borrows into the slice,
215    // allowing mutable access to elements later.
216    let mut index_map: HashMap<String, Vec<usize>> = HashMap::new();
217    for (idx, inv) in ap_invoices.iter().enumerate() {
218        index_map
219            .entry(inv.vendor_invoice_number.clone())
220            .or_default()
221            .push(idx);
222    }
223
224    for payment in payments {
225        if payment.payment_type != PaymentType::ApPayment {
226            continue;
227        }
228        for allocation in &payment.allocations {
229            if let Some(indices) = index_map.get(&allocation.invoice_id) {
230                for &idx in indices {
231                    let inv = &mut ap_invoices[idx];
232                    inv.amount_remaining =
233                        (inv.amount_remaining - allocation.amount).max(Decimal::ZERO);
234                    // Update status to reflect settlement state.
235                    inv.status = if inv.amount_remaining == Decimal::ZERO {
236                        SubledgerDocumentStatus::Cleared
237                    } else {
238                        SubledgerDocumentStatus::PartiallyCleared
239                    };
240                }
241            }
242        }
243    }
244}
245
246/// Reduces `amount_remaining` on AR invoices by the amounts applied in each receipt.
247///
248/// For each `Payment` whose `payment_type` is `ArReceipt`, iterates over its
249/// `allocations` and matches them to AR invoices by `allocation.invoice_id` ==
250/// `ar_invoice.invoice_number`. `amount_remaining` is clamped to zero so
251/// over-payments do not produce negative balances.
252pub fn apply_ar_settlements(ar_invoices: &mut [ARInvoice], payments: &[Payment]) {
253    // Build a lookup: invoice_number → list of indices in ar_invoices.
254    // Uses owned String keys so the map does not hold borrows into the slice,
255    // allowing mutable access to elements later.
256    let mut index_map: HashMap<String, Vec<usize>> = HashMap::new();
257    for (idx, inv) in ar_invoices.iter().enumerate() {
258        index_map
259            .entry(inv.invoice_number.clone())
260            .or_default()
261            .push(idx);
262    }
263
264    for payment in payments {
265        if payment.payment_type != PaymentType::ArReceipt {
266            continue;
267        }
268        for allocation in &payment.allocations {
269            if let Some(indices) = index_map.get(&allocation.invoice_id) {
270                for &idx in indices {
271                    let inv = &mut ar_invoices[idx];
272                    inv.amount_remaining =
273                        (inv.amount_remaining - allocation.amount).max(Decimal::ZERO);
274                    // Update status to reflect settlement state.
275                    inv.status = if inv.amount_remaining == Decimal::ZERO {
276                        SubledgerDocumentStatus::Cleared
277                    } else {
278                        SubledgerDocumentStatus::PartiallyCleared
279                    };
280                }
281            }
282        }
283    }
284}
285
286/// Parse payment terms string into PaymentTerms struct.
287fn parse_payment_terms(terms_str: &str) -> PaymentTerms {
288    // Try to parse common payment terms formats
289    match terms_str.to_uppercase().as_str() {
290        "NET30" | "N30" => PaymentTerms::net_30(),
291        "NET60" | "N60" => PaymentTerms::net_60(),
292        "NET90" | "N90" => PaymentTerms::net_90(),
293        "DUE ON RECEIPT" | "COD" => PaymentTerms::net(0), // Due immediately
294        _ => {
295            // Default to NET30 if parsing fails
296            PaymentTerms::net_30()
297        }
298    }
299}
300
301/// Result of linking document flows to subledgers.
302#[derive(Debug, Clone, Default)]
303pub struct SubledgerLinkResult {
304    /// AP invoices created from vendor invoices.
305    pub ap_invoices: Vec<APInvoice>,
306    /// AR invoices created from customer invoices.
307    pub ar_invoices: Vec<ARInvoice>,
308    /// Number of vendor invoices processed.
309    pub vendor_invoices_processed: usize,
310    /// Number of customer invoices processed.
311    pub customer_invoices_processed: usize,
312}
313
314#[cfg(test)]
315#[allow(clippy::unwrap_used)]
316mod tests {
317    use super::*;
318    use chrono::NaiveDate;
319    use datasynth_core::models::documents::VendorInvoiceItem;
320    use rust_decimal_macros::dec;
321
322    #[test]
323    fn test_create_ap_invoice_from_vendor_invoice() {
324        let mut vendor_invoice = VendorInvoice::new(
325            "VI-001",
326            "1000",
327            "VEND001",
328            "V-INV-001",
329            2024,
330            1,
331            NaiveDate::from_ymd_opt(2024, 1, 15).unwrap(),
332            "SYSTEM",
333        );
334
335        vendor_invoice.add_item(VendorInvoiceItem::new(1, "Test Item", dec!(10), dec!(100)));
336
337        let mut linker = DocumentFlowLinker::new();
338        let ap_invoice = linker.create_ap_invoice_from_vendor_invoice(&vendor_invoice);
339
340        assert_eq!(ap_invoice.vendor_id, "VEND001");
341        // vendor_invoice_number now stores the document-flow document ID so that
342        // PaymentAllocation.invoice_id can be matched during settlement.
343        assert_eq!(ap_invoice.vendor_invoice_number, "VI-001");
344        assert_eq!(ap_invoice.lines.len(), 1);
345        assert!(ap_invoice.gross_amount.document_amount > Decimal::ZERO);
346    }
347
348    #[test]
349    fn test_create_ar_invoice_from_customer_invoice() {
350        use datasynth_core::models::documents::CustomerInvoiceItem;
351
352        let mut customer_invoice = CustomerInvoice::new(
353            "CI-001",
354            "1000",
355            "CUST001",
356            2024,
357            1,
358            NaiveDate::from_ymd_opt(2024, 1, 15).unwrap(),
359            NaiveDate::from_ymd_opt(2024, 2, 14).unwrap(),
360            "SYSTEM",
361        );
362
363        customer_invoice.add_item(CustomerInvoiceItem::new(1, "Product A", dec!(5), dec!(200)));
364
365        let mut linker = DocumentFlowLinker::new();
366        let ar_invoice = linker.create_ar_invoice_from_customer_invoice(&customer_invoice);
367
368        assert_eq!(ar_invoice.customer_id, "CUST001");
369        assert_eq!(ar_invoice.lines.len(), 1);
370        assert!(ar_invoice.gross_amount.document_amount > Decimal::ZERO);
371    }
372
373    #[test]
374    fn test_batch_conversion() {
375        let vendor_invoice = VendorInvoice::new(
376            "VI-001",
377            "1000",
378            "VEND001",
379            "V-INV-001",
380            2024,
381            1,
382            NaiveDate::from_ymd_opt(2024, 1, 15).unwrap(),
383            "SYSTEM",
384        );
385
386        let mut linker = DocumentFlowLinker::new();
387        let ap_invoices =
388            linker.batch_create_ap_invoices(&[vendor_invoice.clone(), vendor_invoice]);
389
390        assert_eq!(ap_invoices.len(), 2);
391        assert_eq!(ap_invoices[0].invoice_number, "APINV00000001");
392        assert_eq!(ap_invoices[1].invoice_number, "APINV00000002");
393    }
394
395    #[test]
396    fn test_parse_payment_terms() {
397        let terms = parse_payment_terms("NET30");
398        assert_eq!(terms.net_due_days, 30);
399
400        let terms = parse_payment_terms("NET60");
401        assert_eq!(terms.net_due_days, 60);
402
403        let terms = parse_payment_terms("DUE ON RECEIPT");
404        assert_eq!(terms.net_due_days, 0);
405
406        // Unknown terms default to NET30
407        let terms = parse_payment_terms("CUSTOM");
408        assert_eq!(terms.net_due_days, 30);
409    }
410}