Skip to main content

datasynth_core/models/subledger/ar/
invoice.rs

1//! AR 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, DunningInfo, GLReference, PaymentTerms, SubledgerDocumentStatus,
10    TaxInfo,
11};
12
13/// AR Invoice (customer invoice).
14#[derive(Debug, Clone, Serialize, Deserialize)]
15pub struct ARInvoice {
16    /// Unique invoice number.
17    pub invoice_number: String,
18    /// Company code.
19    pub company_code: String,
20    /// Customer ID.
21    pub customer_id: String,
22    /// Customer name.
23    pub customer_name: String,
24    /// Invoice date.
25    pub invoice_date: NaiveDate,
26    /// Posting date.
27    pub posting_date: NaiveDate,
28    /// Due date.
29    pub due_date: NaiveDate,
30    /// Baseline date for payment terms.
31    pub baseline_date: NaiveDate,
32    /// Invoice type.
33    pub invoice_type: ARInvoiceType,
34    /// Invoice status.
35    pub status: SubledgerDocumentStatus,
36    /// Invoice lines.
37    pub lines: Vec<ARInvoiceLine>,
38    /// Net amount (before tax).
39    pub net_amount: CurrencyAmount,
40    /// Tax amount.
41    pub tax_amount: CurrencyAmount,
42    /// Gross amount (after tax).
43    pub gross_amount: CurrencyAmount,
44    /// Amount paid.
45    pub amount_paid: Decimal,
46    /// Amount remaining.
47    pub amount_remaining: Decimal,
48    /// Payment terms.
49    pub payment_terms: PaymentTerms,
50    /// Tax details.
51    pub tax_details: Vec<TaxInfo>,
52    /// GL reference.
53    pub gl_reference: Option<GLReference>,
54    /// Clearing information.
55    pub clearing_info: Vec<ClearingInfo>,
56    /// Dunning information.
57    pub dunning_info: DunningInfo,
58    /// Reference documents (sales order, delivery).
59    pub reference_documents: Vec<ARDocumentReference>,
60    /// Cost center.
61    pub cost_center: Option<String>,
62    /// Profit center.
63    pub profit_center: Option<String>,
64    /// Sales organization.
65    pub sales_org: Option<String>,
66    /// Distribution channel.
67    pub distribution_channel: Option<String>,
68    /// Division.
69    pub division: Option<String>,
70    /// Created timestamp.
71    #[serde(with = "crate::serde_timestamp::utc")]
72    pub created_at: DateTime<Utc>,
73    /// Created by user.
74    pub created_by: Option<String>,
75    /// Last modified timestamp.
76    #[serde(default, with = "crate::serde_timestamp::utc::option")]
77    pub modified_at: Option<DateTime<Utc>>,
78    /// Notes.
79    pub notes: Option<String>,
80}
81
82impl ARInvoice {
83    /// Creates a new AR invoice.
84    pub fn new(
85        invoice_number: String,
86        company_code: String,
87        customer_id: String,
88        customer_name: String,
89        invoice_date: NaiveDate,
90        payment_terms: PaymentTerms,
91        currency: String,
92    ) -> Self {
93        let due_date = payment_terms.calculate_due_date(invoice_date);
94
95        Self {
96            invoice_number,
97            company_code,
98            customer_id,
99            customer_name,
100            invoice_date,
101            posting_date: invoice_date,
102            due_date,
103            baseline_date: invoice_date,
104            invoice_type: ARInvoiceType::Standard,
105            status: SubledgerDocumentStatus::Open,
106            lines: Vec::new(),
107            net_amount: CurrencyAmount::single_currency(Decimal::ZERO, currency.clone()),
108            tax_amount: CurrencyAmount::single_currency(Decimal::ZERO, currency.clone()),
109            gross_amount: CurrencyAmount::single_currency(Decimal::ZERO, currency),
110            amount_paid: Decimal::ZERO,
111            amount_remaining: Decimal::ZERO,
112            payment_terms,
113            tax_details: Vec::new(),
114            gl_reference: None,
115            clearing_info: Vec::new(),
116            dunning_info: DunningInfo::default(),
117            reference_documents: Vec::new(),
118            cost_center: None,
119            profit_center: None,
120            sales_org: None,
121            distribution_channel: None,
122            division: None,
123            created_at: Utc::now(),
124            created_by: None,
125            modified_at: None,
126            notes: None,
127        }
128    }
129
130    /// Adds an invoice line.
131    pub fn add_line(&mut self, line: ARInvoiceLine) {
132        self.lines.push(line);
133        self.recalculate_totals();
134    }
135
136    /// Recalculates totals from lines.
137    pub fn recalculate_totals(&mut self) {
138        let net_total: Decimal = self.lines.iter().map(|l| l.net_amount).sum();
139        let tax_total: Decimal = self.lines.iter().map(|l| l.tax_amount).sum();
140        let gross_total = net_total + tax_total;
141
142        self.net_amount.document_amount = net_total;
143        self.net_amount.local_amount = net_total * self.net_amount.exchange_rate;
144        self.tax_amount.document_amount = tax_total;
145        self.tax_amount.local_amount = tax_total * self.tax_amount.exchange_rate;
146        self.gross_amount.document_amount = gross_total;
147        self.gross_amount.local_amount = gross_total * self.gross_amount.exchange_rate;
148        self.amount_remaining = gross_total - self.amount_paid;
149    }
150
151    /// Applies a payment to the invoice.
152    pub fn apply_payment(&mut self, amount: Decimal, clearing: ClearingInfo) {
153        self.amount_paid += amount;
154        self.amount_remaining = self.gross_amount.document_amount - self.amount_paid;
155        self.clearing_info.push(clearing);
156
157        self.status = if self.amount_remaining <= Decimal::ZERO {
158            SubledgerDocumentStatus::Cleared
159        } else {
160            SubledgerDocumentStatus::PartiallyCleared
161        };
162
163        self.modified_at = Some(Utc::now());
164    }
165
166    /// Checks if invoice is overdue.
167    pub fn is_overdue(&self, as_of_date: NaiveDate) -> bool {
168        self.status == SubledgerDocumentStatus::Open && as_of_date > self.due_date
169    }
170
171    /// Calculates days overdue.
172    pub fn days_overdue(&self, as_of_date: NaiveDate) -> i64 {
173        if self.is_overdue(as_of_date) {
174            (as_of_date - self.due_date).num_days()
175        } else {
176            0
177        }
178    }
179
180    /// Gets discount amount if paid by discount date.
181    pub fn available_discount(&self, payment_date: NaiveDate) -> Decimal {
182        self.payment_terms.calculate_discount(
183            self.gross_amount.document_amount,
184            payment_date,
185            self.baseline_date,
186        )
187    }
188
189    /// Sets the GL reference.
190    pub fn set_gl_reference(&mut self, reference: GLReference) {
191        self.gl_reference = Some(reference);
192    }
193
194    /// Adds a reference document.
195    pub fn add_reference(&mut self, reference: ARDocumentReference) {
196        self.reference_documents.push(reference);
197    }
198
199    /// Reverses the invoice.
200    pub fn reverse(&mut self, reversal_date: NaiveDate, reason: String) {
201        self.status = SubledgerDocumentStatus::Reversed;
202        self.notes = Some(format!(
203            "{}Reversed on {}: {}",
204            self.notes
205                .as_ref()
206                .map(|n| format!("{n}. "))
207                .unwrap_or_default(),
208            reversal_date,
209            reason
210        ));
211        self.modified_at = Some(Utc::now());
212    }
213}
214
215/// Type of AR invoice.
216#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
217pub enum ARInvoiceType {
218    /// Standard invoice.
219    #[default]
220    Standard,
221    /// Down payment request.
222    DownPaymentRequest,
223    /// Recurring invoice.
224    Recurring,
225    /// Credit invoice (negative).
226    CreditInvoice,
227    /// Debit invoice (adjustment).
228    DebitInvoice,
229    /// Pro forma invoice.
230    ProForma,
231    /// Intercompany invoice.
232    Intercompany,
233}
234
235/// AR invoice line item.
236#[derive(Debug, Clone, Serialize, Deserialize)]
237pub struct ARInvoiceLine {
238    /// Line number.
239    pub line_number: u32,
240    /// Material/product ID.
241    pub material_id: Option<String>,
242    /// Description.
243    pub description: String,
244    /// Quantity.
245    pub quantity: Decimal,
246    /// Unit of measure.
247    pub unit: String,
248    /// Unit price.
249    pub unit_price: Decimal,
250    /// Net amount (quantity * unit_price).
251    pub net_amount: Decimal,
252    /// Tax code.
253    pub tax_code: Option<String>,
254    /// Tax rate.
255    pub tax_rate: Decimal,
256    /// Tax amount.
257    pub tax_amount: Decimal,
258    /// Gross amount.
259    pub gross_amount: Decimal,
260    /// Revenue account.
261    pub revenue_account: String,
262    /// Cost center.
263    pub cost_center: Option<String>,
264    /// Profit center.
265    pub profit_center: Option<String>,
266    /// Reference (sales order line).
267    pub reference: Option<String>,
268}
269
270impl ARInvoiceLine {
271    /// Creates a new invoice line.
272    pub fn new(
273        line_number: u32,
274        description: String,
275        quantity: Decimal,
276        unit: String,
277        unit_price: Decimal,
278        revenue_account: String,
279    ) -> Self {
280        let net_amount = (quantity * unit_price).round_dp(2);
281        Self {
282            line_number,
283            material_id: None,
284            description,
285            quantity,
286            unit,
287            unit_price,
288            net_amount,
289            tax_code: None,
290            tax_rate: Decimal::ZERO,
291            tax_amount: Decimal::ZERO,
292            gross_amount: net_amount,
293            revenue_account,
294            cost_center: None,
295            profit_center: None,
296            reference: None,
297        }
298    }
299
300    /// Sets tax information.
301    pub fn with_tax(mut self, tax_code: String, tax_rate: Decimal) -> Self {
302        self.tax_code = Some(tax_code);
303        self.tax_rate = tax_rate;
304        self.tax_amount = (self.net_amount * tax_rate / dec!(100)).round_dp(2);
305        self.gross_amount = self.net_amount + self.tax_amount;
306        self
307    }
308
309    /// Sets material ID.
310    pub fn with_material(mut self, material_id: String) -> Self {
311        self.material_id = Some(material_id);
312        self
313    }
314
315    /// Sets cost/profit center.
316    pub fn with_cost_center(mut self, cost_center: String, profit_center: Option<String>) -> Self {
317        self.cost_center = Some(cost_center);
318        self.profit_center = profit_center;
319        self
320    }
321}
322
323/// Reference to related documents.
324#[derive(Debug, Clone, Serialize, Deserialize)]
325pub struct ARDocumentReference {
326    /// Reference document type.
327    pub document_type: ARReferenceDocType,
328    /// Document number.
329    pub document_number: String,
330    /// Document date.
331    pub document_date: NaiveDate,
332}
333
334/// Type of reference document.
335#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
336pub enum ARReferenceDocType {
337    /// Sales order.
338    SalesOrder,
339    /// Delivery.
340    Delivery,
341    /// Contract.
342    Contract,
343    /// Quotation.
344    Quotation,
345    /// Return order.
346    ReturnOrder,
347}
348
349/// Summary of open AR items for a customer.
350#[derive(Debug, Clone, Serialize, Deserialize)]
351pub struct CustomerARSummary {
352    /// Customer ID.
353    pub customer_id: String,
354    /// Customer name.
355    pub customer_name: String,
356    /// Total open amount.
357    pub total_open: Decimal,
358    /// Total overdue amount.
359    pub total_overdue: Decimal,
360    /// Number of open invoices.
361    pub open_invoice_count: u32,
362    /// Number of overdue invoices.
363    pub overdue_invoice_count: u32,
364    /// Oldest open invoice date.
365    pub oldest_open_date: Option<NaiveDate>,
366    /// Credit limit.
367    pub credit_limit: Option<Decimal>,
368    /// Credit utilization percentage.
369    pub credit_utilization: Option<Decimal>,
370    /// Payment behavior score.
371    pub payment_score: Option<Decimal>,
372}
373
374impl CustomerARSummary {
375    /// Creates from a list of invoices.
376    pub fn from_invoices(
377        customer_id: String,
378        customer_name: String,
379        invoices: &[ARInvoice],
380        as_of_date: NaiveDate,
381        credit_limit: Option<Decimal>,
382    ) -> Self {
383        let open_invoices: Vec<_> = invoices
384            .iter()
385            .filter(|i| {
386                i.customer_id == customer_id
387                    && matches!(
388                        i.status,
389                        SubledgerDocumentStatus::Open | SubledgerDocumentStatus::PartiallyCleared
390                    )
391            })
392            .collect();
393
394        let total_open: Decimal = open_invoices.iter().map(|i| i.amount_remaining).sum();
395        let overdue_invoices: Vec<_> = open_invoices
396            .iter()
397            .filter(|i| i.is_overdue(as_of_date))
398            .collect();
399        let total_overdue: Decimal = overdue_invoices.iter().map(|i| i.amount_remaining).sum();
400
401        let oldest_open_date = open_invoices.iter().map(|i| i.invoice_date).min();
402
403        let credit_utilization = credit_limit.map(|limit| {
404            if limit > Decimal::ZERO {
405                (total_open / limit * dec!(100)).round_dp(2)
406            } else {
407                Decimal::ZERO
408            }
409        });
410
411        Self {
412            customer_id,
413            customer_name,
414            total_open,
415            total_overdue,
416            open_invoice_count: open_invoices.len() as u32,
417            overdue_invoice_count: overdue_invoices.len() as u32,
418            oldest_open_date,
419            credit_limit,
420            credit_utilization,
421            payment_score: None,
422        }
423    }
424}
425
426#[cfg(test)]
427#[allow(clippy::unwrap_used)]
428mod tests {
429    use super::*;
430
431    fn create_test_invoice() -> ARInvoice {
432        let mut invoice = ARInvoice::new(
433            "INV001".to_string(),
434            "1000".to_string(),
435            "CUST001".to_string(),
436            "Test Customer".to_string(),
437            NaiveDate::from_ymd_opt(2024, 1, 15).unwrap(),
438            PaymentTerms::net_30(),
439            "USD".to_string(),
440        );
441
442        let line = ARInvoiceLine::new(
443            1,
444            "Product A".to_string(),
445            dec!(10),
446            "EA".to_string(),
447            dec!(100),
448            "4000".to_string(),
449        )
450        .with_tax("VAT".to_string(), dec!(20));
451
452        invoice.add_line(line);
453        invoice
454    }
455
456    #[test]
457    fn test_invoice_totals() {
458        let invoice = create_test_invoice();
459        assert_eq!(invoice.net_amount.document_amount, dec!(1000));
460        assert_eq!(invoice.tax_amount.document_amount, dec!(200));
461        assert_eq!(invoice.gross_amount.document_amount, dec!(1200));
462    }
463
464    #[test]
465    fn test_invoice_due_date() {
466        let invoice = create_test_invoice();
467        assert_eq!(
468            invoice.due_date,
469            NaiveDate::from_ymd_opt(2024, 2, 14).unwrap()
470        );
471    }
472
473    #[test]
474    fn test_invoice_overdue() {
475        let invoice = create_test_invoice();
476        let before_due = NaiveDate::from_ymd_opt(2024, 2, 10).unwrap();
477        let after_due = NaiveDate::from_ymd_opt(2024, 2, 20).unwrap();
478
479        assert!(!invoice.is_overdue(before_due));
480        assert!(invoice.is_overdue(after_due));
481        assert_eq!(invoice.days_overdue(after_due), 6);
482    }
483
484    #[test]
485    fn test_apply_payment() {
486        let mut invoice = create_test_invoice();
487        let clearing = ClearingInfo {
488            clearing_document: "PAY001".to_string(),
489            clearing_date: NaiveDate::from_ymd_opt(2024, 2, 1).unwrap(),
490            clearing_amount: dec!(600),
491            clearing_type: crate::models::subledger::ClearingType::Payment,
492        };
493
494        invoice.apply_payment(dec!(600), clearing);
495        assert_eq!(invoice.amount_paid, dec!(600));
496        assert_eq!(invoice.amount_remaining, dec!(600));
497        assert_eq!(invoice.status, SubledgerDocumentStatus::PartiallyCleared);
498    }
499}