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)]
715mod tests {
716    use super::*;
717
718    #[test]
719    fn test_customer_invoice_creation() {
720        let invoice = CustomerInvoice::new(
721            "CI-1000-0000000001",
722            "1000",
723            "C-000001",
724            2024,
725            1,
726            NaiveDate::from_ymd_opt(2024, 1, 15).unwrap(),
727            NaiveDate::from_ymd_opt(2024, 2, 14).unwrap(),
728            "JSMITH",
729        );
730
731        assert_eq!(invoice.customer_id, "C-000001");
732        assert_eq!(invoice.payment_status, InvoicePaymentStatus::Open);
733    }
734
735    #[test]
736    fn test_customer_invoice_from_delivery() {
737        let invoice = CustomerInvoice::from_delivery(
738            "CI-1000-0000000001",
739            "1000",
740            "DLV-1000-0000000001",
741            "C-000001",
742            2024,
743            1,
744            NaiveDate::from_ymd_opt(2024, 1, 15).unwrap(),
745            NaiveDate::from_ymd_opt(2024, 2, 14).unwrap(),
746            "JSMITH",
747        );
748
749        assert_eq!(invoice.delivery_id, Some("DLV-1000-0000000001".to_string()));
750        assert_eq!(invoice.header.document_references.len(), 1);
751    }
752
753    #[test]
754    fn test_invoice_items() {
755        let mut invoice = CustomerInvoice::new(
756            "CI-1000-0000000001",
757            "1000",
758            "C-000001",
759            2024,
760            1,
761            NaiveDate::from_ymd_opt(2024, 1, 15).unwrap(),
762            NaiveDate::from_ymd_opt(2024, 2, 14).unwrap(),
763            "JSMITH",
764        );
765
766        let item = CustomerInvoiceItem::from_delivery(
767            1,
768            "Product A",
769            Decimal::from(100),
770            Decimal::from(50),
771            "DLV-1000-0000000001",
772            1,
773        )
774        .with_material("MAT-001")
775        .with_cogs(Decimal::from(3000));
776
777        invoice.add_item(item);
778
779        assert_eq!(invoice.total_net_amount, Decimal::from(5000));
780        assert_eq!(invoice.total_cogs, Decimal::from(3000));
781        assert_eq!(invoice.gross_margin(), Decimal::from(40)); // (5000-3000)/5000 * 100 = 40%
782    }
783
784    #[test]
785    fn test_payment_recording() {
786        let mut invoice = CustomerInvoice::new(
787            "CI-1000-0000000001",
788            "1000",
789            "C-000001",
790            2024,
791            1,
792            NaiveDate::from_ymd_opt(2024, 1, 15).unwrap(),
793            NaiveDate::from_ymd_opt(2024, 2, 14).unwrap(),
794            "JSMITH",
795        );
796
797        let item = CustomerInvoiceItem::new(1, "Product", Decimal::from(10), Decimal::from(100));
798        invoice.add_item(item);
799        invoice.post("BILLING", NaiveDate::from_ymd_opt(2024, 1, 15).unwrap());
800
801        assert_eq!(invoice.amount_open, Decimal::from(1000));
802
803        // Partial payment
804        invoice.record_payment(Decimal::from(500), Decimal::ZERO);
805        assert_eq!(invoice.amount_paid, Decimal::from(500));
806        assert_eq!(invoice.amount_open, Decimal::from(500));
807        assert_eq!(invoice.payment_status, InvoicePaymentStatus::PartiallyPaid);
808
809        // Final payment
810        invoice.record_payment(Decimal::from(500), Decimal::ZERO);
811        assert_eq!(invoice.payment_status, InvoicePaymentStatus::Paid);
812    }
813
814    #[test]
815    fn test_cash_discount() {
816        let mut invoice = CustomerInvoice::new(
817            "CI-1000-0000000001",
818            "1000",
819            "C-000001",
820            2024,
821            1,
822            NaiveDate::from_ymd_opt(2024, 1, 15).unwrap(),
823            NaiveDate::from_ymd_opt(2024, 2, 14).unwrap(),
824            "JSMITH",
825        )
826        .with_payment_terms("2/10 NET 30", Some(10), Some(Decimal::from(2)));
827
828        let item = CustomerInvoiceItem::new(1, "Product", Decimal::from(10), Decimal::from(100));
829        invoice.add_item(item);
830        invoice.post("BILLING", NaiveDate::from_ymd_opt(2024, 1, 15).unwrap());
831
832        // Within discount period
833        let discount =
834            invoice.cash_discount_available(NaiveDate::from_ymd_opt(2024, 1, 20).unwrap());
835        assert_eq!(discount, Decimal::from(20)); // 2% of 1000
836
837        // After discount period
838        let discount =
839            invoice.cash_discount_available(NaiveDate::from_ymd_opt(2024, 1, 30).unwrap());
840        assert_eq!(discount, Decimal::ZERO);
841    }
842
843    #[test]
844    fn test_aging() {
845        let invoice = CustomerInvoice::new(
846            "CI-1000-0000000001",
847            "1000",
848            "C-000001",
849            2024,
850            1,
851            NaiveDate::from_ymd_opt(2024, 1, 15).unwrap(),
852            NaiveDate::from_ymd_opt(2024, 2, 14).unwrap(),
853            "JSMITH",
854        );
855
856        // Not overdue
857        assert!(!invoice.is_overdue(NaiveDate::from_ymd_opt(2024, 2, 14).unwrap()));
858        assert_eq!(
859            invoice.aging_bucket(NaiveDate::from_ymd_opt(2024, 2, 14).unwrap()),
860            AgingBucket::Current
861        );
862
863        // 15 days overdue
864        assert!(invoice.is_overdue(NaiveDate::from_ymd_opt(2024, 3, 1).unwrap()));
865        assert_eq!(
866            invoice.aging_bucket(NaiveDate::from_ymd_opt(2024, 3, 1).unwrap()),
867            AgingBucket::Days1To30
868        );
869
870        // 45 days overdue
871        assert_eq!(
872            invoice.aging_bucket(NaiveDate::from_ymd_opt(2024, 4, 1).unwrap()),
873            AgingBucket::Days31To60
874        );
875
876        // 100 days overdue
877        assert_eq!(
878            invoice.aging_bucket(NaiveDate::from_ymd_opt(2024, 5, 25).unwrap()),
879            AgingBucket::Over90
880        );
881    }
882
883    #[test]
884    fn test_gl_entry_generation() {
885        let mut invoice = CustomerInvoice::new(
886            "CI-1000-0000000001",
887            "1000",
888            "C-000001",
889            2024,
890            1,
891            NaiveDate::from_ymd_opt(2024, 1, 15).unwrap(),
892            NaiveDate::from_ymd_opt(2024, 2, 14).unwrap(),
893            "JSMITH",
894        );
895
896        let mut item =
897            CustomerInvoiceItem::new(1, "Product", Decimal::from(10), Decimal::from(100));
898        item.base.tax_amount = Decimal::from(100);
899        invoice.add_item(item);
900        invoice.recalculate_totals();
901
902        let entries = invoice.generate_gl_entries();
903        assert_eq!(entries.len(), 3);
904
905        // DR AR
906        assert_eq!(entries[0].0, "120000");
907        assert_eq!(entries[0].1, Decimal::from(1100)); // 1000 net + 100 tax
908
909        // CR Revenue
910        assert_eq!(entries[1].0, "400000");
911        assert_eq!(entries[1].2, Decimal::from(1000));
912
913        // CR Tax
914        assert_eq!(entries[2].0, "220000");
915        assert_eq!(entries[2].2, Decimal::from(100));
916    }
917
918    #[test]
919    fn test_credit_memo_gl_entries() {
920        let mut invoice = CustomerInvoice::credit_memo(
921            "CM-1000-0000000001",
922            "1000",
923            "C-000001",
924            2024,
925            1,
926            NaiveDate::from_ymd_opt(2024, 1, 15).unwrap(),
927            "JSMITH",
928        );
929
930        let item = CustomerInvoiceItem::new(1, "Return", Decimal::from(5), Decimal::from(100));
931        invoice.add_item(item);
932
933        let entries = invoice.generate_gl_entries();
934
935        // CR AR (credit reduces AR)
936        assert_eq!(entries[0].0, "120000");
937        assert_eq!(entries[0].2, Decimal::from(500)); // Credit side
938
939        // DR Revenue (credit reduces revenue)
940        assert_eq!(entries[1].0, "400000");
941        assert_eq!(entries[1].1, Decimal::from(500)); // Debit side
942    }
943
944    #[test]
945    fn test_write_off() {
946        let mut invoice = CustomerInvoice::new(
947            "CI-1000-0000000001",
948            "1000",
949            "C-000001",
950            2024,
951            1,
952            NaiveDate::from_ymd_opt(2024, 1, 15).unwrap(),
953            NaiveDate::from_ymd_opt(2024, 2, 14).unwrap(),
954            "JSMITH",
955        );
956
957        let item = CustomerInvoiceItem::new(1, "Product", Decimal::from(10), Decimal::from(100));
958        invoice.add_item(item);
959        invoice.post("BILLING", NaiveDate::from_ymd_opt(2024, 1, 15).unwrap());
960
961        invoice.record_payment(Decimal::from(900), Decimal::ZERO);
962        invoice.write_off(Decimal::from(100), "Small balance write-off");
963
964        assert_eq!(invoice.write_off_amount, Decimal::from(100));
965        assert_eq!(invoice.amount_open, Decimal::ZERO);
966        assert_eq!(invoice.payment_status, InvoicePaymentStatus::WrittenOff);
967    }
968
969    #[test]
970    fn test_dunning() {
971        let mut invoice = CustomerInvoice::new(
972            "CI-1000-0000000001",
973            "1000",
974            "C-000001",
975            2024,
976            1,
977            NaiveDate::from_ymd_opt(2024, 1, 15).unwrap(),
978            NaiveDate::from_ymd_opt(2024, 2, 14).unwrap(),
979            "JSMITH",
980        );
981
982        invoice.record_dunning(NaiveDate::from_ymd_opt(2024, 2, 20).unwrap());
983        assert_eq!(invoice.dunning_level, 1);
984
985        invoice.record_dunning(NaiveDate::from_ymd_opt(2024, 3, 5).unwrap());
986        invoice.record_dunning(NaiveDate::from_ymd_opt(2024, 3, 20).unwrap());
987        invoice.record_dunning(NaiveDate::from_ymd_opt(2024, 4, 5).unwrap());
988
989        assert_eq!(invoice.dunning_level, 4);
990        assert_eq!(invoice.payment_status, InvoicePaymentStatus::InCollection);
991    }
992}