1use chrono::NaiveDate;
7use rust_decimal::Decimal;
8use serde::{Deserialize, Serialize};
9
10use super::{
11 DocumentHeader, DocumentLineItem, DocumentReference, DocumentStatus, DocumentType,
12 ReferenceType,
13};
14
15#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize, Default)]
17#[serde(rename_all = "snake_case")]
18pub enum VendorInvoiceType {
19 #[default]
21 Standard,
22 CreditMemo,
24 SubsequentAdjustment,
26 DownPaymentRequest,
28 InvoicePlan,
30 Recurring,
32}
33
34#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize, Default)]
36#[serde(rename_all = "snake_case")]
37pub enum InvoiceVerificationStatus {
38 #[default]
40 Unverified,
41 ThreeWayMatchPassed,
43 ThreeWayMatchFailed,
45 ManuallyApproved,
47 BlockedForPayment,
49}
50
51#[derive(Debug, Clone, Serialize, Deserialize)]
53pub struct VendorInvoiceItem {
54 #[serde(flatten)]
56 pub base: DocumentLineItem,
57
58 pub po_number: Option<String>,
60
61 pub po_item: Option<u16>,
63
64 pub gr_number: Option<String>,
66
67 pub gr_item: Option<u16>,
69
70 pub invoiced_quantity: Decimal,
72
73 pub match_status: ThreeWayMatchStatus,
75
76 pub price_variance: Decimal,
78
79 pub quantity_variance: Decimal,
81
82 pub tax_code: Option<String>,
84
85 pub withholding_tax: bool,
87
88 pub withholding_tax_amount: Decimal,
90}
91
92#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize, Default)]
94#[serde(rename_all = "snake_case")]
95pub enum ThreeWayMatchStatus {
96 #[default]
98 NotApplicable,
99 Matched,
101 PriceMismatch,
103 QuantityMismatch,
105 BothMismatch,
107 GrNotReceived,
109}
110
111impl VendorInvoiceItem {
112 #[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 #[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 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 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 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 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 pub fn with_match_status(mut self, status: ThreeWayMatchStatus) -> Self {
185 self.match_status = status;
186 self
187 }
188
189 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 pub fn calculate_quantity_variance(&mut self, gr_quantity: Decimal) {
196 self.quantity_variance = self.base.quantity - gr_quantity;
197 }
198}
199
200#[derive(Debug, Clone, Serialize, Deserialize)]
202pub struct VendorInvoice {
203 pub header: DocumentHeader,
205
206 pub invoice_type: VendorInvoiceType,
208
209 pub vendor_id: String,
211
212 pub vendor_invoice_number: String,
214
215 pub invoice_date: NaiveDate,
217
218 pub items: Vec<VendorInvoiceItem>,
220
221 pub net_amount: Decimal,
223
224 pub tax_amount: Decimal,
226
227 pub gross_amount: Decimal,
229
230 pub withholding_tax_amount: Decimal,
232
233 pub payable_amount: Decimal,
235
236 pub payment_terms: String,
238
239 pub due_date: NaiveDate,
241
242 pub discount_due_date: Option<NaiveDate>,
244
245 pub cash_discount_percent: Decimal,
247
248 pub cash_discount_amount: Decimal,
250
251 pub verification_status: InvoiceVerificationStatus,
253
254 pub payment_block: bool,
256
257 pub payment_block_reason: Option<String>,
259
260 pub purchase_order_id: Option<String>,
262
263 pub goods_receipt_id: Option<String>,
265
266 pub is_paid: bool,
268
269 pub amount_paid: Decimal,
271
272 pub balance: Decimal,
274
275 pub payment_references: Vec<String>,
277
278 pub baseline_date: NaiveDate,
280}
281
282impl VendorInvoice {
283 #[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 #[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 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 pub fn with_invoice_type(mut self, invoice_type: VendorInvoiceType) -> Self {
395 self.invoice_type = invoice_type;
396 self
397 }
398
399 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 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 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 pub fn unblock_payment(&mut self) {
422 self.payment_block = false;
423 self.payment_block_reason = None;
424 }
425
426 pub fn add_item(&mut self, item: VendorInvoiceItem) {
428 self.items.push(item);
429 self.recalculate_totals();
430 }
431
432 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 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 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 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 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 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 pub fn generate_gl_entries(&self) -> Vec<(String, Decimal, Decimal, Option<String>)> {
489 let mut entries = Vec::new();
490
491 for item in &self.items {
493 let account = if item.po_number.is_some() && item.gr_number.is_some() {
494 "290000".to_string() } 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 if self.tax_amount > Decimal::ZERO {
512 entries.push((
513 "154000".to_string(), self.tax_amount,
515 Decimal::ZERO,
516 None,
517 ));
518 }
519
520 entries.push((
522 "210000".to_string(), 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}