datasynth_core/models/documents/
vendor_invoice.rs

1//! Vendor Invoice document model.
2//!
3//! Represents vendor invoices in the P2P (Procure-to-Pay) process flow.
4//! Vendor invoices create accounting entries: DR Expense/GR-IR, CR AP.
5
6use chrono::NaiveDate;
7use rust_decimal::Decimal;
8use serde::{Deserialize, Serialize};
9
10use super::{
11    DocumentHeader, DocumentLineItem, DocumentReference, DocumentStatus, DocumentType,
12    ReferenceType,
13};
14
15/// Vendor Invoice type.
16#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize, Default)]
17#[serde(rename_all = "snake_case")]
18pub enum VendorInvoiceType {
19    /// Standard invoice against PO
20    #[default]
21    Standard,
22    /// Credit memo from vendor
23    CreditMemo,
24    /// Subsequent debit/credit
25    SubsequentAdjustment,
26    /// Down payment request
27    DownPaymentRequest,
28    /// Invoice plan
29    InvoicePlan,
30    /// Recurring invoice
31    Recurring,
32}
33
34/// Invoice verification status.
35#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize, Default)]
36#[serde(rename_all = "snake_case")]
37pub enum InvoiceVerificationStatus {
38    /// Not verified
39    #[default]
40    Unverified,
41    /// Three-way match passed
42    ThreeWayMatchPassed,
43    /// Three-way match failed
44    ThreeWayMatchFailed,
45    /// Manually approved despite mismatch
46    ManuallyApproved,
47    /// Blocked for payment
48    BlockedForPayment,
49}
50
51/// Vendor Invoice line item.
52#[derive(Debug, Clone, Serialize, Deserialize)]
53pub struct VendorInvoiceItem {
54    /// Base line item fields
55    #[serde(flatten)]
56    pub base: DocumentLineItem,
57
58    /// Reference PO number
59    pub po_number: Option<String>,
60
61    /// Reference PO item
62    pub po_item: Option<u16>,
63
64    /// Reference GR number
65    pub gr_number: Option<String>,
66
67    /// Reference GR item
68    pub gr_item: Option<u16>,
69
70    /// Invoiced quantity
71    pub invoiced_quantity: Decimal,
72
73    /// Three-way match status
74    pub match_status: ThreeWayMatchStatus,
75
76    /// Price variance amount
77    pub price_variance: Decimal,
78
79    /// Quantity variance
80    pub quantity_variance: Decimal,
81
82    /// Tax code
83    pub tax_code: Option<String>,
84
85    /// Withholding tax applicable
86    pub withholding_tax: bool,
87
88    /// Withholding tax amount
89    pub withholding_tax_amount: Decimal,
90}
91
92/// Three-way match status for invoice line.
93#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize, Default)]
94#[serde(rename_all = "snake_case")]
95pub enum ThreeWayMatchStatus {
96    /// Not applicable (no PO)
97    #[default]
98    NotApplicable,
99    /// Match passed
100    Matched,
101    /// Price mismatch
102    PriceMismatch,
103    /// Quantity mismatch
104    QuantityMismatch,
105    /// Both price and quantity mismatch
106    BothMismatch,
107    /// GR not received
108    GrNotReceived,
109}
110
111impl VendorInvoiceItem {
112    /// Create a new vendor invoice item.
113    #[allow(clippy::too_many_arguments)]
114    pub fn new(
115        line_number: u16,
116        description: impl Into<String>,
117        quantity: Decimal,
118        unit_price: Decimal,
119    ) -> Self {
120        let base = DocumentLineItem::new(line_number, description, quantity, unit_price);
121        Self {
122            base,
123            po_number: None,
124            po_item: None,
125            gr_number: None,
126            gr_item: None,
127            invoiced_quantity: quantity,
128            match_status: ThreeWayMatchStatus::NotApplicable,
129            price_variance: Decimal::ZERO,
130            quantity_variance: Decimal::ZERO,
131            tax_code: None,
132            withholding_tax: false,
133            withholding_tax_amount: Decimal::ZERO,
134        }
135    }
136
137    /// Create from PO/GR reference.
138    #[allow(clippy::too_many_arguments)]
139    pub fn from_po_gr(
140        line_number: u16,
141        description: impl Into<String>,
142        quantity: Decimal,
143        unit_price: Decimal,
144        po_number: impl Into<String>,
145        po_item: u16,
146        gr_number: Option<String>,
147        gr_item: Option<u16>,
148    ) -> Self {
149        let mut item = Self::new(line_number, description, quantity, unit_price);
150        item.po_number = Some(po_number.into());
151        item.po_item = Some(po_item);
152        item.gr_number = gr_number;
153        item.gr_item = gr_item;
154        item
155    }
156
157    /// Set GL account.
158    pub fn with_gl_account(mut self, account: impl Into<String>) -> Self {
159        self.base = self.base.with_gl_account(account);
160        self
161    }
162
163    /// Set cost center.
164    pub fn with_cost_center(mut self, cost_center: impl Into<String>) -> Self {
165        self.base = self.base.with_cost_center(cost_center);
166        self
167    }
168
169    /// Set tax.
170    pub fn with_tax(mut self, tax_code: impl Into<String>, tax_amount: Decimal) -> Self {
171        self.tax_code = Some(tax_code.into());
172        self.base = self.base.with_tax(tax_amount);
173        self
174    }
175
176    /// Set withholding tax.
177    pub fn with_withholding_tax(mut self, amount: Decimal) -> Self {
178        self.withholding_tax = true;
179        self.withholding_tax_amount = amount;
180        self
181    }
182
183    /// Set match status.
184    pub fn with_match_status(mut self, status: ThreeWayMatchStatus) -> Self {
185        self.match_status = status;
186        self
187    }
188
189    /// Calculate price variance.
190    pub fn calculate_price_variance(&mut self, po_price: Decimal) {
191        self.price_variance = (self.base.unit_price - po_price) * self.base.quantity;
192    }
193
194    /// Calculate quantity variance.
195    pub fn calculate_quantity_variance(&mut self, gr_quantity: Decimal) {
196        self.quantity_variance = self.base.quantity - gr_quantity;
197    }
198}
199
200/// Vendor Invoice document.
201#[derive(Debug, Clone, Serialize, Deserialize)]
202pub struct VendorInvoice {
203    /// Document header
204    pub header: DocumentHeader,
205
206    /// Invoice type
207    pub invoice_type: VendorInvoiceType,
208
209    /// Vendor ID
210    pub vendor_id: String,
211
212    /// Vendor invoice number (external reference)
213    pub vendor_invoice_number: String,
214
215    /// Invoice date from vendor
216    pub invoice_date: NaiveDate,
217
218    /// Line items
219    pub items: Vec<VendorInvoiceItem>,
220
221    /// Net amount
222    pub net_amount: Decimal,
223
224    /// Tax amount
225    pub tax_amount: Decimal,
226
227    /// Gross amount
228    pub gross_amount: Decimal,
229
230    /// Withholding tax amount
231    pub withholding_tax_amount: Decimal,
232
233    /// Amount to pay (gross - withholding)
234    pub payable_amount: Decimal,
235
236    /// Payment terms
237    pub payment_terms: String,
238
239    /// Due date for payment
240    pub due_date: NaiveDate,
241
242    /// Discount due date (for early payment)
243    pub discount_due_date: Option<NaiveDate>,
244
245    /// Cash discount percentage
246    pub cash_discount_percent: Decimal,
247
248    /// Cash discount amount
249    pub cash_discount_amount: Decimal,
250
251    /// Verification status
252    pub verification_status: InvoiceVerificationStatus,
253
254    /// Is blocked for payment?
255    pub payment_block: bool,
256
257    /// Payment block reason
258    pub payment_block_reason: Option<String>,
259
260    /// Reference PO (primary)
261    pub purchase_order_id: Option<String>,
262
263    /// Reference GR (primary)
264    pub goods_receipt_id: Option<String>,
265
266    /// Is this invoice paid?
267    pub is_paid: bool,
268
269    /// Amount paid
270    pub amount_paid: Decimal,
271
272    /// Remaining balance
273    pub balance: Decimal,
274
275    /// Payment document references
276    pub payment_references: Vec<String>,
277
278    /// Baseline date for payment
279    pub baseline_date: NaiveDate,
280}
281
282impl VendorInvoice {
283    /// Create a new vendor invoice.
284    #[allow(clippy::too_many_arguments)]
285    pub fn new(
286        invoice_id: impl Into<String>,
287        company_code: impl Into<String>,
288        vendor_id: impl Into<String>,
289        vendor_invoice_number: impl Into<String>,
290        fiscal_year: u16,
291        fiscal_period: u8,
292        invoice_date: NaiveDate,
293        created_by: impl Into<String>,
294    ) -> Self {
295        let header = DocumentHeader::new(
296            invoice_id,
297            DocumentType::VendorInvoice,
298            company_code,
299            fiscal_year,
300            fiscal_period,
301            invoice_date,
302            created_by,
303        );
304
305        let due_date = invoice_date + chrono::Duration::days(30);
306
307        Self {
308            header,
309            invoice_type: VendorInvoiceType::Standard,
310            vendor_id: vendor_id.into(),
311            vendor_invoice_number: vendor_invoice_number.into(),
312            invoice_date,
313            items: Vec::new(),
314            net_amount: Decimal::ZERO,
315            tax_amount: Decimal::ZERO,
316            gross_amount: Decimal::ZERO,
317            withholding_tax_amount: Decimal::ZERO,
318            payable_amount: Decimal::ZERO,
319            payment_terms: "NET30".to_string(),
320            due_date,
321            discount_due_date: None,
322            cash_discount_percent: Decimal::ZERO,
323            cash_discount_amount: Decimal::ZERO,
324            verification_status: InvoiceVerificationStatus::Unverified,
325            payment_block: false,
326            payment_block_reason: None,
327            purchase_order_id: None,
328            goods_receipt_id: None,
329            is_paid: false,
330            amount_paid: Decimal::ZERO,
331            balance: Decimal::ZERO,
332            payment_references: Vec::new(),
333            baseline_date: invoice_date,
334        }
335    }
336
337    /// Create invoice referencing PO and GR.
338    #[allow(clippy::too_many_arguments)]
339    pub fn from_po_gr(
340        invoice_id: impl Into<String>,
341        company_code: impl Into<String>,
342        vendor_id: impl Into<String>,
343        vendor_invoice_number: impl Into<String>,
344        po_id: impl Into<String>,
345        gr_id: impl Into<String>,
346        fiscal_year: u16,
347        fiscal_period: u8,
348        invoice_date: NaiveDate,
349        created_by: impl Into<String>,
350    ) -> Self {
351        let po = po_id.into();
352        let gr = gr_id.into();
353        let cc = company_code.into();
354
355        let mut invoice = Self::new(
356            invoice_id,
357            &cc,
358            vendor_id,
359            vendor_invoice_number,
360            fiscal_year,
361            fiscal_period,
362            invoice_date,
363            created_by,
364        );
365
366        invoice.purchase_order_id = Some(po.clone());
367        invoice.goods_receipt_id = Some(gr.clone());
368
369        // Add references
370        invoice.header.add_reference(DocumentReference::new(
371            DocumentType::PurchaseOrder,
372            po,
373            DocumentType::VendorInvoice,
374            invoice.header.document_id.clone(),
375            ReferenceType::FollowOn,
376            &cc,
377            invoice_date,
378        ));
379
380        invoice.header.add_reference(DocumentReference::new(
381            DocumentType::GoodsReceipt,
382            gr,
383            DocumentType::VendorInvoice,
384            invoice.header.document_id.clone(),
385            ReferenceType::FollowOn,
386            cc,
387            invoice_date,
388        ));
389
390        invoice
391    }
392
393    /// Set invoice type.
394    pub fn with_invoice_type(mut self, invoice_type: VendorInvoiceType) -> Self {
395        self.invoice_type = invoice_type;
396        self
397    }
398
399    /// Set payment terms.
400    pub fn with_payment_terms(mut self, terms: impl Into<String>, due_days: i64) -> Self {
401        self.payment_terms = terms.into();
402        self.due_date = self.invoice_date + chrono::Duration::days(due_days);
403        self
404    }
405
406    /// Set cash discount.
407    pub fn with_cash_discount(mut self, percent: Decimal, discount_days: i64) -> Self {
408        self.cash_discount_percent = percent;
409        self.discount_due_date = Some(self.invoice_date + chrono::Duration::days(discount_days));
410        self
411    }
412
413    /// Block payment.
414    pub fn block_payment(&mut self, reason: impl Into<String>) {
415        self.payment_block = true;
416        self.payment_block_reason = Some(reason.into());
417        self.verification_status = InvoiceVerificationStatus::BlockedForPayment;
418    }
419
420    /// Unblock payment.
421    pub fn unblock_payment(&mut self) {
422        self.payment_block = false;
423        self.payment_block_reason = None;
424    }
425
426    /// Add a line item.
427    pub fn add_item(&mut self, item: VendorInvoiceItem) {
428        self.items.push(item);
429        self.recalculate_totals();
430    }
431
432    /// Recalculate totals.
433    pub fn recalculate_totals(&mut self) {
434        self.net_amount = self.items.iter().map(|i| i.base.net_amount).sum();
435        self.tax_amount = self.items.iter().map(|i| i.base.tax_amount).sum();
436        self.withholding_tax_amount = self.items.iter().map(|i| i.withholding_tax_amount).sum();
437        self.gross_amount = self.net_amount + self.tax_amount;
438        self.payable_amount = self.gross_amount - self.withholding_tax_amount;
439        self.cash_discount_amount =
440            self.net_amount * self.cash_discount_percent / Decimal::from(100);
441        self.balance = self.payable_amount - self.amount_paid;
442    }
443
444    /// Record payment.
445    pub fn record_payment(&mut self, amount: Decimal, payment_doc_id: impl Into<String>) {
446        self.amount_paid += amount;
447        self.balance = self.payable_amount - self.amount_paid;
448        self.payment_references.push(payment_doc_id.into());
449
450        if self.balance <= Decimal::ZERO {
451            self.is_paid = true;
452            self.header.update_status(DocumentStatus::Cleared, "SYSTEM");
453        }
454    }
455
456    /// Post the invoice.
457    pub fn post(&mut self, user: impl Into<String>, posting_date: NaiveDate) {
458        self.header.posting_date = Some(posting_date);
459        self.header.update_status(DocumentStatus::Posted, user);
460    }
461
462    /// Verify the invoice (three-way match).
463    pub fn verify(&mut self, passed: bool) {
464        self.verification_status = if passed {
465            InvoiceVerificationStatus::ThreeWayMatchPassed
466        } else {
467            InvoiceVerificationStatus::ThreeWayMatchFailed
468        };
469    }
470
471    /// Check if discount is still available.
472    pub fn discount_available(&self, as_of_date: NaiveDate) -> bool {
473        self.discount_due_date.is_some_and(|d| as_of_date <= d)
474    }
475
476    /// Get amount with discount.
477    pub fn discounted_amount(&self, as_of_date: NaiveDate) -> Decimal {
478        if self.discount_available(as_of_date) {
479            self.payable_amount - self.cash_discount_amount
480        } else {
481            self.payable_amount
482        }
483    }
484
485    /// Generate GL entries for invoice posting.
486    /// DR Expense/GR-IR Clearing
487    /// CR AP (Vendor)
488    pub fn generate_gl_entries(&self) -> Vec<(String, Decimal, Decimal, Option<String>)> {
489        let mut entries = Vec::new();
490
491        // Debit entries (expenses or GR/IR clearing)
492        for item in &self.items {
493            let account = if item.po_number.is_some() && item.gr_number.is_some() {
494                "290000".to_string() // GR/IR Clearing
495            } else {
496                item.base
497                    .gl_account
498                    .clone()
499                    .unwrap_or_else(|| "600000".to_string())
500            };
501
502            entries.push((
503                account,
504                item.base.net_amount,
505                Decimal::ZERO,
506                item.base.cost_center.clone(),
507            ));
508        }
509
510        // Tax entry (if applicable)
511        if self.tax_amount > Decimal::ZERO {
512            entries.push((
513                "154000".to_string(), // Input VAT
514                self.tax_amount,
515                Decimal::ZERO,
516                None,
517            ));
518        }
519
520        // Credit entry (AP)
521        entries.push((
522            "210000".to_string(), // AP
523            Decimal::ZERO,
524            self.gross_amount,
525            None,
526        ));
527
528        entries
529    }
530}
531
532#[cfg(test)]
533mod tests {
534    use super::*;
535
536    #[test]
537    fn test_vendor_invoice_creation() {
538        let invoice = VendorInvoice::new(
539            "VI-1000-0000000001",
540            "1000",
541            "V-000001",
542            "INV-2024-001",
543            2024,
544            1,
545            NaiveDate::from_ymd_opt(2024, 1, 15).unwrap(),
546            "JSMITH",
547        );
548
549        assert_eq!(invoice.vendor_id, "V-000001");
550        assert_eq!(
551            invoice.due_date,
552            NaiveDate::from_ymd_opt(2024, 2, 14).unwrap()
553        );
554    }
555
556    #[test]
557    fn test_vendor_invoice_with_items() {
558        let mut invoice = VendorInvoice::new(
559            "VI-1000-0000000001",
560            "1000",
561            "V-000001",
562            "INV-2024-001",
563            2024,
564            1,
565            NaiveDate::from_ymd_opt(2024, 1, 15).unwrap(),
566            "JSMITH",
567        );
568
569        invoice.add_item(
570            VendorInvoiceItem::new(1, "Office Supplies", Decimal::from(10), Decimal::from(25))
571                .with_tax("VAT10", Decimal::from(25)),
572        );
573
574        assert_eq!(invoice.net_amount, Decimal::from(250));
575        assert_eq!(invoice.tax_amount, Decimal::from(25));
576        assert_eq!(invoice.gross_amount, Decimal::from(275));
577    }
578
579    #[test]
580    fn test_payment_recording() {
581        let mut invoice = VendorInvoice::new(
582            "VI-1000-0000000001",
583            "1000",
584            "V-000001",
585            "INV-2024-001",
586            2024,
587            1,
588            NaiveDate::from_ymd_opt(2024, 1, 15).unwrap(),
589            "JSMITH",
590        );
591
592        invoice.add_item(VendorInvoiceItem::new(
593            1,
594            "Test",
595            Decimal::from(1),
596            Decimal::from(1000),
597        ));
598
599        invoice.record_payment(Decimal::from(500), "PAY-001");
600        assert_eq!(invoice.balance, Decimal::from(500));
601        assert!(!invoice.is_paid);
602
603        invoice.record_payment(Decimal::from(500), "PAY-002");
604        assert_eq!(invoice.balance, Decimal::ZERO);
605        assert!(invoice.is_paid);
606    }
607
608    #[test]
609    fn test_cash_discount() {
610        let invoice = VendorInvoice::new(
611            "VI-1000-0000000001",
612            "1000",
613            "V-000001",
614            "INV-2024-001",
615            2024,
616            1,
617            NaiveDate::from_ymd_opt(2024, 1, 15).unwrap(),
618            "JSMITH",
619        )
620        .with_cash_discount(Decimal::from(2), 10);
621
622        let early_date = NaiveDate::from_ymd_opt(2024, 1, 20).unwrap();
623        let late_date = NaiveDate::from_ymd_opt(2024, 2, 1).unwrap();
624
625        assert!(invoice.discount_available(early_date));
626        assert!(!invoice.discount_available(late_date));
627    }
628}