1use chrono::{DateTime, NaiveDate, Utc};
4use rust_decimal::Decimal;
5use rust_decimal_macros::dec;
6use serde::{Deserialize, Serialize};
7
8use crate::models::subledger::{
9 ClearingInfo, CurrencyAmount, GLReference, PaymentTerms, SubledgerDocumentStatus, TaxInfo,
10};
11
12#[derive(Debug, Clone, Serialize, Deserialize)]
14pub struct APInvoice {
15 pub invoice_number: String,
17 pub vendor_invoice_number: String,
19 pub company_code: String,
21 pub vendor_id: String,
23 pub vendor_name: String,
25 pub invoice_date: NaiveDate,
27 pub posting_date: NaiveDate,
29 pub document_date: NaiveDate,
31 pub due_date: NaiveDate,
33 pub baseline_date: NaiveDate,
35 pub invoice_type: APInvoiceType,
37 pub status: SubledgerDocumentStatus,
39 pub lines: Vec<APInvoiceLine>,
41 pub net_amount: CurrencyAmount,
43 pub tax_amount: CurrencyAmount,
45 pub gross_amount: CurrencyAmount,
47 pub amount_paid: Decimal,
49 pub amount_remaining: Decimal,
51 pub payment_terms: PaymentTerms,
53 pub tax_details: Vec<TaxInfo>,
55 pub gl_reference: Option<GLReference>,
57 pub clearing_info: Vec<ClearingInfo>,
59 pub match_status: MatchStatus,
61 pub reference_po: Option<String>,
63 pub reference_gr: Option<String>,
65 pub payment_block: Option<PaymentBlockReason>,
67 pub withholding_tax: Option<WithholdingTax>,
69 #[serde(with = "crate::serde_timestamp::utc")]
71 pub created_at: DateTime<Utc>,
72 pub created_by: Option<String>,
74 #[serde(default, with = "crate::serde_timestamp::utc::option")]
76 pub modified_at: Option<DateTime<Utc>>,
77 pub notes: Option<String>,
79}
80
81impl APInvoice {
82 #[allow(clippy::too_many_arguments)]
84 pub fn new(
85 invoice_number: String,
86 vendor_invoice_number: String,
87 company_code: String,
88 vendor_id: String,
89 vendor_name: String,
90 invoice_date: NaiveDate,
91 payment_terms: PaymentTerms,
92 currency: String,
93 ) -> Self {
94 let due_date = payment_terms.calculate_due_date(invoice_date);
95
96 Self {
97 invoice_number,
98 vendor_invoice_number,
99 company_code,
100 vendor_id,
101 vendor_name,
102 invoice_date,
103 posting_date: invoice_date,
104 document_date: invoice_date,
105 due_date,
106 baseline_date: invoice_date,
107 invoice_type: APInvoiceType::Standard,
108 status: SubledgerDocumentStatus::Open,
109 lines: Vec::new(),
110 net_amount: CurrencyAmount::single_currency(Decimal::ZERO, currency.clone()),
111 tax_amount: CurrencyAmount::single_currency(Decimal::ZERO, currency.clone()),
112 gross_amount: CurrencyAmount::single_currency(Decimal::ZERO, currency),
113 amount_paid: Decimal::ZERO,
114 amount_remaining: Decimal::ZERO,
115 payment_terms,
116 tax_details: Vec::new(),
117 gl_reference: None,
118 clearing_info: Vec::new(),
119 match_status: MatchStatus::NotMatched,
120 reference_po: None,
121 reference_gr: None,
122 payment_block: None,
123 withholding_tax: None,
124 created_at: Utc::now(),
125 created_by: None,
126 modified_at: None,
127 notes: None,
128 }
129 }
130
131 pub fn add_line(&mut self, line: APInvoiceLine) {
133 self.lines.push(line);
134 self.recalculate_totals();
135 }
136
137 pub fn recalculate_totals(&mut self) {
139 let net_total: Decimal = self.lines.iter().map(|l| l.net_amount).sum();
140 let tax_total: Decimal = self.lines.iter().map(|l| l.tax_amount).sum();
141 let gross_total = net_total + tax_total;
142
143 self.net_amount.document_amount = net_total;
144 self.net_amount.local_amount = net_total * self.net_amount.exchange_rate;
145 self.tax_amount.document_amount = tax_total;
146 self.tax_amount.local_amount = tax_total * self.tax_amount.exchange_rate;
147 self.gross_amount.document_amount = gross_total;
148 self.gross_amount.local_amount = gross_total * self.gross_amount.exchange_rate;
149 self.amount_remaining = gross_total - self.amount_paid;
150 }
151
152 pub fn apply_payment(&mut self, amount: Decimal, clearing: ClearingInfo) {
154 self.amount_paid += amount;
155 self.amount_remaining = self.gross_amount.document_amount - self.amount_paid;
156 self.clearing_info.push(clearing);
157
158 self.status = if self.amount_remaining <= Decimal::ZERO {
159 SubledgerDocumentStatus::Cleared
160 } else {
161 SubledgerDocumentStatus::PartiallyCleared
162 };
163
164 self.modified_at = Some(Utc::now());
165 }
166
167 pub fn is_overdue(&self, as_of_date: NaiveDate) -> bool {
169 self.status == SubledgerDocumentStatus::Open && as_of_date > self.due_date
170 }
171
172 pub fn days_overdue(&self, as_of_date: NaiveDate) -> i64 {
174 if self.is_overdue(as_of_date) {
175 (as_of_date - self.due_date).num_days()
176 } else {
177 0
178 }
179 }
180
181 pub fn available_discount(&self, payment_date: NaiveDate) -> Decimal {
183 self.payment_terms.calculate_discount(
184 self.gross_amount.document_amount,
185 payment_date,
186 self.baseline_date,
187 )
188 }
189
190 pub fn with_po_reference(mut self, po_number: String) -> Self {
192 self.reference_po = Some(po_number);
193 self
194 }
195
196 pub fn with_gr_reference(mut self, gr_number: String) -> Self {
198 self.reference_gr = Some(gr_number);
199 self
200 }
201
202 pub fn block_payment(&mut self, reason: PaymentBlockReason) {
204 self.payment_block = Some(reason);
205 }
206
207 pub fn unblock_payment(&mut self) {
209 self.payment_block = None;
210 }
211
212 pub fn is_blocked(&self) -> bool {
214 self.payment_block.is_some()
215 }
216
217 pub fn set_match_status(&mut self, status: MatchStatus) {
219 let should_block = matches!(
220 &status,
221 MatchStatus::MatchedWithVariance { .. } | MatchStatus::NotMatched
222 );
223 self.match_status = status;
224 if should_block {
225 self.payment_block = Some(PaymentBlockReason::MatchException);
226 }
227 }
228
229 pub fn is_payable(&self) -> bool {
231 !self.is_blocked()
232 && self.status == SubledgerDocumentStatus::Open
233 && matches!(
234 self.match_status,
235 MatchStatus::Matched | MatchStatus::NotRequired
236 )
237 }
238
239 pub fn with_withholding_tax(mut self, wht: WithholdingTax) -> Self {
241 self.withholding_tax = Some(wht);
242 self
243 }
244
245 pub fn net_payable(&self) -> Decimal {
247 let wht_amount = self
248 .withholding_tax
249 .as_ref()
250 .map(|w| w.amount)
251 .unwrap_or_default();
252 self.amount_remaining - wht_amount
253 }
254
255 pub fn reverse(&mut self, reversal_date: NaiveDate, reason: String) {
257 self.status = SubledgerDocumentStatus::Reversed;
258 self.notes = Some(format!(
259 "{}Reversed on {}: {}",
260 self.notes
261 .as_ref()
262 .map(|n| format!("{n}. "))
263 .unwrap_or_default(),
264 reversal_date,
265 reason
266 ));
267 self.modified_at = Some(Utc::now());
268 }
269}
270
271#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
273pub enum APInvoiceType {
274 #[default]
276 Standard,
277 DownPayment,
279 CreditMemo,
281 Recurring,
283 Intercompany,
285 Service,
287 Expense,
289}
290
291#[derive(Debug, Clone, Serialize, Deserialize, Default)]
293pub enum MatchStatus {
294 #[default]
296 NotMatched,
297 Matched,
299 MatchedWithVariance {
301 price_variance: Decimal,
303 quantity_variance: Decimal,
305 },
306 TwoWayMatched,
308 NotRequired,
310}
311
312#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
314pub enum PaymentBlockReason {
315 QualityHold,
317 PriceVariance,
319 QuantityVariance,
321 MissingDocumentation,
323 Review,
325 MatchException,
327 DuplicateSuspect,
329 ManualBlock,
331 Other,
333}
334
335#[derive(Debug, Clone, Serialize, Deserialize)]
337pub struct WithholdingTax {
338 pub wht_type: String,
340 pub rate: Decimal,
342 pub base_amount: Decimal,
344 pub amount: Decimal,
346}
347
348impl WithholdingTax {
349 pub fn new(wht_type: String, rate: Decimal, base_amount: Decimal) -> Self {
351 let amount = (base_amount * rate / dec!(100)).round_dp(2);
352 Self {
353 wht_type,
354 rate,
355 base_amount,
356 amount,
357 }
358 }
359}
360
361#[derive(Debug, Clone, Serialize, Deserialize)]
363pub struct APInvoiceLine {
364 pub line_number: u32,
366 pub material_id: Option<String>,
368 pub description: String,
370 pub quantity: Decimal,
372 pub unit: String,
374 pub unit_price: Decimal,
376 pub net_amount: Decimal,
378 pub tax_code: Option<String>,
380 pub tax_rate: Decimal,
382 pub tax_amount: Decimal,
384 pub gross_amount: Decimal,
386 pub gl_account: String,
388 pub cost_center: Option<String>,
390 pub internal_order: Option<String>,
392 pub wbs_element: Option<String>,
394 pub asset_number: Option<String>,
396 pub po_line: Option<u32>,
398 pub gr_line: Option<u32>,
400}
401
402impl APInvoiceLine {
403 pub fn new(
405 line_number: u32,
406 description: String,
407 quantity: Decimal,
408 unit: String,
409 unit_price: Decimal,
410 gl_account: String,
411 ) -> Self {
412 let net_amount = (quantity * unit_price).round_dp(2);
413 Self {
414 line_number,
415 material_id: None,
416 description,
417 quantity,
418 unit,
419 unit_price,
420 net_amount,
421 tax_code: None,
422 tax_rate: Decimal::ZERO,
423 tax_amount: Decimal::ZERO,
424 gross_amount: net_amount,
425 gl_account,
426 cost_center: None,
427 internal_order: None,
428 wbs_element: None,
429 asset_number: None,
430 po_line: None,
431 gr_line: None,
432 }
433 }
434
435 pub fn with_tax(mut self, tax_code: String, tax_rate: Decimal) -> Self {
437 self.tax_code = Some(tax_code);
438 self.tax_rate = tax_rate;
439 self.tax_amount = (self.net_amount * tax_rate / dec!(100)).round_dp(2);
440 self.gross_amount = self.net_amount + self.tax_amount;
441 self
442 }
443
444 pub fn with_cost_center(mut self, cost_center: String) -> Self {
446 self.cost_center = Some(cost_center);
447 self
448 }
449
450 pub fn with_po_reference(mut self, po_line: u32) -> Self {
452 self.po_line = Some(po_line);
453 self
454 }
455
456 pub fn with_asset(mut self, asset_number: String) -> Self {
458 self.asset_number = Some(asset_number);
459 self
460 }
461}
462
463#[derive(Debug, Clone, Serialize, Deserialize)]
465pub struct VendorAPSummary {
466 pub vendor_id: String,
468 pub vendor_name: String,
470 pub total_open: Decimal,
472 pub total_overdue: Decimal,
474 pub open_invoice_count: u32,
476 pub coming_due_7d: Decimal,
478 pub coming_due_30d: Decimal,
480 pub available_discount: Decimal,
482}
483
484impl VendorAPSummary {
485 pub fn from_invoices(
487 vendor_id: String,
488 vendor_name: String,
489 invoices: &[APInvoice],
490 as_of_date: NaiveDate,
491 ) -> Self {
492 let open_invoices: Vec<_> = invoices
493 .iter()
494 .filter(|i| {
495 i.vendor_id == vendor_id
496 && matches!(
497 i.status,
498 SubledgerDocumentStatus::Open | SubledgerDocumentStatus::PartiallyCleared
499 )
500 })
501 .collect();
502
503 let total_open: Decimal = open_invoices.iter().map(|i| i.amount_remaining).sum();
504 let total_overdue: Decimal = open_invoices
505 .iter()
506 .filter(|i| i.is_overdue(as_of_date))
507 .map(|i| i.amount_remaining)
508 .sum();
509
510 let due_7d = as_of_date + chrono::Duration::days(7);
511 let due_30d = as_of_date + chrono::Duration::days(30);
512
513 let coming_due_7d: Decimal = open_invoices
514 .iter()
515 .filter(|i| i.due_date <= due_7d && i.due_date > as_of_date)
516 .map(|i| i.amount_remaining)
517 .sum();
518
519 let coming_due_30d: Decimal = open_invoices
520 .iter()
521 .filter(|i| i.due_date <= due_30d && i.due_date > as_of_date)
522 .map(|i| i.amount_remaining)
523 .sum();
524
525 let available_discount: Decimal = open_invoices
526 .iter()
527 .map(|i| i.available_discount(as_of_date))
528 .sum();
529
530 Self {
531 vendor_id,
532 vendor_name,
533 total_open,
534 total_overdue,
535 open_invoice_count: open_invoices.len() as u32,
536 coming_due_7d,
537 coming_due_30d,
538 available_discount,
539 }
540 }
541}
542
543#[cfg(test)]
544#[allow(clippy::unwrap_used)]
545mod tests {
546 use super::*;
547
548 fn create_test_invoice() -> APInvoice {
549 let mut invoice = APInvoice::new(
550 "AP001".to_string(),
551 "VINV-2024-001".to_string(),
552 "1000".to_string(),
553 "VEND001".to_string(),
554 "Test Vendor".to_string(),
555 NaiveDate::from_ymd_opt(2024, 1, 15).unwrap(),
556 PaymentTerms::two_ten_net_30(),
557 "USD".to_string(),
558 );
559
560 let line = APInvoiceLine::new(
561 1,
562 "Office Supplies".to_string(),
563 dec!(100),
564 "EA".to_string(),
565 dec!(10),
566 "5000".to_string(),
567 )
568 .with_tax("VAT".to_string(), dec!(10));
569
570 invoice.add_line(line);
571 invoice
572 }
573
574 #[test]
575 fn test_invoice_totals() {
576 let invoice = create_test_invoice();
577 assert_eq!(invoice.net_amount.document_amount, dec!(1000));
578 assert_eq!(invoice.tax_amount.document_amount, dec!(100));
579 assert_eq!(invoice.gross_amount.document_amount, dec!(1100));
580 }
581
582 #[test]
583 fn test_discount_calculation() {
584 let invoice = create_test_invoice();
585 let early_date = NaiveDate::from_ymd_opt(2024, 1, 20).unwrap();
586 let late_date = NaiveDate::from_ymd_opt(2024, 2, 1).unwrap();
587
588 let early_discount = invoice.available_discount(early_date);
589 let late_discount = invoice.available_discount(late_date);
590
591 assert_eq!(early_discount, dec!(22)); assert_eq!(late_discount, Decimal::ZERO);
593 }
594
595 #[test]
596 fn test_payment_block() {
597 let mut invoice = create_test_invoice();
598 invoice.set_match_status(MatchStatus::NotRequired);
600 assert!(invoice.is_payable());
601
602 invoice.block_payment(PaymentBlockReason::QualityHold);
603 assert!(!invoice.is_payable());
604 assert!(invoice.is_blocked());
605
606 invoice.unblock_payment();
607 assert!(invoice.is_payable());
608 }
609
610 #[test]
611 fn test_withholding_tax() {
612 let invoice = create_test_invoice().with_withholding_tax(WithholdingTax::new(
613 "WHT10".to_string(),
614 dec!(10),
615 dec!(1000),
616 ));
617
618 assert_eq!(invoice.withholding_tax.as_ref().unwrap().amount, dec!(100));
619 assert_eq!(invoice.net_payable(), dec!(1000)); }
621}