use chrono::NaiveDate;
use rust_decimal::Decimal;
use serde::{Deserialize, Serialize};
use super::{
DocumentHeader, DocumentLineItem, DocumentReference, DocumentStatus, DocumentType,
ReferenceType,
};
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize, Default)]
#[serde(rename_all = "snake_case")]
pub enum VendorInvoiceType {
#[default]
Standard,
CreditMemo,
SubsequentAdjustment,
DownPaymentRequest,
InvoicePlan,
Recurring,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize, Default)]
#[serde(rename_all = "snake_case")]
pub enum InvoiceVerificationStatus {
#[default]
Unverified,
ThreeWayMatchPassed,
ThreeWayMatchFailed,
ManuallyApproved,
BlockedForPayment,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct VendorInvoiceItem {
#[serde(flatten)]
pub base: DocumentLineItem,
pub po_number: Option<String>,
pub po_item: Option<u16>,
pub gr_number: Option<String>,
pub gr_item: Option<u16>,
pub invoiced_quantity: Decimal,
pub match_status: ThreeWayMatchStatus,
pub price_variance: Decimal,
pub quantity_variance: Decimal,
pub tax_code: Option<String>,
pub withholding_tax: bool,
pub withholding_tax_amount: Decimal,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize, Default)]
#[serde(rename_all = "snake_case")]
pub enum ThreeWayMatchStatus {
#[default]
NotApplicable,
Matched,
PriceMismatch,
QuantityMismatch,
BothMismatch,
GrNotReceived,
}
impl VendorInvoiceItem {
#[allow(clippy::too_many_arguments)]
pub fn new(
line_number: u16,
description: impl Into<String>,
quantity: Decimal,
unit_price: Decimal,
) -> Self {
let base = DocumentLineItem::new(line_number, description, quantity, unit_price);
Self {
base,
po_number: None,
po_item: None,
gr_number: None,
gr_item: None,
invoiced_quantity: quantity,
match_status: ThreeWayMatchStatus::NotApplicable,
price_variance: Decimal::ZERO,
quantity_variance: Decimal::ZERO,
tax_code: None,
withholding_tax: false,
withholding_tax_amount: Decimal::ZERO,
}
}
#[allow(clippy::too_many_arguments)]
pub fn from_po_gr(
line_number: u16,
description: impl Into<String>,
quantity: Decimal,
unit_price: Decimal,
po_number: impl Into<String>,
po_item: u16,
gr_number: Option<String>,
gr_item: Option<u16>,
) -> Self {
let mut item = Self::new(line_number, description, quantity, unit_price);
item.po_number = Some(po_number.into());
item.po_item = Some(po_item);
item.gr_number = gr_number;
item.gr_item = gr_item;
item
}
pub fn with_gl_account(mut self, account: impl Into<String>) -> Self {
self.base = self.base.with_gl_account(account);
self
}
pub fn with_cost_center(mut self, cost_center: impl Into<String>) -> Self {
self.base = self.base.with_cost_center(cost_center);
self
}
pub fn with_tax(mut self, tax_code: impl Into<String>, tax_amount: Decimal) -> Self {
self.tax_code = Some(tax_code.into());
self.base = self.base.with_tax(tax_amount);
self
}
pub fn with_withholding_tax(mut self, amount: Decimal) -> Self {
self.withholding_tax = true;
self.withholding_tax_amount = amount;
self
}
pub fn with_match_status(mut self, status: ThreeWayMatchStatus) -> Self {
self.match_status = status;
self
}
pub fn calculate_price_variance(&mut self, po_price: Decimal) {
self.price_variance = (self.base.unit_price - po_price) * self.base.quantity;
}
pub fn calculate_quantity_variance(&mut self, gr_quantity: Decimal) {
self.quantity_variance = self.base.quantity - gr_quantity;
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct VendorInvoice {
pub header: DocumentHeader,
pub invoice_type: VendorInvoiceType,
pub vendor_id: String,
pub vendor_invoice_number: String,
pub invoice_date: NaiveDate,
pub items: Vec<VendorInvoiceItem>,
pub net_amount: Decimal,
pub tax_amount: Decimal,
pub gross_amount: Decimal,
pub withholding_tax_amount: Decimal,
pub payable_amount: Decimal,
pub payment_terms: String,
pub due_date: NaiveDate,
pub discount_due_date: Option<NaiveDate>,
pub cash_discount_percent: Decimal,
pub cash_discount_amount: Decimal,
pub verification_status: InvoiceVerificationStatus,
pub payment_block: bool,
pub payment_block_reason: Option<String>,
pub purchase_order_id: Option<String>,
pub goods_receipt_id: Option<String>,
pub is_paid: bool,
pub amount_paid: Decimal,
pub balance: Decimal,
pub payment_references: Vec<String>,
pub baseline_date: NaiveDate,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub vendor_name: Option<String>,
}
impl VendorInvoice {
#[allow(clippy::too_many_arguments)]
pub fn new(
invoice_id: impl Into<String>,
company_code: impl Into<String>,
vendor_id: impl Into<String>,
vendor_invoice_number: impl Into<String>,
fiscal_year: u16,
fiscal_period: u8,
invoice_date: NaiveDate,
created_by: impl Into<String>,
) -> Self {
let header = DocumentHeader::new(
invoice_id,
DocumentType::VendorInvoice,
company_code,
fiscal_year,
fiscal_period,
invoice_date,
created_by,
);
let due_date = invoice_date + chrono::Duration::days(30);
Self {
header,
invoice_type: VendorInvoiceType::Standard,
vendor_id: vendor_id.into(),
vendor_invoice_number: vendor_invoice_number.into(),
invoice_date,
items: Vec::new(),
net_amount: Decimal::ZERO,
tax_amount: Decimal::ZERO,
gross_amount: Decimal::ZERO,
withholding_tax_amount: Decimal::ZERO,
payable_amount: Decimal::ZERO,
payment_terms: "NET30".to_string(),
due_date,
discount_due_date: None,
cash_discount_percent: Decimal::ZERO,
cash_discount_amount: Decimal::ZERO,
verification_status: InvoiceVerificationStatus::Unverified,
payment_block: false,
payment_block_reason: None,
purchase_order_id: None,
goods_receipt_id: None,
is_paid: false,
amount_paid: Decimal::ZERO,
balance: Decimal::ZERO,
payment_references: Vec::new(),
baseline_date: invoice_date,
vendor_name: None,
}
}
#[allow(clippy::too_many_arguments)]
pub fn from_po_gr(
invoice_id: impl Into<String>,
company_code: impl Into<String>,
vendor_id: impl Into<String>,
vendor_invoice_number: impl Into<String>,
po_id: impl Into<String>,
gr_id: impl Into<String>,
fiscal_year: u16,
fiscal_period: u8,
invoice_date: NaiveDate,
created_by: impl Into<String>,
) -> Self {
let po = po_id.into();
let gr = gr_id.into();
let cc = company_code.into();
let mut invoice = Self::new(
invoice_id,
&cc,
vendor_id,
vendor_invoice_number,
fiscal_year,
fiscal_period,
invoice_date,
created_by,
);
invoice.purchase_order_id = Some(po.clone());
invoice.goods_receipt_id = Some(gr.clone());
invoice.header.add_reference(DocumentReference::new(
DocumentType::PurchaseOrder,
po,
DocumentType::VendorInvoice,
invoice.header.document_id.clone(),
ReferenceType::FollowOn,
&cc,
invoice_date,
));
invoice.header.add_reference(DocumentReference::new(
DocumentType::GoodsReceipt,
gr,
DocumentType::VendorInvoice,
invoice.header.document_id.clone(),
ReferenceType::FollowOn,
cc,
invoice_date,
));
invoice
}
pub fn with_invoice_type(mut self, invoice_type: VendorInvoiceType) -> Self {
self.invoice_type = invoice_type;
self
}
pub fn with_payment_terms(mut self, terms: impl Into<String>, due_days: i64) -> Self {
self.payment_terms = terms.into();
self.due_date = self.invoice_date + chrono::Duration::days(due_days);
self
}
pub fn with_cash_discount(mut self, percent: Decimal, discount_days: i64) -> Self {
self.cash_discount_percent = percent;
self.discount_due_date = Some(self.invoice_date + chrono::Duration::days(discount_days));
self
}
pub fn block_payment(&mut self, reason: impl Into<String>) {
self.payment_block = true;
self.payment_block_reason = Some(reason.into());
self.verification_status = InvoiceVerificationStatus::BlockedForPayment;
}
pub fn unblock_payment(&mut self) {
self.payment_block = false;
self.payment_block_reason = None;
}
pub fn add_item(&mut self, item: VendorInvoiceItem) {
self.items.push(item);
self.recalculate_totals();
}
pub fn recalculate_totals(&mut self) {
self.net_amount = self.items.iter().map(|i| i.base.net_amount).sum();
self.tax_amount = self.items.iter().map(|i| i.base.tax_amount).sum();
self.withholding_tax_amount = self.items.iter().map(|i| i.withholding_tax_amount).sum();
self.gross_amount = self.net_amount + self.tax_amount;
self.payable_amount = self.gross_amount - self.withholding_tax_amount;
self.cash_discount_amount =
self.net_amount * self.cash_discount_percent / Decimal::from(100);
self.balance = self.payable_amount - self.amount_paid;
}
pub fn record_payment(&mut self, amount: Decimal, payment_doc_id: impl Into<String>) {
self.amount_paid += amount;
self.balance = self.payable_amount - self.amount_paid;
self.payment_references.push(payment_doc_id.into());
if self.balance <= Decimal::ZERO {
self.is_paid = true;
self.header.update_status(DocumentStatus::Cleared, "SYSTEM");
}
}
pub fn post(&mut self, user: impl Into<String>, posting_date: NaiveDate) {
self.header.posting_date = Some(posting_date);
self.header.update_status(DocumentStatus::Posted, user);
}
pub fn verify(&mut self, passed: bool) {
self.verification_status = if passed {
InvoiceVerificationStatus::ThreeWayMatchPassed
} else {
InvoiceVerificationStatus::ThreeWayMatchFailed
};
}
pub fn discount_available(&self, as_of_date: NaiveDate) -> bool {
self.discount_due_date.is_some_and(|d| as_of_date <= d)
}
pub fn discounted_amount(&self, as_of_date: NaiveDate) -> Decimal {
if self.discount_available(as_of_date) {
self.payable_amount - self.cash_discount_amount
} else {
self.payable_amount
}
}
pub fn generate_gl_entries(&self) -> Vec<(String, Decimal, Decimal, Option<String>)> {
let mut entries = Vec::new();
for item in &self.items {
let account = if item.po_number.is_some() && item.gr_number.is_some() {
"290000".to_string() } else {
item.base
.gl_account
.clone()
.unwrap_or_else(|| "600000".to_string())
};
entries.push((
account,
item.base.net_amount,
Decimal::ZERO,
item.base.cost_center.clone(),
));
}
if self.tax_amount > Decimal::ZERO {
entries.push((
"154000".to_string(), self.tax_amount,
Decimal::ZERO,
None,
));
}
entries.push((
"210000".to_string(), Decimal::ZERO,
self.gross_amount,
None,
));
entries
}
}
#[cfg(test)]
#[allow(clippy::unwrap_used)]
mod tests {
use super::*;
#[test]
fn test_vendor_invoice_creation() {
let invoice = VendorInvoice::new(
"VI-1000-0000000001",
"1000",
"V-000001",
"INV-2024-001",
2024,
1,
NaiveDate::from_ymd_opt(2024, 1, 15).unwrap(),
"JSMITH",
);
assert_eq!(invoice.vendor_id, "V-000001");
assert_eq!(
invoice.due_date,
NaiveDate::from_ymd_opt(2024, 2, 14).unwrap()
);
}
#[test]
fn test_vendor_invoice_with_items() {
let mut invoice = VendorInvoice::new(
"VI-1000-0000000001",
"1000",
"V-000001",
"INV-2024-001",
2024,
1,
NaiveDate::from_ymd_opt(2024, 1, 15).unwrap(),
"JSMITH",
);
invoice.add_item(
VendorInvoiceItem::new(1, "Office Supplies", Decimal::from(10), Decimal::from(25))
.with_tax("VAT10", Decimal::from(25)),
);
assert_eq!(invoice.net_amount, Decimal::from(250));
assert_eq!(invoice.tax_amount, Decimal::from(25));
assert_eq!(invoice.gross_amount, Decimal::from(275));
}
#[test]
fn test_payment_recording() {
let mut invoice = VendorInvoice::new(
"VI-1000-0000000001",
"1000",
"V-000001",
"INV-2024-001",
2024,
1,
NaiveDate::from_ymd_opt(2024, 1, 15).unwrap(),
"JSMITH",
);
invoice.add_item(VendorInvoiceItem::new(
1,
"Test",
Decimal::from(1),
Decimal::from(1000),
));
invoice.record_payment(Decimal::from(500), "PAY-001");
assert_eq!(invoice.balance, Decimal::from(500));
assert!(!invoice.is_paid);
invoice.record_payment(Decimal::from(500), "PAY-002");
assert_eq!(invoice.balance, Decimal::ZERO);
assert!(invoice.is_paid);
}
#[test]
fn test_cash_discount() {
let invoice = VendorInvoice::new(
"VI-1000-0000000001",
"1000",
"V-000001",
"INV-2024-001",
2024,
1,
NaiveDate::from_ymd_opt(2024, 1, 15).unwrap(),
"JSMITH",
)
.with_cash_discount(Decimal::from(2), 10);
let early_date = NaiveDate::from_ymd_opt(2024, 1, 20).unwrap();
let late_date = NaiveDate::from_ymd_opt(2024, 2, 1).unwrap();
assert!(invoice.discount_available(early_date));
assert!(!invoice.discount_available(late_date));
}
}