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 #[serde(default, skip_serializing_if = "Option::is_none")]
283 pub vendor_name: Option<String>,
284}
285
286impl VendorInvoice {
287 #[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 #[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 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 pub fn with_invoice_type(mut self, invoice_type: VendorInvoiceType) -> Self {
400 self.invoice_type = invoice_type;
401 self
402 }
403
404 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 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 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 pub fn unblock_payment(&mut self) {
427 self.payment_block = false;
428 self.payment_block_reason = None;
429 }
430
431 pub fn add_item(&mut self, item: VendorInvoiceItem) {
433 self.items.push(item);
434 self.recalculate_totals();
435 }
436
437 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 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 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 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 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 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 pub fn generate_gl_entries(&self) -> Vec<(String, Decimal, Decimal, Option<String>)> {
494 let mut entries = Vec::new();
495
496 for item in &self.items {
498 let account = if item.po_number.is_some() && item.gr_number.is_some() {
499 "290000".to_string() } 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 if self.tax_amount > Decimal::ZERO {
517 entries.push((
518 "154000".to_string(), self.tax_amount,
520 Decimal::ZERO,
521 None,
522 ));
523 }
524
525 entries.push((
527 "210000".to_string(), 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}