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