1use chrono::NaiveDate;
7use rust_decimal::Decimal;
8use serde::{Deserialize, Serialize};
9
10use super::{
11 DocumentHeader, DocumentLineItem, DocumentReference, DocumentStatus, DocumentType,
12 ReferenceType,
13};
14
15#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize, Default)]
17#[serde(rename_all = "snake_case")]
18pub enum CustomerInvoiceType {
19 #[default]
21 Standard,
22 CreditMemo,
24 DebitMemo,
26 ProForma,
28 DownPaymentRequest,
30 FinalInvoice,
32 Intercompany,
34}
35
36impl CustomerInvoiceType {
37 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 pub fn is_credit(&self) -> bool {
51 matches!(self, Self::CreditMemo)
52 }
53
54 pub fn creates_revenue(&self) -> bool {
56 matches!(
57 self,
58 Self::Standard | Self::FinalInvoice | Self::Intercompany
59 )
60 }
61}
62
63#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize, Default)]
65#[serde(rename_all = "snake_case")]
66pub enum InvoicePaymentStatus {
67 #[default]
69 Open,
70 PartiallyPaid,
72 Paid,
74 Cleared,
76 WrittenOff,
78 InDispute,
80 InCollection,
82}
83
84#[derive(Debug, Clone, Serialize, Deserialize)]
86pub struct CustomerInvoiceItem {
87 #[serde(flatten)]
89 pub base: DocumentLineItem,
90
91 pub sales_order_id: Option<String>,
93
94 pub so_item: Option<u16>,
96
97 pub delivery_id: Option<String>,
99
100 pub delivery_item: Option<u16>,
102
103 pub revenue_account: Option<String>,
105
106 pub cogs_account: Option<String>,
108
109 pub cogs_amount: Decimal,
111
112 pub discount_amount: Decimal,
114
115 pub is_service: bool,
117
118 pub returns_reference: Option<String>,
120}
121
122impl CustomerInvoiceItem {
123 #[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 #[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 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 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 pub fn with_cogs(mut self, cogs: Decimal) -> Self {
177 self.cogs_amount = cogs;
178 self
179 }
180
181 pub fn with_revenue_account(mut self, account: impl Into<String>) -> Self {
183 self.revenue_account = Some(account.into());
184 self
185 }
186
187 pub fn as_service(mut self) -> Self {
189 self.is_service = true;
190 self
191 }
192
193 pub fn with_discount(mut self, discount: Decimal) -> Self {
195 self.discount_amount = discount;
196 self
197 }
198
199 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#[derive(Debug, Clone, Serialize, Deserialize)]
211pub struct CustomerInvoice {
212 pub header: DocumentHeader,
214
215 pub invoice_type: CustomerInvoiceType,
217
218 pub items: Vec<CustomerInvoiceItem>,
220
221 pub customer_id: String,
223
224 pub bill_to: Option<String>,
226
227 pub payer: Option<String>,
229
230 pub sales_org: String,
232
233 pub distribution_channel: String,
235
236 pub division: String,
238
239 pub total_net_amount: Decimal,
241
242 pub total_tax_amount: Decimal,
244
245 pub total_gross_amount: Decimal,
247
248 pub total_discount: Decimal,
250
251 pub total_cogs: Decimal,
253
254 pub payment_terms: String,
256
257 pub due_date: NaiveDate,
259
260 pub discount_date_1: Option<NaiveDate>,
262
263 pub discount_percent_1: Option<Decimal>,
265
266 pub discount_date_2: Option<NaiveDate>,
268
269 pub discount_percent_2: Option<Decimal>,
271
272 pub amount_paid: Decimal,
274
275 pub amount_open: Decimal,
277
278 pub payment_status: InvoicePaymentStatus,
280
281 pub sales_order_id: Option<String>,
283
284 pub delivery_id: Option<String>,
286
287 pub external_reference: Option<String>,
289
290 pub customer_po_number: Option<String>,
292
293 pub is_posted: bool,
295
296 pub is_output_complete: bool,
298
299 pub is_intercompany: bool,
301
302 pub ic_partner: Option<String>,
304
305 pub dispute_reason: Option<String>,
307
308 pub write_off_amount: Decimal,
310
311 pub write_off_reason: Option<String>,
313
314 pub dunning_level: u8,
316
317 pub last_dunning_date: Option<NaiveDate>,
319
320 pub is_cancelled: bool,
322
323 pub cancellation_invoice: Option<String>,
325}
326
327impl CustomerInvoice {
328 #[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 #[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 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 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, created_by,
452 );
453 invoice.invoice_type = CustomerInvoiceType::CreditMemo;
454 invoice.header.document_type = DocumentType::CreditMemo;
455 invoice
456 }
457
458 pub fn with_invoice_type(mut self, invoice_type: CustomerInvoiceType) -> Self {
460 self.invoice_type = invoice_type;
461 self
462 }
463
464 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 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 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 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 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 pub fn add_item(&mut self, item: CustomerInvoiceItem) {
516 self.items.push(item);
517 self.recalculate_totals();
518 }
519
520 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
701pub enum AgingBucket {
702 Current,
704 Days1To30,
706 Days31To60,
708 Days61To90,
710 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)); }
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 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 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 let discount =
834 invoice.cash_discount_available(NaiveDate::from_ymd_opt(2024, 1, 20).unwrap());
835 assert_eq!(discount, Decimal::from(20)); 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 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 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 assert_eq!(
872 invoice.aging_bucket(NaiveDate::from_ymd_opt(2024, 4, 1).unwrap()),
873 AgingBucket::Days31To60
874 );
875
876 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 assert_eq!(entries[0].0, "120000");
907 assert_eq!(entries[0].1, Decimal::from(1100)); assert_eq!(entries[1].0, "400000");
911 assert_eq!(entries[1].2, Decimal::from(1000));
912
913 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 assert_eq!(entries[0].0, "120000");
937 assert_eq!(entries[0].2, Decimal::from(500)); assert_eq!(entries[1].0, "400000");
941 assert_eq!(entries[1].1, Decimal::from(500)); }
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}