Skip to main content

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    /// Vendor display name (denormalized, DS-011)
282    #[serde(default, skip_serializing_if = "Option::is_none")]
283    pub vendor_name: Option<String>,
284}
285
286impl VendorInvoice {
287    /// Create a new vendor invoice.
288    #[allow(clippy::too_many_arguments)]
289    pub fn new(
290        invoice_id: impl Into<String>,
291        company_code: impl Into<String>,
292        vendor_id: impl Into<String>,
293        vendor_invoice_number: impl Into<String>,
294        fiscal_year: u16,
295        fiscal_period: u8,
296        invoice_date: NaiveDate,
297        created_by: impl Into<String>,
298    ) -> Self {
299        let header = DocumentHeader::new(
300            invoice_id,
301            DocumentType::VendorInvoice,
302            company_code,
303            fiscal_year,
304            fiscal_period,
305            invoice_date,
306            created_by,
307        );
308
309        let due_date = invoice_date + chrono::Duration::days(30);
310
311        Self {
312            header,
313            invoice_type: VendorInvoiceType::Standard,
314            vendor_id: vendor_id.into(),
315            vendor_invoice_number: vendor_invoice_number.into(),
316            invoice_date,
317            items: Vec::new(),
318            net_amount: Decimal::ZERO,
319            tax_amount: Decimal::ZERO,
320            gross_amount: Decimal::ZERO,
321            withholding_tax_amount: Decimal::ZERO,
322            payable_amount: Decimal::ZERO,
323            payment_terms: "NET30".to_string(),
324            due_date,
325            discount_due_date: None,
326            cash_discount_percent: Decimal::ZERO,
327            cash_discount_amount: Decimal::ZERO,
328            verification_status: InvoiceVerificationStatus::Unverified,
329            payment_block: false,
330            payment_block_reason: None,
331            purchase_order_id: None,
332            goods_receipt_id: None,
333            is_paid: false,
334            amount_paid: Decimal::ZERO,
335            balance: Decimal::ZERO,
336            payment_references: Vec::new(),
337            baseline_date: invoice_date,
338            vendor_name: None,
339        }
340    }
341
342    /// Create invoice referencing PO and GR.
343    #[allow(clippy::too_many_arguments)]
344    pub fn from_po_gr(
345        invoice_id: impl Into<String>,
346        company_code: impl Into<String>,
347        vendor_id: impl Into<String>,
348        vendor_invoice_number: impl Into<String>,
349        po_id: impl Into<String>,
350        gr_id: impl Into<String>,
351        fiscal_year: u16,
352        fiscal_period: u8,
353        invoice_date: NaiveDate,
354        created_by: impl Into<String>,
355    ) -> Self {
356        let po = po_id.into();
357        let gr = gr_id.into();
358        let cc = company_code.into();
359
360        let mut invoice = Self::new(
361            invoice_id,
362            &cc,
363            vendor_id,
364            vendor_invoice_number,
365            fiscal_year,
366            fiscal_period,
367            invoice_date,
368            created_by,
369        );
370
371        invoice.purchase_order_id = Some(po.clone());
372        invoice.goods_receipt_id = Some(gr.clone());
373
374        // Add references
375        invoice.header.add_reference(DocumentReference::new(
376            DocumentType::PurchaseOrder,
377            po,
378            DocumentType::VendorInvoice,
379            invoice.header.document_id.clone(),
380            ReferenceType::FollowOn,
381            &cc,
382            invoice_date,
383        ));
384
385        invoice.header.add_reference(DocumentReference::new(
386            DocumentType::GoodsReceipt,
387            gr,
388            DocumentType::VendorInvoice,
389            invoice.header.document_id.clone(),
390            ReferenceType::FollowOn,
391            cc,
392            invoice_date,
393        ));
394
395        invoice
396    }
397
398    /// Set invoice type.
399    pub fn with_invoice_type(mut self, invoice_type: VendorInvoiceType) -> Self {
400        self.invoice_type = invoice_type;
401        self
402    }
403
404    /// Set payment terms.
405    pub fn with_payment_terms(mut self, terms: impl Into<String>, due_days: i64) -> Self {
406        self.payment_terms = terms.into();
407        self.due_date = self.invoice_date + chrono::Duration::days(due_days);
408        self
409    }
410
411    /// Set cash discount.
412    pub fn with_cash_discount(mut self, percent: Decimal, discount_days: i64) -> Self {
413        self.cash_discount_percent = percent;
414        self.discount_due_date = Some(self.invoice_date + chrono::Duration::days(discount_days));
415        self
416    }
417
418    /// Block payment.
419    pub fn block_payment(&mut self, reason: impl Into<String>) {
420        self.payment_block = true;
421        self.payment_block_reason = Some(reason.into());
422        self.verification_status = InvoiceVerificationStatus::BlockedForPayment;
423    }
424
425    /// Unblock payment.
426    pub fn unblock_payment(&mut self) {
427        self.payment_block = false;
428        self.payment_block_reason = None;
429    }
430
431    /// Add a line item.
432    pub fn add_item(&mut self, item: VendorInvoiceItem) {
433        self.items.push(item);
434        self.recalculate_totals();
435    }
436
437    /// Recalculate totals.
438    pub fn recalculate_totals(&mut self) {
439        self.net_amount = self.items.iter().map(|i| i.base.net_amount).sum();
440        self.tax_amount = self.items.iter().map(|i| i.base.tax_amount).sum();
441        self.withholding_tax_amount = self.items.iter().map(|i| i.withholding_tax_amount).sum();
442        self.gross_amount = self.net_amount + self.tax_amount;
443        self.payable_amount = self.gross_amount - self.withholding_tax_amount;
444        self.cash_discount_amount =
445            self.net_amount * self.cash_discount_percent / Decimal::from(100);
446        self.balance = self.payable_amount - self.amount_paid;
447    }
448
449    /// Record payment.
450    pub fn record_payment(&mut self, amount: Decimal, payment_doc_id: impl Into<String>) {
451        self.amount_paid += amount;
452        self.balance = self.payable_amount - self.amount_paid;
453        self.payment_references.push(payment_doc_id.into());
454
455        if self.balance <= Decimal::ZERO {
456            self.is_paid = true;
457            self.header.update_status(DocumentStatus::Cleared, "SYSTEM");
458        }
459    }
460
461    /// Post the invoice.
462    pub fn post(&mut self, user: impl Into<String>, posting_date: NaiveDate) {
463        self.header.posting_date = Some(posting_date);
464        self.header.update_status(DocumentStatus::Posted, user);
465    }
466
467    /// Verify the invoice (three-way match).
468    pub fn verify(&mut self, passed: bool) {
469        self.verification_status = if passed {
470            InvoiceVerificationStatus::ThreeWayMatchPassed
471        } else {
472            InvoiceVerificationStatus::ThreeWayMatchFailed
473        };
474    }
475
476    /// Check if discount is still available.
477    pub fn discount_available(&self, as_of_date: NaiveDate) -> bool {
478        self.discount_due_date.is_some_and(|d| as_of_date <= d)
479    }
480
481    /// Get amount with discount.
482    pub fn discounted_amount(&self, as_of_date: NaiveDate) -> Decimal {
483        if self.discount_available(as_of_date) {
484            self.payable_amount - self.cash_discount_amount
485        } else {
486            self.payable_amount
487        }
488    }
489
490    /// Generate GL entries for invoice posting.
491    /// DR Expense/GR-IR Clearing
492    /// CR AP (Vendor)
493    pub fn generate_gl_entries(&self) -> Vec<(String, Decimal, Decimal, Option<String>)> {
494        let mut entries = Vec::new();
495
496        // Debit entries (expenses or GR/IR clearing)
497        for item in &self.items {
498            let account = if item.po_number.is_some() && item.gr_number.is_some() {
499                "290000".to_string() // GR/IR Clearing
500            } else {
501                item.base
502                    .gl_account
503                    .clone()
504                    .unwrap_or_else(|| "600000".to_string())
505            };
506
507            entries.push((
508                account,
509                item.base.net_amount,
510                Decimal::ZERO,
511                item.base.cost_center.clone(),
512            ));
513        }
514
515        // Tax entry (if applicable)
516        if self.tax_amount > Decimal::ZERO {
517            entries.push((
518                "154000".to_string(), // Input VAT
519                self.tax_amount,
520                Decimal::ZERO,
521                None,
522            ));
523        }
524
525        // Credit entry (AP)
526        entries.push((
527            "210000".to_string(), // AP
528            Decimal::ZERO,
529            self.gross_amount,
530            None,
531        ));
532
533        entries
534    }
535}
536
537#[cfg(test)]
538#[allow(clippy::unwrap_used)]
539mod tests {
540    use super::*;
541
542    #[test]
543    fn test_vendor_invoice_creation() {
544        let invoice = VendorInvoice::new(
545            "VI-1000-0000000001",
546            "1000",
547            "V-000001",
548            "INV-2024-001",
549            2024,
550            1,
551            NaiveDate::from_ymd_opt(2024, 1, 15).unwrap(),
552            "JSMITH",
553        );
554
555        assert_eq!(invoice.vendor_id, "V-000001");
556        assert_eq!(
557            invoice.due_date,
558            NaiveDate::from_ymd_opt(2024, 2, 14).unwrap()
559        );
560    }
561
562    #[test]
563    fn test_vendor_invoice_with_items() {
564        let mut invoice = VendorInvoice::new(
565            "VI-1000-0000000001",
566            "1000",
567            "V-000001",
568            "INV-2024-001",
569            2024,
570            1,
571            NaiveDate::from_ymd_opt(2024, 1, 15).unwrap(),
572            "JSMITH",
573        );
574
575        invoice.add_item(
576            VendorInvoiceItem::new(1, "Office Supplies", Decimal::from(10), Decimal::from(25))
577                .with_tax("VAT10", Decimal::from(25)),
578        );
579
580        assert_eq!(invoice.net_amount, Decimal::from(250));
581        assert_eq!(invoice.tax_amount, Decimal::from(25));
582        assert_eq!(invoice.gross_amount, Decimal::from(275));
583    }
584
585    #[test]
586    fn test_payment_recording() {
587        let mut invoice = VendorInvoice::new(
588            "VI-1000-0000000001",
589            "1000",
590            "V-000001",
591            "INV-2024-001",
592            2024,
593            1,
594            NaiveDate::from_ymd_opt(2024, 1, 15).unwrap(),
595            "JSMITH",
596        );
597
598        invoice.add_item(VendorInvoiceItem::new(
599            1,
600            "Test",
601            Decimal::from(1),
602            Decimal::from(1000),
603        ));
604
605        invoice.record_payment(Decimal::from(500), "PAY-001");
606        assert_eq!(invoice.balance, Decimal::from(500));
607        assert!(!invoice.is_paid);
608
609        invoice.record_payment(Decimal::from(500), "PAY-002");
610        assert_eq!(invoice.balance, Decimal::ZERO);
611        assert!(invoice.is_paid);
612    }
613
614    #[test]
615    fn test_cash_discount() {
616        let invoice = VendorInvoice::new(
617            "VI-1000-0000000001",
618            "1000",
619            "V-000001",
620            "INV-2024-001",
621            2024,
622            1,
623            NaiveDate::from_ymd_opt(2024, 1, 15).unwrap(),
624            "JSMITH",
625        )
626        .with_cash_discount(Decimal::from(2), 10);
627
628        let early_date = NaiveDate::from_ymd_opt(2024, 1, 20).unwrap();
629        let late_date = NaiveDate::from_ymd_opt(2024, 2, 1).unwrap();
630
631        assert!(invoice.discount_available(early_date));
632        assert!(!invoice.discount_available(late_date));
633    }
634}