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 #[serde(default, skip_serializing_if = "Option::is_none")]
328 pub customer_name: Option<String>,
329}
330
331impl CustomerInvoice {
332 #[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 #[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 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 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, created_by,
457 );
458 invoice.invoice_type = CustomerInvoiceType::CreditMemo;
459 invoice.header.document_type = DocumentType::CreditMemo;
460 invoice
461 }
462
463 pub fn with_invoice_type(mut self, invoice_type: CustomerInvoiceType) -> Self {
465 self.invoice_type = invoice_type;
466 self
467 }
468
469 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 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 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 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 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 pub fn add_item(&mut self, item: CustomerInvoiceItem) {
521 self.items.push(item);
522 self.recalculate_totals();
523 }
524
525 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
706pub enum AgingBucket {
707 Current,
709 Days1To30,
711 Days31To60,
713 Days61To90,
715 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)); }
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 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 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 let discount =
840 invoice.cash_discount_available(NaiveDate::from_ymd_opt(2024, 1, 20).unwrap());
841 assert_eq!(discount, Decimal::from(20)); 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 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 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 assert_eq!(
878 invoice.aging_bucket(NaiveDate::from_ymd_opt(2024, 4, 1).unwrap()),
879 AgingBucket::Days31To60
880 );
881
882 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 assert_eq!(entries[0].0, "120000");
913 assert_eq!(entries[0].1, Decimal::from(1100)); assert_eq!(entries[1].0, "400000");
917 assert_eq!(entries[1].2, Decimal::from(1000));
918
919 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 assert_eq!(entries[0].0, "120000");
943 assert_eq!(entries[0].2, Decimal::from(500)); assert_eq!(entries[1].0, "400000");
947 assert_eq!(entries[1].1, Decimal::from(500)); }
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}