Skip to main content

datasynth_core/models/documents/
customer_invoice.rs

1//! Customer Invoice document model.
2//!
3//! Represents customer invoices (billing documents) in the O2C (Order-to-Cash) process flow.
4//! Customer invoices create accounting entries: DR Accounts Receivable, CR Revenue.
5
6use chrono::NaiveDate;
7use rust_decimal::Decimal;
8use serde::{Deserialize, Serialize};
9
10use super::{
11    DocumentHeader, DocumentLineItem, DocumentReference, DocumentStatus, DocumentType,
12    ReferenceType,
13};
14
15/// Customer Invoice type.
16#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize, Default)]
17#[serde(rename_all = "snake_case")]
18pub enum CustomerInvoiceType {
19    /// Standard invoice
20    #[default]
21    Standard,
22    /// Credit memo
23    CreditMemo,
24    /// Debit memo
25    DebitMemo,
26    /// Pro forma invoice
27    ProForma,
28    /// Down payment request
29    DownPaymentRequest,
30    /// Final invoice (settling down payment)
31    FinalInvoice,
32    /// Intercompany invoice
33    Intercompany,
34}
35
36impl CustomerInvoiceType {
37    /// Check if this type increases AR (debit).
38    pub fn is_debit(&self) -> bool {
39        matches!(
40            self,
41            Self::Standard
42                | Self::DebitMemo
43                | Self::DownPaymentRequest
44                | Self::FinalInvoice
45                | Self::Intercompany
46        )
47    }
48
49    /// Check if this type decreases AR (credit).
50    pub fn is_credit(&self) -> bool {
51        matches!(self, Self::CreditMemo)
52    }
53
54    /// Check if this creates revenue.
55    pub fn creates_revenue(&self) -> bool {
56        matches!(
57            self,
58            Self::Standard | Self::FinalInvoice | Self::Intercompany
59        )
60    }
61}
62
63/// Customer Invoice payment status.
64#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize, Default)]
65#[serde(rename_all = "snake_case")]
66pub enum InvoicePaymentStatus {
67    /// Open - not paid
68    #[default]
69    Open,
70    /// Partially paid
71    PartiallyPaid,
72    /// Fully paid
73    Paid,
74    /// Cleared (matched and closed)
75    Cleared,
76    /// Written off
77    WrittenOff,
78    /// In dispute
79    InDispute,
80    /// Sent to collection
81    InCollection,
82}
83
84/// Customer Invoice line item.
85#[derive(Debug, Clone, Serialize, Deserialize)]
86pub struct CustomerInvoiceItem {
87    /// Base line item fields
88    #[serde(flatten)]
89    pub base: DocumentLineItem,
90
91    /// Reference sales order number
92    pub sales_order_id: Option<String>,
93
94    /// Reference SO item
95    pub so_item: Option<u16>,
96
97    /// Reference delivery number
98    pub delivery_id: Option<String>,
99
100    /// Reference delivery item
101    pub delivery_item: Option<u16>,
102
103    /// Revenue account (override from material)
104    pub revenue_account: Option<String>,
105
106    /// COGS account (for statistical tracking)
107    pub cogs_account: Option<String>,
108
109    /// COGS amount (for margin calculation)
110    pub cogs_amount: Decimal,
111
112    /// Discount amount
113    pub discount_amount: Decimal,
114
115    /// Is this a service item?
116    pub is_service: bool,
117
118    /// Returns reference (if credit memo for returns)
119    pub returns_reference: Option<String>,
120}
121
122impl CustomerInvoiceItem {
123    /// Create a new customer invoice item.
124    #[allow(clippy::too_many_arguments)]
125    pub fn new(
126        line_number: u16,
127        description: impl Into<String>,
128        quantity: Decimal,
129        unit_price: Decimal,
130    ) -> Self {
131        Self {
132            base: DocumentLineItem::new(line_number, description, quantity, unit_price),
133            sales_order_id: None,
134            so_item: None,
135            delivery_id: None,
136            delivery_item: None,
137            revenue_account: None,
138            cogs_account: None,
139            cogs_amount: Decimal::ZERO,
140            discount_amount: Decimal::ZERO,
141            is_service: false,
142            returns_reference: None,
143        }
144    }
145
146    /// Create from delivery reference.
147    #[allow(clippy::too_many_arguments)]
148    pub fn from_delivery(
149        line_number: u16,
150        description: impl Into<String>,
151        quantity: Decimal,
152        unit_price: Decimal,
153        delivery_id: impl Into<String>,
154        delivery_item: u16,
155    ) -> Self {
156        let mut item = Self::new(line_number, description, quantity, unit_price);
157        item.delivery_id = Some(delivery_id.into());
158        item.delivery_item = Some(delivery_item);
159        item
160    }
161
162    /// Set material.
163    pub fn with_material(mut self, material_id: impl Into<String>) -> Self {
164        self.base = self.base.with_material(material_id);
165        self
166    }
167
168    /// Set sales order reference.
169    pub fn with_sales_order(mut self, so_id: impl Into<String>, so_item: u16) -> Self {
170        self.sales_order_id = Some(so_id.into());
171        self.so_item = Some(so_item);
172        self
173    }
174
175    /// Set COGS amount.
176    pub fn with_cogs(mut self, cogs: Decimal) -> Self {
177        self.cogs_amount = cogs;
178        self
179    }
180
181    /// Set revenue account.
182    pub fn with_revenue_account(mut self, account: impl Into<String>) -> Self {
183        self.revenue_account = Some(account.into());
184        self
185    }
186
187    /// Set as service item.
188    pub fn as_service(mut self) -> Self {
189        self.is_service = true;
190        self
191    }
192
193    /// Set discount.
194    pub fn with_discount(mut self, discount: Decimal) -> Self {
195        self.discount_amount = discount;
196        self
197    }
198
199    /// Calculate gross margin.
200    pub fn gross_margin(&self) -> Decimal {
201        if self.base.net_amount == Decimal::ZERO {
202            return Decimal::ZERO;
203        }
204        ((self.base.net_amount - self.cogs_amount) / self.base.net_amount * Decimal::from(100))
205            .round_dp(2)
206    }
207}
208
209/// Customer Invoice document.
210#[derive(Debug, Clone, Serialize, Deserialize)]
211pub struct CustomerInvoice {
212    /// Document header
213    pub header: DocumentHeader,
214
215    /// Invoice type
216    pub invoice_type: CustomerInvoiceType,
217
218    /// Line items
219    pub items: Vec<CustomerInvoiceItem>,
220
221    /// Customer ID
222    pub customer_id: String,
223
224    /// Bill-to party (if different)
225    pub bill_to: Option<String>,
226
227    /// Payer (if different)
228    pub payer: Option<String>,
229
230    /// Sales organization
231    pub sales_org: String,
232
233    /// Distribution channel
234    pub distribution_channel: String,
235
236    /// Division
237    pub division: String,
238
239    /// Total net amount
240    pub total_net_amount: Decimal,
241
242    /// Total tax amount
243    pub total_tax_amount: Decimal,
244
245    /// Total gross amount
246    pub total_gross_amount: Decimal,
247
248    /// Total discount amount
249    pub total_discount: Decimal,
250
251    /// Total COGS
252    pub total_cogs: Decimal,
253
254    /// Payment terms
255    pub payment_terms: String,
256
257    /// Due date
258    pub due_date: NaiveDate,
259
260    /// Cash discount date 1
261    pub discount_date_1: Option<NaiveDate>,
262
263    /// Cash discount percent 1
264    pub discount_percent_1: Option<Decimal>,
265
266    /// Cash discount date 2
267    pub discount_date_2: Option<NaiveDate>,
268
269    /// Cash discount percent 2
270    pub discount_percent_2: Option<Decimal>,
271
272    /// Amount paid
273    pub amount_paid: Decimal,
274
275    /// Amount open (remaining)
276    pub amount_open: Decimal,
277
278    /// Payment status
279    pub payment_status: InvoicePaymentStatus,
280
281    /// Reference sales order (primary)
282    pub sales_order_id: Option<String>,
283
284    /// Reference delivery (primary)
285    pub delivery_id: Option<String>,
286
287    /// External invoice number (for customer)
288    pub external_reference: Option<String>,
289
290    /// Customer PO number
291    pub customer_po_number: Option<String>,
292
293    /// Is invoice posted?
294    pub is_posted: bool,
295
296    /// Is invoice printed/sent?
297    pub is_output_complete: bool,
298
299    /// Is this an intercompany invoice?
300    pub is_intercompany: bool,
301
302    /// Intercompany partner (company code)
303    pub ic_partner: Option<String>,
304
305    /// Dispute reason (if in dispute)
306    pub dispute_reason: Option<String>,
307
308    /// Write-off amount
309    pub write_off_amount: Decimal,
310
311    /// Write-off reason
312    pub write_off_reason: Option<String>,
313
314    /// Dunning level (0 = not dunned)
315    pub dunning_level: u8,
316
317    /// Last dunning date
318    pub last_dunning_date: Option<NaiveDate>,
319
320    /// Is invoice cancelled/reversed?
321    pub is_cancelled: bool,
322
323    /// Cancellation invoice reference
324    pub cancellation_invoice: Option<String>,
325}
326
327impl CustomerInvoice {
328    /// Create a new customer invoice.
329    #[allow(clippy::too_many_arguments)]
330    pub fn new(
331        invoice_id: impl Into<String>,
332        company_code: impl Into<String>,
333        customer_id: impl Into<String>,
334        fiscal_year: u16,
335        fiscal_period: u8,
336        document_date: NaiveDate,
337        due_date: NaiveDate,
338        created_by: impl Into<String>,
339    ) -> Self {
340        let header = DocumentHeader::new(
341            invoice_id,
342            DocumentType::CustomerInvoice,
343            company_code,
344            fiscal_year,
345            fiscal_period,
346            document_date,
347            created_by,
348        )
349        .with_currency("USD");
350
351        Self {
352            header,
353            invoice_type: CustomerInvoiceType::Standard,
354            items: Vec::new(),
355            customer_id: customer_id.into(),
356            bill_to: None,
357            payer: None,
358            sales_org: "1000".to_string(),
359            distribution_channel: "10".to_string(),
360            division: "00".to_string(),
361            total_net_amount: Decimal::ZERO,
362            total_tax_amount: Decimal::ZERO,
363            total_gross_amount: Decimal::ZERO,
364            total_discount: Decimal::ZERO,
365            total_cogs: Decimal::ZERO,
366            payment_terms: "NET30".to_string(),
367            due_date,
368            discount_date_1: None,
369            discount_percent_1: None,
370            discount_date_2: None,
371            discount_percent_2: None,
372            amount_paid: Decimal::ZERO,
373            amount_open: Decimal::ZERO,
374            payment_status: InvoicePaymentStatus::Open,
375            sales_order_id: None,
376            delivery_id: None,
377            external_reference: None,
378            customer_po_number: None,
379            is_posted: false,
380            is_output_complete: false,
381            is_intercompany: false,
382            ic_partner: None,
383            dispute_reason: None,
384            write_off_amount: Decimal::ZERO,
385            write_off_reason: None,
386            dunning_level: 0,
387            last_dunning_date: None,
388            is_cancelled: false,
389            cancellation_invoice: None,
390        }
391    }
392
393    /// Create from delivery reference.
394    #[allow(clippy::too_many_arguments)]
395    pub fn from_delivery(
396        invoice_id: impl Into<String>,
397        company_code: impl Into<String>,
398        delivery_id: impl Into<String>,
399        customer_id: impl Into<String>,
400        fiscal_year: u16,
401        fiscal_period: u8,
402        document_date: NaiveDate,
403        due_date: NaiveDate,
404        created_by: impl Into<String>,
405    ) -> Self {
406        let dlv_id = delivery_id.into();
407        let mut invoice = Self::new(
408            invoice_id,
409            company_code,
410            customer_id,
411            fiscal_year,
412            fiscal_period,
413            document_date,
414            due_date,
415            created_by,
416        );
417        invoice.delivery_id = Some(dlv_id.clone());
418
419        // Add reference to delivery
420        invoice.header.add_reference(DocumentReference::new(
421            DocumentType::Delivery,
422            dlv_id,
423            DocumentType::CustomerInvoice,
424            invoice.header.document_id.clone(),
425            ReferenceType::FollowOn,
426            invoice.header.company_code.clone(),
427            document_date,
428        ));
429
430        invoice
431    }
432
433    /// Create a credit memo.
434    pub fn credit_memo(
435        invoice_id: impl Into<String>,
436        company_code: impl Into<String>,
437        customer_id: impl Into<String>,
438        fiscal_year: u16,
439        fiscal_period: u8,
440        document_date: NaiveDate,
441        created_by: impl Into<String>,
442    ) -> Self {
443        let mut invoice = Self::new(
444            invoice_id,
445            company_code,
446            customer_id,
447            fiscal_year,
448            fiscal_period,
449            document_date,
450            document_date, // Due immediately
451            created_by,
452        );
453        invoice.invoice_type = CustomerInvoiceType::CreditMemo;
454        invoice.header.document_type = DocumentType::CreditMemo;
455        invoice
456    }
457
458    /// Set invoice type.
459    pub fn with_invoice_type(mut self, invoice_type: CustomerInvoiceType) -> Self {
460        self.invoice_type = invoice_type;
461        self
462    }
463
464    /// Set sales organization.
465    pub fn with_sales_org(
466        mut self,
467        sales_org: impl Into<String>,
468        dist_channel: impl Into<String>,
469        division: impl Into<String>,
470    ) -> Self {
471        self.sales_org = sales_org.into();
472        self.distribution_channel = dist_channel.into();
473        self.division = division.into();
474        self
475    }
476
477    /// Set partner functions.
478    pub fn with_partners(mut self, bill_to: impl Into<String>, payer: impl Into<String>) -> Self {
479        self.bill_to = Some(bill_to.into());
480        self.payer = Some(payer.into());
481        self
482    }
483
484    /// Set payment terms with cash discount.
485    pub fn with_payment_terms(
486        mut self,
487        terms: impl Into<String>,
488        discount_days_1: Option<u16>,
489        discount_percent_1: Option<Decimal>,
490    ) -> Self {
491        self.payment_terms = terms.into();
492        if let (Some(days), Some(pct)) = (discount_days_1, discount_percent_1) {
493            self.discount_date_1 =
494                Some(self.header.document_date + chrono::Duration::days(days as i64));
495            self.discount_percent_1 = Some(pct);
496        }
497        self
498    }
499
500    /// Set customer PO reference.
501    pub fn with_customer_po(mut self, po_number: impl Into<String>) -> Self {
502        self.customer_po_number = Some(po_number.into());
503        self
504    }
505
506    /// Set as intercompany.
507    pub fn as_intercompany(mut self, partner_company: impl Into<String>) -> Self {
508        self.is_intercompany = true;
509        self.ic_partner = Some(partner_company.into());
510        self.invoice_type = CustomerInvoiceType::Intercompany;
511        self
512    }
513
514    /// Add a line item.
515    pub fn add_item(&mut self, item: CustomerInvoiceItem) {
516        self.items.push(item);
517        self.recalculate_totals();
518    }
519
520    /// Recalculate totals.
521    pub fn recalculate_totals(&mut self) {
522        self.total_net_amount = self.items.iter().map(|i| i.base.net_amount).sum();
523        self.total_tax_amount = self.items.iter().map(|i| i.base.tax_amount).sum();
524        self.total_gross_amount = self.total_net_amount + self.total_tax_amount;
525        self.total_discount = self.items.iter().map(|i| i.discount_amount).sum();
526        self.total_cogs = self.items.iter().map(|i| i.cogs_amount).sum();
527        self.amount_open = self.total_gross_amount - self.amount_paid - self.write_off_amount;
528    }
529
530    /// Post the invoice.
531    pub fn post(&mut self, user: impl Into<String>, posting_date: NaiveDate) {
532        self.is_posted = true;
533        self.header.posting_date = Some(posting_date);
534        self.header.update_status(DocumentStatus::Posted, user);
535        self.recalculate_totals();
536    }
537
538    /// Record a payment.
539    pub fn record_payment(&mut self, amount: Decimal, discount_taken: Decimal) {
540        self.amount_paid += amount + discount_taken;
541        self.amount_open = self.total_gross_amount - self.amount_paid - self.write_off_amount;
542
543        if self.amount_open <= Decimal::ZERO {
544            self.payment_status = InvoicePaymentStatus::Paid;
545        } else if self.amount_paid > Decimal::ZERO {
546            self.payment_status = InvoicePaymentStatus::PartiallyPaid;
547        }
548    }
549
550    /// Clear the invoice.
551    pub fn clear(&mut self) {
552        self.payment_status = InvoicePaymentStatus::Cleared;
553        self.amount_open = Decimal::ZERO;
554        self.header.update_status(DocumentStatus::Cleared, "SYSTEM");
555    }
556
557    /// Put invoice in dispute.
558    pub fn dispute(&mut self, reason: impl Into<String>) {
559        self.payment_status = InvoicePaymentStatus::InDispute;
560        self.dispute_reason = Some(reason.into());
561    }
562
563    /// Resolve dispute.
564    pub fn resolve_dispute(&mut self) {
565        self.dispute_reason = None;
566        if self.amount_open > Decimal::ZERO {
567            self.payment_status = if self.amount_paid > Decimal::ZERO {
568                InvoicePaymentStatus::PartiallyPaid
569            } else {
570                InvoicePaymentStatus::Open
571            };
572        } else {
573            self.payment_status = InvoicePaymentStatus::Paid;
574        }
575    }
576
577    /// Write off remaining amount.
578    pub fn write_off(&mut self, amount: Decimal, reason: impl Into<String>) {
579        self.write_off_amount = amount;
580        self.write_off_reason = Some(reason.into());
581        self.amount_open = self.total_gross_amount - self.amount_paid - self.write_off_amount;
582
583        if self.amount_open <= Decimal::ZERO {
584            self.payment_status = InvoicePaymentStatus::WrittenOff;
585        }
586    }
587
588    /// Record dunning.
589    pub fn record_dunning(&mut self, dunning_date: NaiveDate) {
590        self.dunning_level += 1;
591        self.last_dunning_date = Some(dunning_date);
592
593        if self.dunning_level >= 4 {
594            self.payment_status = InvoicePaymentStatus::InCollection;
595        }
596    }
597
598    /// Cancel the invoice.
599    pub fn cancel(&mut self, user: impl Into<String>, cancellation_invoice: impl Into<String>) {
600        self.is_cancelled = true;
601        self.cancellation_invoice = Some(cancellation_invoice.into());
602        self.header.update_status(DocumentStatus::Cancelled, user);
603    }
604
605    /// Check if invoice is overdue.
606    pub fn is_overdue(&self, as_of_date: NaiveDate) -> bool {
607        self.payment_status == InvoicePaymentStatus::Open && as_of_date > self.due_date
608    }
609
610    /// Days past due.
611    pub fn days_past_due(&self, as_of_date: NaiveDate) -> i64 {
612        if as_of_date <= self.due_date {
613            0
614        } else {
615            (as_of_date - self.due_date).num_days()
616        }
617    }
618
619    /// Get aging bucket.
620    pub fn aging_bucket(&self, as_of_date: NaiveDate) -> AgingBucket {
621        let days = self.days_past_due(as_of_date);
622        match days {
623            d if d <= 0 => AgingBucket::Current,
624            1..=30 => AgingBucket::Days1To30,
625            31..=60 => AgingBucket::Days31To60,
626            61..=90 => AgingBucket::Days61To90,
627            _ => AgingBucket::Over90,
628        }
629    }
630
631    /// Cash discount available.
632    pub fn cash_discount_available(&self, as_of_date: NaiveDate) -> Decimal {
633        if let (Some(date1), Some(pct1)) = (self.discount_date_1, self.discount_percent_1) {
634            if as_of_date <= date1 {
635                return self.amount_open * pct1 / Decimal::from(100);
636            }
637        }
638        if let (Some(date2), Some(pct2)) = (self.discount_date_2, self.discount_percent_2) {
639            if as_of_date <= date2 {
640                return self.amount_open * pct2 / Decimal::from(100);
641            }
642        }
643        Decimal::ZERO
644    }
645
646    /// Calculate gross margin.
647    pub fn gross_margin(&self) -> Decimal {
648        if self.total_net_amount == Decimal::ZERO {
649            return Decimal::ZERO;
650        }
651        ((self.total_net_amount - self.total_cogs) / self.total_net_amount * Decimal::from(100))
652            .round_dp(2)
653    }
654
655    /// Generate GL entries.
656    /// DR Accounts Receivable, CR Revenue, CR Tax Payable
657    pub fn generate_gl_entries(&self) -> Vec<(String, Decimal, Decimal)> {
658        let mut entries = Vec::new();
659
660        let sign = if self.invoice_type.is_debit() { 1 } else { -1 };
661
662        // DR AR (or CR for credit memo)
663        let ar_account = "120000".to_string();
664        if sign > 0 {
665            entries.push((ar_account, self.total_gross_amount, Decimal::ZERO));
666        } else {
667            entries.push((ar_account, Decimal::ZERO, self.total_gross_amount));
668        }
669
670        // CR Revenue per item (or DR for credit memo)
671        for item in &self.items {
672            let revenue_account = item
673                .revenue_account
674                .clone()
675                .or_else(|| item.base.gl_account.clone())
676                .unwrap_or_else(|| "400000".to_string());
677
678            if sign > 0 {
679                entries.push((revenue_account, Decimal::ZERO, item.base.net_amount));
680            } else {
681                entries.push((revenue_account, item.base.net_amount, Decimal::ZERO));
682            }
683        }
684
685        // CR Tax (or DR for credit memo)
686        if self.total_tax_amount > Decimal::ZERO {
687            let tax_account = "220000".to_string();
688            if sign > 0 {
689                entries.push((tax_account, Decimal::ZERO, self.total_tax_amount));
690            } else {
691                entries.push((tax_account, self.total_tax_amount, Decimal::ZERO));
692            }
693        }
694
695        entries
696    }
697}
698
699/// AR aging bucket.
700#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
701pub enum AgingBucket {
702    /// Not yet due
703    Current,
704    /// 1-30 days past due
705    Days1To30,
706    /// 31-60 days past due
707    Days31To60,
708    /// 61-90 days past due
709    Days61To90,
710    /// Over 90 days past due
711    Over90,
712}
713
714#[cfg(test)]
715#[allow(clippy::unwrap_used)]
716mod tests {
717    use super::*;
718
719    #[test]
720    fn test_customer_invoice_creation() {
721        let invoice = CustomerInvoice::new(
722            "CI-1000-0000000001",
723            "1000",
724            "C-000001",
725            2024,
726            1,
727            NaiveDate::from_ymd_opt(2024, 1, 15).unwrap(),
728            NaiveDate::from_ymd_opt(2024, 2, 14).unwrap(),
729            "JSMITH",
730        );
731
732        assert_eq!(invoice.customer_id, "C-000001");
733        assert_eq!(invoice.payment_status, InvoicePaymentStatus::Open);
734    }
735
736    #[test]
737    fn test_customer_invoice_from_delivery() {
738        let invoice = CustomerInvoice::from_delivery(
739            "CI-1000-0000000001",
740            "1000",
741            "DLV-1000-0000000001",
742            "C-000001",
743            2024,
744            1,
745            NaiveDate::from_ymd_opt(2024, 1, 15).unwrap(),
746            NaiveDate::from_ymd_opt(2024, 2, 14).unwrap(),
747            "JSMITH",
748        );
749
750        assert_eq!(invoice.delivery_id, Some("DLV-1000-0000000001".to_string()));
751        assert_eq!(invoice.header.document_references.len(), 1);
752    }
753
754    #[test]
755    fn test_invoice_items() {
756        let mut invoice = CustomerInvoice::new(
757            "CI-1000-0000000001",
758            "1000",
759            "C-000001",
760            2024,
761            1,
762            NaiveDate::from_ymd_opt(2024, 1, 15).unwrap(),
763            NaiveDate::from_ymd_opt(2024, 2, 14).unwrap(),
764            "JSMITH",
765        );
766
767        let item = CustomerInvoiceItem::from_delivery(
768            1,
769            "Product A",
770            Decimal::from(100),
771            Decimal::from(50),
772            "DLV-1000-0000000001",
773            1,
774        )
775        .with_material("MAT-001")
776        .with_cogs(Decimal::from(3000));
777
778        invoice.add_item(item);
779
780        assert_eq!(invoice.total_net_amount, Decimal::from(5000));
781        assert_eq!(invoice.total_cogs, Decimal::from(3000));
782        assert_eq!(invoice.gross_margin(), Decimal::from(40)); // (5000-3000)/5000 * 100 = 40%
783    }
784
785    #[test]
786    fn test_payment_recording() {
787        let mut invoice = CustomerInvoice::new(
788            "CI-1000-0000000001",
789            "1000",
790            "C-000001",
791            2024,
792            1,
793            NaiveDate::from_ymd_opt(2024, 1, 15).unwrap(),
794            NaiveDate::from_ymd_opt(2024, 2, 14).unwrap(),
795            "JSMITH",
796        );
797
798        let item = CustomerInvoiceItem::new(1, "Product", Decimal::from(10), Decimal::from(100));
799        invoice.add_item(item);
800        invoice.post("BILLING", NaiveDate::from_ymd_opt(2024, 1, 15).unwrap());
801
802        assert_eq!(invoice.amount_open, Decimal::from(1000));
803
804        // Partial payment
805        invoice.record_payment(Decimal::from(500), Decimal::ZERO);
806        assert_eq!(invoice.amount_paid, Decimal::from(500));
807        assert_eq!(invoice.amount_open, Decimal::from(500));
808        assert_eq!(invoice.payment_status, InvoicePaymentStatus::PartiallyPaid);
809
810        // Final payment
811        invoice.record_payment(Decimal::from(500), Decimal::ZERO);
812        assert_eq!(invoice.payment_status, InvoicePaymentStatus::Paid);
813    }
814
815    #[test]
816    fn test_cash_discount() {
817        let mut invoice = CustomerInvoice::new(
818            "CI-1000-0000000001",
819            "1000",
820            "C-000001",
821            2024,
822            1,
823            NaiveDate::from_ymd_opt(2024, 1, 15).unwrap(),
824            NaiveDate::from_ymd_opt(2024, 2, 14).unwrap(),
825            "JSMITH",
826        )
827        .with_payment_terms("2/10 NET 30", Some(10), Some(Decimal::from(2)));
828
829        let item = CustomerInvoiceItem::new(1, "Product", Decimal::from(10), Decimal::from(100));
830        invoice.add_item(item);
831        invoice.post("BILLING", NaiveDate::from_ymd_opt(2024, 1, 15).unwrap());
832
833        // Within discount period
834        let discount =
835            invoice.cash_discount_available(NaiveDate::from_ymd_opt(2024, 1, 20).unwrap());
836        assert_eq!(discount, Decimal::from(20)); // 2% of 1000
837
838        // After discount period
839        let discount =
840            invoice.cash_discount_available(NaiveDate::from_ymd_opt(2024, 1, 30).unwrap());
841        assert_eq!(discount, Decimal::ZERO);
842    }
843
844    #[test]
845    fn test_aging() {
846        let invoice = CustomerInvoice::new(
847            "CI-1000-0000000001",
848            "1000",
849            "C-000001",
850            2024,
851            1,
852            NaiveDate::from_ymd_opt(2024, 1, 15).unwrap(),
853            NaiveDate::from_ymd_opt(2024, 2, 14).unwrap(),
854            "JSMITH",
855        );
856
857        // Not overdue
858        assert!(!invoice.is_overdue(NaiveDate::from_ymd_opt(2024, 2, 14).unwrap()));
859        assert_eq!(
860            invoice.aging_bucket(NaiveDate::from_ymd_opt(2024, 2, 14).unwrap()),
861            AgingBucket::Current
862        );
863
864        // 15 days overdue
865        assert!(invoice.is_overdue(NaiveDate::from_ymd_opt(2024, 3, 1).unwrap()));
866        assert_eq!(
867            invoice.aging_bucket(NaiveDate::from_ymd_opt(2024, 3, 1).unwrap()),
868            AgingBucket::Days1To30
869        );
870
871        // 45 days overdue
872        assert_eq!(
873            invoice.aging_bucket(NaiveDate::from_ymd_opt(2024, 4, 1).unwrap()),
874            AgingBucket::Days31To60
875        );
876
877        // 100 days overdue
878        assert_eq!(
879            invoice.aging_bucket(NaiveDate::from_ymd_opt(2024, 5, 25).unwrap()),
880            AgingBucket::Over90
881        );
882    }
883
884    #[test]
885    fn test_gl_entry_generation() {
886        let mut invoice = CustomerInvoice::new(
887            "CI-1000-0000000001",
888            "1000",
889            "C-000001",
890            2024,
891            1,
892            NaiveDate::from_ymd_opt(2024, 1, 15).unwrap(),
893            NaiveDate::from_ymd_opt(2024, 2, 14).unwrap(),
894            "JSMITH",
895        );
896
897        let mut item =
898            CustomerInvoiceItem::new(1, "Product", Decimal::from(10), Decimal::from(100));
899        item.base.tax_amount = Decimal::from(100);
900        invoice.add_item(item);
901        invoice.recalculate_totals();
902
903        let entries = invoice.generate_gl_entries();
904        assert_eq!(entries.len(), 3);
905
906        // DR AR
907        assert_eq!(entries[0].0, "120000");
908        assert_eq!(entries[0].1, Decimal::from(1100)); // 1000 net + 100 tax
909
910        // CR Revenue
911        assert_eq!(entries[1].0, "400000");
912        assert_eq!(entries[1].2, Decimal::from(1000));
913
914        // CR Tax
915        assert_eq!(entries[2].0, "220000");
916        assert_eq!(entries[2].2, Decimal::from(100));
917    }
918
919    #[test]
920    fn test_credit_memo_gl_entries() {
921        let mut invoice = CustomerInvoice::credit_memo(
922            "CM-1000-0000000001",
923            "1000",
924            "C-000001",
925            2024,
926            1,
927            NaiveDate::from_ymd_opt(2024, 1, 15).unwrap(),
928            "JSMITH",
929        );
930
931        let item = CustomerInvoiceItem::new(1, "Return", Decimal::from(5), Decimal::from(100));
932        invoice.add_item(item);
933
934        let entries = invoice.generate_gl_entries();
935
936        // CR AR (credit reduces AR)
937        assert_eq!(entries[0].0, "120000");
938        assert_eq!(entries[0].2, Decimal::from(500)); // Credit side
939
940        // DR Revenue (credit reduces revenue)
941        assert_eq!(entries[1].0, "400000");
942        assert_eq!(entries[1].1, Decimal::from(500)); // Debit side
943    }
944
945    #[test]
946    fn test_write_off() {
947        let mut invoice = CustomerInvoice::new(
948            "CI-1000-0000000001",
949            "1000",
950            "C-000001",
951            2024,
952            1,
953            NaiveDate::from_ymd_opt(2024, 1, 15).unwrap(),
954            NaiveDate::from_ymd_opt(2024, 2, 14).unwrap(),
955            "JSMITH",
956        );
957
958        let item = CustomerInvoiceItem::new(1, "Product", Decimal::from(10), Decimal::from(100));
959        invoice.add_item(item);
960        invoice.post("BILLING", NaiveDate::from_ymd_opt(2024, 1, 15).unwrap());
961
962        invoice.record_payment(Decimal::from(900), Decimal::ZERO);
963        invoice.write_off(Decimal::from(100), "Small balance write-off");
964
965        assert_eq!(invoice.write_off_amount, Decimal::from(100));
966        assert_eq!(invoice.amount_open, Decimal::ZERO);
967        assert_eq!(invoice.payment_status, InvoicePaymentStatus::WrittenOff);
968    }
969
970    #[test]
971    fn test_dunning() {
972        let mut invoice = CustomerInvoice::new(
973            "CI-1000-0000000001",
974            "1000",
975            "C-000001",
976            2024,
977            1,
978            NaiveDate::from_ymd_opt(2024, 1, 15).unwrap(),
979            NaiveDate::from_ymd_opt(2024, 2, 14).unwrap(),
980            "JSMITH",
981        );
982
983        invoice.record_dunning(NaiveDate::from_ymd_opt(2024, 2, 20).unwrap());
984        assert_eq!(invoice.dunning_level, 1);
985
986        invoice.record_dunning(NaiveDate::from_ymd_opt(2024, 3, 5).unwrap());
987        invoice.record_dunning(NaiveDate::from_ymd_opt(2024, 3, 20).unwrap());
988        invoice.record_dunning(NaiveDate::from_ymd_opt(2024, 4, 5).unwrap());
989
990        assert_eq!(invoice.dunning_level, 4);
991        assert_eq!(invoice.payment_status, InvoicePaymentStatus::InCollection);
992    }
993}