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::accounts::{expense_accounts, revenue_accounts};
13use datasynth_core::models::documents::{CustomerInvoice, Payment, PaymentType, VendorInvoice};
14use datasynth_core::models::subledger::ap::{APInvoice, APInvoiceLine, MatchStatus};
15use datasynth_core::models::subledger::ar::{ARInvoice, ARInvoiceLine};
16use datasynth_core::models::subledger::{PaymentTerms, SubledgerDocumentStatus};
17
18/// Links document flow invoices to subledger records.
19#[derive(Default)]
20pub struct DocumentFlowLinker {
21    ap_counter: u64,
22    ar_counter: u64,
23    /// Vendor ID → vendor name lookup for realistic AP invoice names.
24    vendor_names: HashMap<String, String>,
25    /// Customer ID → customer name lookup for realistic AR invoice names.
26    customer_names: HashMap<String, String>,
27}
28
29impl DocumentFlowLinker {
30    /// Create a new document flow linker.
31    pub fn new() -> Self {
32        Self {
33            ap_counter: 0,
34            ar_counter: 0,
35            vendor_names: HashMap::new(),
36            customer_names: HashMap::new(),
37        }
38    }
39
40    /// Set vendor name lookup map for realistic AP invoice vendor names.
41    pub fn with_vendor_names(mut self, names: HashMap<String, String>) -> Self {
42        self.vendor_names = names;
43        self
44    }
45
46    /// Set customer name lookup map for realistic AR invoice customer names.
47    pub fn with_customer_names(mut self, names: HashMap<String, String>) -> Self {
48        self.customer_names = names;
49        self
50    }
51
52    /// Convert a document flow VendorInvoice to an AP subledger APInvoice.
53    ///
54    /// This ensures that vendor invoices from the P2P flow create corresponding
55    /// AP subledger records for complete data coherence.
56    pub fn create_ap_invoice_from_vendor_invoice(
57        &mut self,
58        vendor_invoice: &VendorInvoice,
59    ) -> APInvoice {
60        self.ap_counter += 1;
61
62        // Generate AP invoice number based on vendor invoice
63        let invoice_number = format!("APINV{:08}", self.ap_counter);
64
65        // Create the AP invoice.
66        // Use the document-flow document ID as vendor_invoice_number so that
67        // PaymentAllocation.invoice_id (which references the same document ID)
68        // can be matched during settlement.
69        let mut ap_invoice = APInvoice::new(
70            invoice_number,
71            vendor_invoice.header.document_id.clone(),
72            vendor_invoice.header.company_code.clone(),
73            vendor_invoice.vendor_id.clone(),
74            self.vendor_names
75                .get(&vendor_invoice.vendor_id)
76                .cloned()
77                .unwrap_or_else(|| format!("Vendor {}", vendor_invoice.vendor_id)),
78            vendor_invoice.invoice_date,
79            parse_payment_terms(&vendor_invoice.payment_terms),
80            vendor_invoice.header.currency.clone(),
81        );
82
83        // Set PO reference if available
84        if let Some(po_id) = &vendor_invoice.purchase_order_id {
85            ap_invoice.reference_po = Some(po_id.clone());
86            ap_invoice.match_status = match vendor_invoice.verification_status {
87                datasynth_core::models::documents::InvoiceVerificationStatus::ThreeWayMatchPassed => {
88                    MatchStatus::Matched
89                }
90                datasynth_core::models::documents::InvoiceVerificationStatus::ThreeWayMatchFailed => {
91                    // Compute non-zero variance from invoice line items.
92                    // When three-way match fails, there is a meaningful price/quantity difference.
93                    let total_line_amount: Decimal = vendor_invoice
94                        .items
95                        .iter()
96                        .map(|item| item.base.unit_price * item.base.quantity)
97                        .sum();
98                    // Price variance: ~2-5% of invoice total
99                    let price_var = (total_line_amount * Decimal::new(3, 2)).round_dp(2);
100                    // Quantity variance: ~1-3% of invoice total
101                    let qty_var = (total_line_amount * Decimal::new(15, 3)).round_dp(2);
102                    MatchStatus::MatchedWithVariance {
103                        price_variance: price_var,
104                        quantity_variance: qty_var,
105                    }
106                }
107                _ => MatchStatus::NotRequired,
108            };
109        }
110
111        // Add line items
112        for (idx, item) in vendor_invoice.items.iter().enumerate() {
113            let line = APInvoiceLine::new(
114                (idx + 1) as u32,
115                item.base.description.clone(),
116                item.base.quantity,
117                item.base.uom.clone(),
118                item.base.unit_price,
119                item.base
120                    .gl_account
121                    .clone()
122                    .unwrap_or_else(|| expense_accounts::COGS.to_string()),
123            )
124            .with_tax(
125                item.tax_code.clone().unwrap_or_else(|| "VAT".to_string()),
126                item.base.tax_amount,
127            );
128
129            ap_invoice.add_line(line);
130        }
131
132        ap_invoice
133    }
134
135    /// Convert a document flow CustomerInvoice to an AR subledger ARInvoice.
136    ///
137    /// This ensures that customer invoices from the O2C flow create corresponding
138    /// AR subledger records for complete data coherence.
139    pub fn create_ar_invoice_from_customer_invoice(
140        &mut self,
141        customer_invoice: &CustomerInvoice,
142    ) -> ARInvoice {
143        self.ar_counter += 1;
144
145        // Use the document-flow document ID as the AR invoice number so that
146        // PaymentAllocation.invoice_id (which references the same document ID)
147        // can be matched during settlement.
148        let invoice_number = customer_invoice.header.document_id.clone();
149
150        // Create the AR invoice
151        let mut ar_invoice = ARInvoice::new(
152            invoice_number,
153            customer_invoice.header.company_code.clone(),
154            customer_invoice.customer_id.clone(),
155            self.customer_names
156                .get(&customer_invoice.customer_id)
157                .cloned()
158                .unwrap_or_else(|| format!("Customer {}", customer_invoice.customer_id)),
159            customer_invoice.header.document_date,
160            parse_payment_terms(&customer_invoice.payment_terms),
161            customer_invoice.header.currency.clone(),
162        );
163
164        // Add line items
165        for (idx, item) in customer_invoice.items.iter().enumerate() {
166            let line = ARInvoiceLine::new(
167                (idx + 1) as u32,
168                item.base.description.clone(),
169                item.base.quantity,
170                item.base.uom.clone(),
171                item.base.unit_price,
172                item.revenue_account
173                    .clone()
174                    .unwrap_or_else(|| revenue_accounts::PRODUCT_REVENUE.to_string()),
175            )
176            .with_tax("VAT".to_string(), item.base.tax_amount);
177
178            ar_invoice.add_line(line);
179        }
180
181        ar_invoice
182    }
183
184    /// Batch convert multiple vendor invoices to AP invoices.
185    pub fn batch_create_ap_invoices(
186        &mut self,
187        vendor_invoices: &[VendorInvoice],
188    ) -> Vec<APInvoice> {
189        vendor_invoices
190            .iter()
191            .map(|vi| self.create_ap_invoice_from_vendor_invoice(vi))
192            .collect()
193    }
194
195    /// Batch convert multiple customer invoices to AR invoices.
196    pub fn batch_create_ar_invoices(
197        &mut self,
198        customer_invoices: &[CustomerInvoice],
199    ) -> Vec<ARInvoice> {
200        customer_invoices
201            .iter()
202            .map(|ci| self.create_ar_invoice_from_customer_invoice(ci))
203            .collect()
204    }
205}
206
207/// Reduces `amount_remaining` on AP invoices by the amounts applied in each payment.
208///
209/// For each `Payment` whose `payment_type` is `ApPayment`, iterates over its
210/// `allocations` and matches them to AP invoices by `allocation.invoice_id` ==
211/// `ap_invoice.vendor_invoice_number`. `amount_remaining` is clamped to zero so
212/// over-payments do not produce negative balances.
213pub fn apply_ap_settlements(ap_invoices: &mut [APInvoice], payments: &[Payment]) {
214    // Build a lookup: vendor_invoice_number → list of indices in ap_invoices.
215    // Uses owned String keys so the map does not hold borrows into the slice,
216    // allowing mutable access to elements later.
217    let mut index_map: HashMap<String, Vec<usize>> = HashMap::new();
218    for (idx, inv) in ap_invoices.iter().enumerate() {
219        index_map
220            .entry(inv.vendor_invoice_number.clone())
221            .or_default()
222            .push(idx);
223    }
224
225    for payment in payments {
226        if payment.payment_type != PaymentType::ApPayment {
227            continue;
228        }
229        for allocation in &payment.allocations {
230            if let Some(indices) = index_map.get(&allocation.invoice_id) {
231                for &idx in indices {
232                    let inv = &mut ap_invoices[idx];
233                    inv.amount_remaining =
234                        (inv.amount_remaining - allocation.amount).max(Decimal::ZERO);
235                    // Update status to reflect settlement state.
236                    inv.status = if inv.amount_remaining == Decimal::ZERO {
237                        SubledgerDocumentStatus::Cleared
238                    } else {
239                        SubledgerDocumentStatus::PartiallyCleared
240                    };
241                }
242            }
243        }
244    }
245}
246
247/// Reduces `amount_remaining` on AR invoices by the amounts applied in each receipt.
248///
249/// For each `Payment` whose `payment_type` is `ArReceipt`, iterates over its
250/// `allocations` and matches them to AR invoices by `allocation.invoice_id` ==
251/// `ar_invoice.invoice_number`. `amount_remaining` is clamped to zero so
252/// over-payments do not produce negative balances.
253pub fn apply_ar_settlements(ar_invoices: &mut [ARInvoice], payments: &[Payment]) {
254    // Build a lookup: invoice_number → list of indices in ar_invoices.
255    // Uses owned String keys so the map does not hold borrows into the slice,
256    // allowing mutable access to elements later.
257    let mut index_map: HashMap<String, Vec<usize>> = HashMap::new();
258    for (idx, inv) in ar_invoices.iter().enumerate() {
259        index_map
260            .entry(inv.invoice_number.clone())
261            .or_default()
262            .push(idx);
263    }
264
265    for payment in payments {
266        if payment.payment_type != PaymentType::ArReceipt {
267            continue;
268        }
269        for allocation in &payment.allocations {
270            if let Some(indices) = index_map.get(&allocation.invoice_id) {
271                for &idx in indices {
272                    let inv = &mut ar_invoices[idx];
273                    inv.amount_remaining =
274                        (inv.amount_remaining - allocation.amount).max(Decimal::ZERO);
275                    // Update status to reflect settlement state.
276                    inv.status = if inv.amount_remaining == Decimal::ZERO {
277                        SubledgerDocumentStatus::Cleared
278                    } else {
279                        SubledgerDocumentStatus::PartiallyCleared
280                    };
281                }
282            }
283        }
284    }
285}
286
287/// Parse payment terms string into PaymentTerms struct.
288fn parse_payment_terms(terms_str: &str) -> PaymentTerms {
289    // Try to parse common payment terms formats
290    match terms_str.to_uppercase().as_str() {
291        "NET30" | "N30" => PaymentTerms::net_30(),
292        "NET60" | "N60" => PaymentTerms::net_60(),
293        "NET90" | "N90" => PaymentTerms::net_90(),
294        "DUE ON RECEIPT" | "COD" => PaymentTerms::net(0), // Due immediately
295        _ => {
296            // Default to NET30 if parsing fails
297            PaymentTerms::net_30()
298        }
299    }
300}
301
302/// Result of linking document flows to subledgers.
303#[derive(Debug, Clone, Default)]
304pub struct SubledgerLinkResult {
305    /// AP invoices created from vendor invoices.
306    pub ap_invoices: Vec<APInvoice>,
307    /// AR invoices created from customer invoices.
308    pub ar_invoices: Vec<ARInvoice>,
309    /// Number of vendor invoices processed.
310    pub vendor_invoices_processed: usize,
311    /// Number of customer invoices processed.
312    pub customer_invoices_processed: usize,
313}
314
315#[cfg(test)]
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}