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    /// Customer display name (denormalized, DS-011)
327    #[serde(default, skip_serializing_if = "Option::is_none")]
328    pub customer_name: Option<String>,
329}
330
331impl CustomerInvoice {
332    /// Create a new customer invoice.
333    #[allow(clippy::too_many_arguments)]
334    pub fn new(
335        invoice_id: impl Into<String>,
336        company_code: impl Into<String>,
337        customer_id: impl Into<String>,
338        fiscal_year: u16,
339        fiscal_period: u8,
340        document_date: NaiveDate,
341        due_date: NaiveDate,
342        created_by: impl Into<String>,
343    ) -> Self {
344        let header = DocumentHeader::new(
345            invoice_id,
346            DocumentType::CustomerInvoice,
347            company_code,
348            fiscal_year,
349            fiscal_period,
350            document_date,
351            created_by,
352        )
353        .with_currency("USD");
354
355        Self {
356            header,
357            invoice_type: CustomerInvoiceType::Standard,
358            items: Vec::new(),
359            customer_id: customer_id.into(),
360            bill_to: None,
361            payer: None,
362            sales_org: "1000".to_string(),
363            distribution_channel: "10".to_string(),
364            division: "00".to_string(),
365            total_net_amount: Decimal::ZERO,
366            total_tax_amount: Decimal::ZERO,
367            total_gross_amount: Decimal::ZERO,
368            total_discount: Decimal::ZERO,
369            total_cogs: Decimal::ZERO,
370            payment_terms: "NET30".to_string(),
371            due_date,
372            discount_date_1: None,
373            discount_percent_1: None,
374            discount_date_2: None,
375            discount_percent_2: None,
376            amount_paid: Decimal::ZERO,
377            amount_open: Decimal::ZERO,
378            payment_status: InvoicePaymentStatus::Open,
379            sales_order_id: None,
380            delivery_id: None,
381            external_reference: None,
382            customer_po_number: None,
383            is_posted: false,
384            is_output_complete: false,
385            is_intercompany: false,
386            ic_partner: None,
387            dispute_reason: None,
388            write_off_amount: Decimal::ZERO,
389            write_off_reason: None,
390            dunning_level: 0,
391            last_dunning_date: None,
392            is_cancelled: false,
393            cancellation_invoice: None,
394            customer_name: None,
395        }
396    }
397
398    /// Create from delivery reference.
399    #[allow(clippy::too_many_arguments)]
400    pub fn from_delivery(
401        invoice_id: impl Into<String>,
402        company_code: impl Into<String>,
403        delivery_id: impl Into<String>,
404        customer_id: impl Into<String>,
405        fiscal_year: u16,
406        fiscal_period: u8,
407        document_date: NaiveDate,
408        due_date: NaiveDate,
409        created_by: impl Into<String>,
410    ) -> Self {
411        let dlv_id = delivery_id.into();
412        let mut invoice = Self::new(
413            invoice_id,
414            company_code,
415            customer_id,
416            fiscal_year,
417            fiscal_period,
418            document_date,
419            due_date,
420            created_by,
421        );
422        invoice.delivery_id = Some(dlv_id.clone());
423
424        // Add reference to delivery
425        invoice.header.add_reference(DocumentReference::new(
426            DocumentType::Delivery,
427            dlv_id,
428            DocumentType::CustomerInvoice,
429            invoice.header.document_id.clone(),
430            ReferenceType::FollowOn,
431            invoice.header.company_code.clone(),
432            document_date,
433        ));
434
435        invoice
436    }
437
438    /// Create a credit memo.
439    pub fn credit_memo(
440        invoice_id: impl Into<String>,
441        company_code: impl Into<String>,
442        customer_id: impl Into<String>,
443        fiscal_year: u16,
444        fiscal_period: u8,
445        document_date: NaiveDate,
446        created_by: impl Into<String>,
447    ) -> Self {
448        let mut invoice = Self::new(
449            invoice_id,
450            company_code,
451            customer_id,
452            fiscal_year,
453            fiscal_period,
454            document_date,
455            document_date, // Due immediately
456            created_by,
457        );
458        invoice.invoice_type = CustomerInvoiceType::CreditMemo;
459        invoice.header.document_type = DocumentType::CreditMemo;
460        invoice
461    }
462
463    /// Set invoice type.
464    pub fn with_invoice_type(mut self, invoice_type: CustomerInvoiceType) -> Self {
465        self.invoice_type = invoice_type;
466        self
467    }
468
469    /// Set sales organization.
470    pub fn with_sales_org(
471        mut self,
472        sales_org: impl Into<String>,
473        dist_channel: impl Into<String>,
474        division: impl Into<String>,
475    ) -> Self {
476        self.sales_org = sales_org.into();
477        self.distribution_channel = dist_channel.into();
478        self.division = division.into();
479        self
480    }
481
482    /// Set partner functions.
483    pub fn with_partners(mut self, bill_to: impl Into<String>, payer: impl Into<String>) -> Self {
484        self.bill_to = Some(bill_to.into());
485        self.payer = Some(payer.into());
486        self
487    }
488
489    /// Set payment terms with cash discount.
490    pub fn with_payment_terms(
491        mut self,
492        terms: impl Into<String>,
493        discount_days_1: Option<u16>,
494        discount_percent_1: Option<Decimal>,
495    ) -> Self {
496        self.payment_terms = terms.into();
497        if let (Some(days), Some(pct)) = (discount_days_1, discount_percent_1) {
498            self.discount_date_1 =
499                Some(self.header.document_date + chrono::Duration::days(days as i64));
500            self.discount_percent_1 = Some(pct);
501        }
502        self
503    }
504
505    /// Set customer PO reference.
506    pub fn with_customer_po(mut self, po_number: impl Into<String>) -> Self {
507        self.customer_po_number = Some(po_number.into());
508        self
509    }
510
511    /// Set as intercompany.
512    pub fn as_intercompany(mut self, partner_company: impl Into<String>) -> Self {
513        self.is_intercompany = true;
514        self.ic_partner = Some(partner_company.into());
515        self.invoice_type = CustomerInvoiceType::Intercompany;
516        self
517    }
518
519    /// Add a line item.
520    pub fn add_item(&mut self, item: CustomerInvoiceItem) {
521        self.items.push(item);
522        self.recalculate_totals();
523    }
524
525    /// Recalculate totals.
526    pub fn recalculate_totals(&mut self) {
527        self.total_net_amount = self.items.iter().map(|i| i.base.net_amount).sum();
528        self.total_tax_amount = self.items.iter().map(|i| i.base.tax_amount).sum();
529        self.total_gross_amount = self.total_net_amount + self.total_tax_amount;
530        self.total_discount = self.items.iter().map(|i| i.discount_amount).sum();
531        self.total_cogs = self.items.iter().map(|i| i.cogs_amount).sum();
532        self.amount_open = self.total_gross_amount - self.amount_paid - self.write_off_amount;
533    }
534
535    /// Post the invoice.
536    pub fn post(&mut self, user: impl Into<String>, posting_date: NaiveDate) {
537        self.is_posted = true;
538        self.header.posting_date = Some(posting_date);
539        self.header.update_status(DocumentStatus::Posted, user);
540        self.recalculate_totals();
541    }
542
543    /// Record a payment.
544    pub fn record_payment(&mut self, amount: Decimal, discount_taken: Decimal) {
545        self.amount_paid += amount + discount_taken;
546        self.amount_open = self.total_gross_amount - self.amount_paid - self.write_off_amount;
547
548        if self.amount_open <= Decimal::ZERO {
549            self.payment_status = InvoicePaymentStatus::Paid;
550        } else if self.amount_paid > Decimal::ZERO {
551            self.payment_status = InvoicePaymentStatus::PartiallyPaid;
552        }
553    }
554
555    /// Clear the invoice.
556    pub fn clear(&mut self) {
557        self.payment_status = InvoicePaymentStatus::Cleared;
558        self.amount_open = Decimal::ZERO;
559        self.header.update_status(DocumentStatus::Cleared, "SYSTEM");
560    }
561
562    /// Put invoice in dispute.
563    pub fn dispute(&mut self, reason: impl Into<String>) {
564        self.payment_status = InvoicePaymentStatus::InDispute;
565        self.dispute_reason = Some(reason.into());
566    }
567
568    /// Resolve dispute.
569    pub fn resolve_dispute(&mut self) {
570        self.dispute_reason = None;
571        if self.amount_open > Decimal::ZERO {
572            self.payment_status = if self.amount_paid > Decimal::ZERO {
573                InvoicePaymentStatus::PartiallyPaid
574            } else {
575                InvoicePaymentStatus::Open
576            };
577        } else {
578            self.payment_status = InvoicePaymentStatus::Paid;
579        }
580    }
581
582    /// Write off remaining amount.
583    pub fn write_off(&mut self, amount: Decimal, reason: impl Into<String>) {
584        self.write_off_amount = amount;
585        self.write_off_reason = Some(reason.into());
586        self.amount_open = self.total_gross_amount - self.amount_paid - self.write_off_amount;
587
588        if self.amount_open <= Decimal::ZERO {
589            self.payment_status = InvoicePaymentStatus::WrittenOff;
590        }
591    }
592
593    /// Record dunning.
594    pub fn record_dunning(&mut self, dunning_date: NaiveDate) {
595        self.dunning_level += 1;
596        self.last_dunning_date = Some(dunning_date);
597
598        if self.dunning_level >= 4 {
599            self.payment_status = InvoicePaymentStatus::InCollection;
600        }
601    }
602
603    /// Cancel the invoice.
604    pub fn cancel(&mut self, user: impl Into<String>, cancellation_invoice: impl Into<String>) {
605        self.is_cancelled = true;
606        self.cancellation_invoice = Some(cancellation_invoice.into());
607        self.header.update_status(DocumentStatus::Cancelled, user);
608    }
609
610    /// Check if invoice is overdue.
611    pub fn is_overdue(&self, as_of_date: NaiveDate) -> bool {
612        self.payment_status == InvoicePaymentStatus::Open && as_of_date > self.due_date
613    }
614
615    /// Days past due.
616    pub fn days_past_due(&self, as_of_date: NaiveDate) -> i64 {
617        if as_of_date <= self.due_date {
618            0
619        } else {
620            (as_of_date - self.due_date).num_days()
621        }
622    }
623
624    /// Get aging bucket.
625    pub fn aging_bucket(&self, as_of_date: NaiveDate) -> AgingBucket {
626        let days = self.days_past_due(as_of_date);
627        match days {
628            d if d <= 0 => AgingBucket::Current,
629            1..=30 => AgingBucket::Days1To30,
630            31..=60 => AgingBucket::Days31To60,
631            61..=90 => AgingBucket::Days61To90,
632            _ => AgingBucket::Over90,
633        }
634    }
635
636    /// Cash discount available.
637    pub fn cash_discount_available(&self, as_of_date: NaiveDate) -> Decimal {
638        if let (Some(date1), Some(pct1)) = (self.discount_date_1, self.discount_percent_1) {
639            if as_of_date <= date1 {
640                return self.amount_open * pct1 / Decimal::from(100);
641            }
642        }
643        if let (Some(date2), Some(pct2)) = (self.discount_date_2, self.discount_percent_2) {
644            if as_of_date <= date2 {
645                return self.amount_open * pct2 / Decimal::from(100);
646            }
647        }
648        Decimal::ZERO
649    }
650
651    /// Calculate gross margin.
652    pub fn gross_margin(&self) -> Decimal {
653        if self.total_net_amount == Decimal::ZERO {
654            return Decimal::ZERO;
655        }
656        ((self.total_net_amount - self.total_cogs) / self.total_net_amount * Decimal::from(100))
657            .round_dp(2)
658    }
659
660    /// Generate GL entries.
661    /// DR Accounts Receivable, CR Revenue, CR Tax Payable
662    pub fn generate_gl_entries(&self) -> Vec<(String, Decimal, Decimal)> {
663        let mut entries = Vec::new();
664
665        let sign = if self.invoice_type.is_debit() { 1 } else { -1 };
666
667        // DR AR (or CR for credit memo)
668        let ar_account = "120000".to_string();
669        if sign > 0 {
670            entries.push((ar_account, self.total_gross_amount, Decimal::ZERO));
671        } else {
672            entries.push((ar_account, Decimal::ZERO, self.total_gross_amount));
673        }
674
675        // CR Revenue per item (or DR for credit memo)
676        for item in &self.items {
677            let revenue_account = item
678                .revenue_account
679                .clone()
680                .or_else(|| item.base.gl_account.clone())
681                .unwrap_or_else(|| "400000".to_string());
682
683            if sign > 0 {
684                entries.push((revenue_account, Decimal::ZERO, item.base.net_amount));
685            } else {
686                entries.push((revenue_account, item.base.net_amount, Decimal::ZERO));
687            }
688        }
689
690        // CR Tax (or DR for credit memo)
691        if self.total_tax_amount > Decimal::ZERO {
692            let tax_account = "220000".to_string();
693            if sign > 0 {
694                entries.push((tax_account, Decimal::ZERO, self.total_tax_amount));
695            } else {
696                entries.push((tax_account, self.total_tax_amount, Decimal::ZERO));
697            }
698        }
699
700        entries
701    }
702}
703
704/// AR aging bucket.
705#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
706pub enum AgingBucket {
707    /// Not yet due
708    Current,
709    /// 1-30 days past due
710    Days1To30,
711    /// 31-60 days past due
712    Days31To60,
713    /// 61-90 days past due
714    Days61To90,
715    /// Over 90 days past due
716    Over90,
717}
718
719#[cfg(test)]
720#[allow(clippy::unwrap_used)]
721mod tests {
722    use super::*;
723
724    #[test]
725    fn test_customer_invoice_creation() {
726        let invoice = CustomerInvoice::new(
727            "CI-1000-0000000001",
728            "1000",
729            "C-000001",
730            2024,
731            1,
732            NaiveDate::from_ymd_opt(2024, 1, 15).unwrap(),
733            NaiveDate::from_ymd_opt(2024, 2, 14).unwrap(),
734            "JSMITH",
735        );
736
737        assert_eq!(invoice.customer_id, "C-000001");
738        assert_eq!(invoice.payment_status, InvoicePaymentStatus::Open);
739    }
740
741    #[test]
742    fn test_customer_invoice_from_delivery() {
743        let invoice = CustomerInvoice::from_delivery(
744            "CI-1000-0000000001",
745            "1000",
746            "DLV-1000-0000000001",
747            "C-000001",
748            2024,
749            1,
750            NaiveDate::from_ymd_opt(2024, 1, 15).unwrap(),
751            NaiveDate::from_ymd_opt(2024, 2, 14).unwrap(),
752            "JSMITH",
753        );
754
755        assert_eq!(invoice.delivery_id, Some("DLV-1000-0000000001".to_string()));
756        assert_eq!(invoice.header.document_references.len(), 1);
757    }
758
759    #[test]
760    fn test_invoice_items() {
761        let mut invoice = CustomerInvoice::new(
762            "CI-1000-0000000001",
763            "1000",
764            "C-000001",
765            2024,
766            1,
767            NaiveDate::from_ymd_opt(2024, 1, 15).unwrap(),
768            NaiveDate::from_ymd_opt(2024, 2, 14).unwrap(),
769            "JSMITH",
770        );
771
772        let item = CustomerInvoiceItem::from_delivery(
773            1,
774            "Product A",
775            Decimal::from(100),
776            Decimal::from(50),
777            "DLV-1000-0000000001",
778            1,
779        )
780        .with_material("MAT-001")
781        .with_cogs(Decimal::from(3000));
782
783        invoice.add_item(item);
784
785        assert_eq!(invoice.total_net_amount, Decimal::from(5000));
786        assert_eq!(invoice.total_cogs, Decimal::from(3000));
787        assert_eq!(invoice.gross_margin(), Decimal::from(40)); // (5000-3000)/5000 * 100 = 40%
788    }
789
790    #[test]
791    fn test_payment_recording() {
792        let mut invoice = CustomerInvoice::new(
793            "CI-1000-0000000001",
794            "1000",
795            "C-000001",
796            2024,
797            1,
798            NaiveDate::from_ymd_opt(2024, 1, 15).unwrap(),
799            NaiveDate::from_ymd_opt(2024, 2, 14).unwrap(),
800            "JSMITH",
801        );
802
803        let item = CustomerInvoiceItem::new(1, "Product", Decimal::from(10), Decimal::from(100));
804        invoice.add_item(item);
805        invoice.post("BILLING", NaiveDate::from_ymd_opt(2024, 1, 15).unwrap());
806
807        assert_eq!(invoice.amount_open, Decimal::from(1000));
808
809        // Partial payment
810        invoice.record_payment(Decimal::from(500), Decimal::ZERO);
811        assert_eq!(invoice.amount_paid, Decimal::from(500));
812        assert_eq!(invoice.amount_open, Decimal::from(500));
813        assert_eq!(invoice.payment_status, InvoicePaymentStatus::PartiallyPaid);
814
815        // Final payment
816        invoice.record_payment(Decimal::from(500), Decimal::ZERO);
817        assert_eq!(invoice.payment_status, InvoicePaymentStatus::Paid);
818    }
819
820    #[test]
821    fn test_cash_discount() {
822        let mut invoice = CustomerInvoice::new(
823            "CI-1000-0000000001",
824            "1000",
825            "C-000001",
826            2024,
827            1,
828            NaiveDate::from_ymd_opt(2024, 1, 15).unwrap(),
829            NaiveDate::from_ymd_opt(2024, 2, 14).unwrap(),
830            "JSMITH",
831        )
832        .with_payment_terms("2/10 NET 30", Some(10), Some(Decimal::from(2)));
833
834        let item = CustomerInvoiceItem::new(1, "Product", Decimal::from(10), Decimal::from(100));
835        invoice.add_item(item);
836        invoice.post("BILLING", NaiveDate::from_ymd_opt(2024, 1, 15).unwrap());
837
838        // Within discount period
839        let discount =
840            invoice.cash_discount_available(NaiveDate::from_ymd_opt(2024, 1, 20).unwrap());
841        assert_eq!(discount, Decimal::from(20)); // 2% of 1000
842
843        // After discount period
844        let discount =
845            invoice.cash_discount_available(NaiveDate::from_ymd_opt(2024, 1, 30).unwrap());
846        assert_eq!(discount, Decimal::ZERO);
847    }
848
849    #[test]
850    fn test_aging() {
851        let invoice = CustomerInvoice::new(
852            "CI-1000-0000000001",
853            "1000",
854            "C-000001",
855            2024,
856            1,
857            NaiveDate::from_ymd_opt(2024, 1, 15).unwrap(),
858            NaiveDate::from_ymd_opt(2024, 2, 14).unwrap(),
859            "JSMITH",
860        );
861
862        // Not overdue
863        assert!(!invoice.is_overdue(NaiveDate::from_ymd_opt(2024, 2, 14).unwrap()));
864        assert_eq!(
865            invoice.aging_bucket(NaiveDate::from_ymd_opt(2024, 2, 14).unwrap()),
866            AgingBucket::Current
867        );
868
869        // 15 days overdue
870        assert!(invoice.is_overdue(NaiveDate::from_ymd_opt(2024, 3, 1).unwrap()));
871        assert_eq!(
872            invoice.aging_bucket(NaiveDate::from_ymd_opt(2024, 3, 1).unwrap()),
873            AgingBucket::Days1To30
874        );
875
876        // 45 days overdue
877        assert_eq!(
878            invoice.aging_bucket(NaiveDate::from_ymd_opt(2024, 4, 1).unwrap()),
879            AgingBucket::Days31To60
880        );
881
882        // 100 days overdue
883        assert_eq!(
884            invoice.aging_bucket(NaiveDate::from_ymd_opt(2024, 5, 25).unwrap()),
885            AgingBucket::Over90
886        );
887    }
888
889    #[test]
890    fn test_gl_entry_generation() {
891        let mut invoice = CustomerInvoice::new(
892            "CI-1000-0000000001",
893            "1000",
894            "C-000001",
895            2024,
896            1,
897            NaiveDate::from_ymd_opt(2024, 1, 15).unwrap(),
898            NaiveDate::from_ymd_opt(2024, 2, 14).unwrap(),
899            "JSMITH",
900        );
901
902        let mut item =
903            CustomerInvoiceItem::new(1, "Product", Decimal::from(10), Decimal::from(100));
904        item.base.tax_amount = Decimal::from(100);
905        invoice.add_item(item);
906        invoice.recalculate_totals();
907
908        let entries = invoice.generate_gl_entries();
909        assert_eq!(entries.len(), 3);
910
911        // DR AR
912        assert_eq!(entries[0].0, "120000");
913        assert_eq!(entries[0].1, Decimal::from(1100)); // 1000 net + 100 tax
914
915        // CR Revenue
916        assert_eq!(entries[1].0, "400000");
917        assert_eq!(entries[1].2, Decimal::from(1000));
918
919        // CR Tax
920        assert_eq!(entries[2].0, "220000");
921        assert_eq!(entries[2].2, Decimal::from(100));
922    }
923
924    #[test]
925    fn test_credit_memo_gl_entries() {
926        let mut invoice = CustomerInvoice::credit_memo(
927            "CM-1000-0000000001",
928            "1000",
929            "C-000001",
930            2024,
931            1,
932            NaiveDate::from_ymd_opt(2024, 1, 15).unwrap(),
933            "JSMITH",
934        );
935
936        let item = CustomerInvoiceItem::new(1, "Return", Decimal::from(5), Decimal::from(100));
937        invoice.add_item(item);
938
939        let entries = invoice.generate_gl_entries();
940
941        // CR AR (credit reduces AR)
942        assert_eq!(entries[0].0, "120000");
943        assert_eq!(entries[0].2, Decimal::from(500)); // Credit side
944
945        // DR Revenue (credit reduces revenue)
946        assert_eq!(entries[1].0, "400000");
947        assert_eq!(entries[1].1, Decimal::from(500)); // Debit side
948    }
949
950    #[test]
951    fn test_write_off() {
952        let mut invoice = CustomerInvoice::new(
953            "CI-1000-0000000001",
954            "1000",
955            "C-000001",
956            2024,
957            1,
958            NaiveDate::from_ymd_opt(2024, 1, 15).unwrap(),
959            NaiveDate::from_ymd_opt(2024, 2, 14).unwrap(),
960            "JSMITH",
961        );
962
963        let item = CustomerInvoiceItem::new(1, "Product", Decimal::from(10), Decimal::from(100));
964        invoice.add_item(item);
965        invoice.post("BILLING", NaiveDate::from_ymd_opt(2024, 1, 15).unwrap());
966
967        invoice.record_payment(Decimal::from(900), Decimal::ZERO);
968        invoice.write_off(Decimal::from(100), "Small balance write-off");
969
970        assert_eq!(invoice.write_off_amount, Decimal::from(100));
971        assert_eq!(invoice.amount_open, Decimal::ZERO);
972        assert_eq!(invoice.payment_status, InvoicePaymentStatus::WrittenOff);
973    }
974
975    #[test]
976    fn test_dunning() {
977        let mut invoice = CustomerInvoice::new(
978            "CI-1000-0000000001",
979            "1000",
980            "C-000001",
981            2024,
982            1,
983            NaiveDate::from_ymd_opt(2024, 1, 15).unwrap(),
984            NaiveDate::from_ymd_opt(2024, 2, 14).unwrap(),
985            "JSMITH",
986        );
987
988        invoice.record_dunning(NaiveDate::from_ymd_opt(2024, 2, 20).unwrap());
989        assert_eq!(invoice.dunning_level, 1);
990
991        invoice.record_dunning(NaiveDate::from_ymd_opt(2024, 3, 5).unwrap());
992        invoice.record_dunning(NaiveDate::from_ymd_opt(2024, 3, 20).unwrap());
993        invoice.record_dunning(NaiveDate::from_ymd_opt(2024, 4, 5).unwrap());
994
995        assert_eq!(invoice.dunning_level, 4);
996        assert_eq!(invoice.payment_status, InvoicePaymentStatus::InCollection);
997    }
998}