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