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    pub created_at: DateTime<Utc>,
71    /// Created by user.
72    pub created_by: Option<String>,
73    /// Last modified timestamp.
74    pub modified_at: Option<DateTime<Utc>>,
75    /// Notes.
76    pub notes: Option<String>,
77}
78
79impl APInvoice {
80    /// Creates a new AP invoice.
81    #[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    /// Adds an invoice line.
130    pub fn add_line(&mut self, line: APInvoiceLine) {
131        self.lines.push(line);
132        self.recalculate_totals();
133    }
134
135    /// Recalculates totals from lines.
136    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    /// Applies a payment to the invoice.
151    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    /// Checks if invoice is overdue.
166    pub fn is_overdue(&self, as_of_date: NaiveDate) -> bool {
167        self.status == SubledgerDocumentStatus::Open && as_of_date > self.due_date
168    }
169
170    /// Calculates days overdue.
171    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    /// Gets discount amount if paid by discount date.
180    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    /// Sets purchase order reference.
189    pub fn with_po_reference(mut self, po_number: String) -> Self {
190        self.reference_po = Some(po_number);
191        self
192    }
193
194    /// Sets goods receipt reference.
195    pub fn with_gr_reference(mut self, gr_number: String) -> Self {
196        self.reference_gr = Some(gr_number);
197        self
198    }
199
200    /// Sets payment block.
201    pub fn block_payment(&mut self, reason: PaymentBlockReason) {
202        self.payment_block = Some(reason);
203    }
204
205    /// Removes payment block.
206    pub fn unblock_payment(&mut self) {
207        self.payment_block = None;
208    }
209
210    /// Checks if payment is blocked.
211    pub fn is_blocked(&self) -> bool {
212        self.payment_block.is_some()
213    }
214
215    /// Sets three-way match status.
216    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    /// Checks if ready for payment.
228    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    /// Sets withholding tax.
238    pub fn with_withholding_tax(mut self, wht: WithholdingTax) -> Self {
239        self.withholding_tax = Some(wht);
240        self
241    }
242
243    /// Gets net payable amount (after withholding).
244    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    /// Reverses the invoice.
254    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/// Type of AP invoice.
270#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
271pub enum APInvoiceType {
272    /// Standard invoice.
273    #[default]
274    Standard,
275    /// Down payment request.
276    DownPayment,
277    /// Credit memo (negative invoice).
278    CreditMemo,
279    /// Recurring invoice.
280    Recurring,
281    /// Intercompany invoice.
282    Intercompany,
283    /// Service invoice (no goods receipt).
284    Service,
285    /// Expense reimbursement.
286    Expense,
287}
288
289/// Three-way match status.
290#[derive(Debug, Clone, Serialize, Deserialize, Default)]
291pub enum MatchStatus {
292    /// Not yet matched.
293    #[default]
294    NotMatched,
295    /// Fully matched (PO = GR = Invoice).
296    Matched,
297    /// Matched with variance.
298    MatchedWithVariance {
299        /// Price variance.
300        price_variance: Decimal,
301        /// Quantity variance.
302        quantity_variance: Decimal,
303    },
304    /// Two-way match only (no GR required).
305    TwoWayMatched,
306    /// Match not required (e.g., expense invoice).
307    NotRequired,
308}
309
310/// Payment block reason.
311#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
312pub enum PaymentBlockReason {
313    /// Quality issue.
314    QualityHold,
315    /// Price variance.
316    PriceVariance,
317    /// Quantity variance.
318    QuantityVariance,
319    /// Missing documentation.
320    MissingDocumentation,
321    /// Under review.
322    Review,
323    /// Match exception.
324    MatchException,
325    /// Duplicate suspected.
326    DuplicateSuspect,
327    /// Manual block.
328    ManualBlock,
329    /// Other.
330    Other,
331}
332
333/// Withholding tax information.
334#[derive(Debug, Clone, Serialize, Deserialize)]
335pub struct WithholdingTax {
336    /// Withholding tax type.
337    pub wht_type: String,
338    /// Withholding tax rate.
339    pub rate: Decimal,
340    /// Base amount.
341    pub base_amount: Decimal,
342    /// Withholding amount.
343    pub amount: Decimal,
344}
345
346impl WithholdingTax {
347    /// Creates new withholding tax.
348    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/// AP invoice line item.
360#[derive(Debug, Clone, Serialize, Deserialize)]
361pub struct APInvoiceLine {
362    /// Line number.
363    pub line_number: u32,
364    /// Material/service ID.
365    pub material_id: Option<String>,
366    /// Description.
367    pub description: String,
368    /// Quantity.
369    pub quantity: Decimal,
370    /// Unit of measure.
371    pub unit: String,
372    /// Unit price.
373    pub unit_price: Decimal,
374    /// Net amount.
375    pub net_amount: Decimal,
376    /// Tax code.
377    pub tax_code: Option<String>,
378    /// Tax rate.
379    pub tax_rate: Decimal,
380    /// Tax amount.
381    pub tax_amount: Decimal,
382    /// Gross amount.
383    pub gross_amount: Decimal,
384    /// GL account.
385    pub gl_account: String,
386    /// Cost center.
387    pub cost_center: Option<String>,
388    /// Internal order.
389    pub internal_order: Option<String>,
390    /// WBS element (project).
391    pub wbs_element: Option<String>,
392    /// Asset number (for asset acquisitions).
393    pub asset_number: Option<String>,
394    /// Reference PO line.
395    pub po_line: Option<u32>,
396    /// Reference GR line.
397    pub gr_line: Option<u32>,
398}
399
400impl APInvoiceLine {
401    /// Creates a new invoice line.
402    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    /// Sets tax information.
434    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    /// Sets cost center.
443    pub fn with_cost_center(mut self, cost_center: String) -> Self {
444        self.cost_center = Some(cost_center);
445        self
446    }
447
448    /// Sets PO reference.
449    pub fn with_po_reference(mut self, po_line: u32) -> Self {
450        self.po_line = Some(po_line);
451        self
452    }
453
454    /// Sets asset number.
455    pub fn with_asset(mut self, asset_number: String) -> Self {
456        self.asset_number = Some(asset_number);
457        self
458    }
459}
460
461/// Summary of open AP items for a vendor.
462#[derive(Debug, Clone, Serialize, Deserialize)]
463pub struct VendorAPSummary {
464    /// Vendor ID.
465    pub vendor_id: String,
466    /// Vendor name.
467    pub vendor_name: String,
468    /// Total open amount.
469    pub total_open: Decimal,
470    /// Total overdue amount.
471    pub total_overdue: Decimal,
472    /// Number of open invoices.
473    pub open_invoice_count: u32,
474    /// Amount coming due (next 7 days).
475    pub coming_due_7d: Decimal,
476    /// Amount coming due (next 30 days).
477    pub coming_due_30d: Decimal,
478    /// Available discount amount.
479    pub available_discount: Decimal,
480}
481
482impl VendorAPSummary {
483    /// Creates from a list of invoices.
484    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)); // 2% of 1100
589        assert_eq!(late_discount, Decimal::ZERO);
590    }
591
592    #[test]
593    fn test_payment_block() {
594        let mut invoice = create_test_invoice();
595        // Set match status to NotRequired so invoice is payable
596        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)); // 1100 - 100 WHT
617    }
618}