Skip to main content

datasynth_core/models/subledger/ap/
invoice.rs

1//! AP Invoice (vendor invoice) model.
2
3use 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/// AP Invoice (vendor invoice).
13#[derive(Debug, Clone, Serialize, Deserialize)]
14pub struct APInvoice {
15    /// Unique invoice number (internal).
16    pub invoice_number: String,
17    /// Vendor's invoice number.
18    pub vendor_invoice_number: String,
19    /// Company code.
20    pub company_code: String,
21    /// Vendor ID.
22    pub vendor_id: String,
23    /// Vendor name.
24    pub vendor_name: String,
25    /// Invoice date (vendor's date).
26    pub invoice_date: NaiveDate,
27    /// Posting date.
28    pub posting_date: NaiveDate,
29    /// Document date (receipt date).
30    pub document_date: NaiveDate,
31    /// Due date.
32    pub due_date: NaiveDate,
33    /// Baseline date for payment terms.
34    pub baseline_date: NaiveDate,
35    /// Invoice type.
36    pub invoice_type: APInvoiceType,
37    /// Invoice status.
38    pub status: SubledgerDocumentStatus,
39    /// Invoice lines.
40    pub lines: Vec<APInvoiceLine>,
41    /// Net amount (before tax).
42    pub net_amount: CurrencyAmount,
43    /// Tax amount.
44    pub tax_amount: CurrencyAmount,
45    /// Gross amount (after tax).
46    pub gross_amount: CurrencyAmount,
47    /// Amount paid.
48    pub amount_paid: Decimal,
49    /// Amount remaining.
50    pub amount_remaining: Decimal,
51    /// Payment terms.
52    pub payment_terms: PaymentTerms,
53    /// Tax details.
54    pub tax_details: Vec<TaxInfo>,
55    /// GL reference.
56    pub gl_reference: Option<GLReference>,
57    /// Clearing information.
58    pub clearing_info: Vec<ClearingInfo>,
59    /// Three-way match status.
60    pub match_status: MatchStatus,
61    /// Reference purchase order.
62    pub reference_po: Option<String>,
63    /// Reference goods receipt.
64    pub reference_gr: Option<String>,
65    /// Payment block reason.
66    pub payment_block: Option<PaymentBlockReason>,
67    /// Withholding tax applicable.
68    pub withholding_tax: Option<WithholdingTax>,
69    /// Created timestamp.
70    #[serde(with = "crate::serde_timestamp::utc")]
71    pub created_at: DateTime<Utc>,
72    /// Created by user.
73    pub created_by: Option<String>,
74    /// Last modified timestamp.
75    #[serde(default, with = "crate::serde_timestamp::utc::option")]
76    pub modified_at: Option<DateTime<Utc>>,
77    /// Notes.
78    pub notes: Option<String>,
79}
80
81impl APInvoice {
82    /// Creates a new AP invoice.
83    #[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    /// Adds an invoice line.
132    pub fn add_line(&mut self, line: APInvoiceLine) {
133        self.lines.push(line);
134        self.recalculate_totals();
135    }
136
137    /// Recalculates totals from lines.
138    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    /// Applies a payment to the invoice.
153    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    /// Checks if invoice is overdue.
168    pub fn is_overdue(&self, as_of_date: NaiveDate) -> bool {
169        self.status == SubledgerDocumentStatus::Open && as_of_date > self.due_date
170    }
171
172    /// Calculates days overdue.
173    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    /// Gets discount amount if paid by discount date.
182    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    /// Sets purchase order reference.
191    pub fn with_po_reference(mut self, po_number: String) -> Self {
192        self.reference_po = Some(po_number);
193        self
194    }
195
196    /// Sets goods receipt reference.
197    pub fn with_gr_reference(mut self, gr_number: String) -> Self {
198        self.reference_gr = Some(gr_number);
199        self
200    }
201
202    /// Sets payment block.
203    pub fn block_payment(&mut self, reason: PaymentBlockReason) {
204        self.payment_block = Some(reason);
205    }
206
207    /// Removes payment block.
208    pub fn unblock_payment(&mut self) {
209        self.payment_block = None;
210    }
211
212    /// Checks if payment is blocked.
213    pub fn is_blocked(&self) -> bool {
214        self.payment_block.is_some()
215    }
216
217    /// Sets three-way match status.
218    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    /// Checks if ready for payment.
230    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    /// Sets withholding tax.
240    pub fn with_withholding_tax(mut self, wht: WithholdingTax) -> Self {
241        self.withholding_tax = Some(wht);
242        self
243    }
244
245    /// Gets net payable amount (after withholding).
246    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    /// Reverses the invoice.
256    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/// Type of AP invoice.
272#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
273pub enum APInvoiceType {
274    /// Standard invoice.
275    #[default]
276    Standard,
277    /// Down payment request.
278    DownPayment,
279    /// Credit memo (negative invoice).
280    CreditMemo,
281    /// Recurring invoice.
282    Recurring,
283    /// Intercompany invoice.
284    Intercompany,
285    /// Service invoice (no goods receipt).
286    Service,
287    /// Expense reimbursement.
288    Expense,
289}
290
291/// Three-way match status.
292#[derive(Debug, Clone, Serialize, Deserialize, Default)]
293pub enum MatchStatus {
294    /// Not yet matched.
295    #[default]
296    NotMatched,
297    /// Fully matched (PO = GR = Invoice).
298    Matched,
299    /// Matched with variance.
300    MatchedWithVariance {
301        /// Price variance.
302        price_variance: Decimal,
303        /// Quantity variance.
304        quantity_variance: Decimal,
305    },
306    /// Two-way match only (no GR required).
307    TwoWayMatched,
308    /// Match not required (e.g., expense invoice).
309    NotRequired,
310}
311
312/// Payment block reason.
313#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
314pub enum PaymentBlockReason {
315    /// Quality issue.
316    QualityHold,
317    /// Price variance.
318    PriceVariance,
319    /// Quantity variance.
320    QuantityVariance,
321    /// Missing documentation.
322    MissingDocumentation,
323    /// Under review.
324    Review,
325    /// Match exception.
326    MatchException,
327    /// Duplicate suspected.
328    DuplicateSuspect,
329    /// Manual block.
330    ManualBlock,
331    /// Other.
332    Other,
333}
334
335/// Withholding tax information.
336#[derive(Debug, Clone, Serialize, Deserialize)]
337pub struct WithholdingTax {
338    /// Withholding tax type.
339    pub wht_type: String,
340    /// Withholding tax rate.
341    pub rate: Decimal,
342    /// Base amount.
343    pub base_amount: Decimal,
344    /// Withholding amount.
345    pub amount: Decimal,
346}
347
348impl WithholdingTax {
349    /// Creates new withholding tax.
350    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/// AP invoice line item.
362#[derive(Debug, Clone, Serialize, Deserialize)]
363pub struct APInvoiceLine {
364    /// Line number.
365    pub line_number: u32,
366    /// Material/service ID.
367    pub material_id: Option<String>,
368    /// Description.
369    pub description: String,
370    /// Quantity.
371    pub quantity: Decimal,
372    /// Unit of measure.
373    pub unit: String,
374    /// Unit price.
375    pub unit_price: Decimal,
376    /// Net amount.
377    pub net_amount: Decimal,
378    /// Tax code.
379    pub tax_code: Option<String>,
380    /// Tax rate.
381    pub tax_rate: Decimal,
382    /// Tax amount.
383    pub tax_amount: Decimal,
384    /// Gross amount.
385    pub gross_amount: Decimal,
386    /// GL account.
387    pub gl_account: String,
388    /// Cost center.
389    pub cost_center: Option<String>,
390    /// Internal order.
391    pub internal_order: Option<String>,
392    /// WBS element (project).
393    pub wbs_element: Option<String>,
394    /// Asset number (for asset acquisitions).
395    pub asset_number: Option<String>,
396    /// Reference PO line.
397    pub po_line: Option<u32>,
398    /// Reference GR line.
399    pub gr_line: Option<u32>,
400}
401
402impl APInvoiceLine {
403    /// Creates a new invoice line.
404    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    /// Sets tax information.
436    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    /// Sets cost center.
445    pub fn with_cost_center(mut self, cost_center: String) -> Self {
446        self.cost_center = Some(cost_center);
447        self
448    }
449
450    /// Sets PO reference.
451    pub fn with_po_reference(mut self, po_line: u32) -> Self {
452        self.po_line = Some(po_line);
453        self
454    }
455
456    /// Sets asset number.
457    pub fn with_asset(mut self, asset_number: String) -> Self {
458        self.asset_number = Some(asset_number);
459        self
460    }
461}
462
463/// Summary of open AP items for a vendor.
464#[derive(Debug, Clone, Serialize, Deserialize)]
465pub struct VendorAPSummary {
466    /// Vendor ID.
467    pub vendor_id: String,
468    /// Vendor name.
469    pub vendor_name: String,
470    /// Total open amount.
471    pub total_open: Decimal,
472    /// Total overdue amount.
473    pub total_overdue: Decimal,
474    /// Number of open invoices.
475    pub open_invoice_count: u32,
476    /// Amount coming due (next 7 days).
477    pub coming_due_7d: Decimal,
478    /// Amount coming due (next 30 days).
479    pub coming_due_30d: Decimal,
480    /// Available discount amount.
481    pub available_discount: Decimal,
482}
483
484impl VendorAPSummary {
485    /// Creates from a list of invoices.
486    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)); // 2% of 1100
592        assert_eq!(late_discount, Decimal::ZERO);
593    }
594
595    #[test]
596    fn test_payment_block() {
597        let mut invoice = create_test_invoice();
598        // Set match status to NotRequired so invoice is payable
599        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)); // 1100 - 100 WHT
620    }
621}