1use chrono::{DateTime, Utc};
11use rust_decimal::Decimal;
12use serde::{Deserialize, Serialize};
13use strum::{Display, EnumString};
14use uuid::Uuid;
15
16#[derive(
22 Debug, Clone, Copy, PartialEq, Eq, Display, EnumString, Serialize, Deserialize, Default,
23)]
24#[strum(serialize_all = "snake_case", ascii_case_insensitive)]
25#[serde(rename_all = "snake_case")]
26#[non_exhaustive]
27pub enum AgingBucket {
28 #[default]
30 Current,
31 #[strum(to_string = "1_30", serialize = "days_1_to_30")]
33 Days1To30,
34 #[strum(to_string = "31_60", serialize = "days_31_to_60")]
36 Days31To60,
37 #[strum(to_string = "61_90", serialize = "days_61_to_90")]
39 Days61To90,
40 #[strum(to_string = "over_90", serialize = "days_over_90")]
42 DaysOver90,
43}
44
45#[derive(
47 Debug, Clone, Copy, PartialEq, Eq, Display, EnumString, Serialize, Deserialize, Default,
48)]
49#[strum(serialize_all = "snake_case", ascii_case_insensitive)]
50#[serde(rename_all = "snake_case")]
51#[non_exhaustive]
52pub enum CollectionStatus {
53 #[default]
55 None,
56 #[strum(serialize = "reminder_1_sent")]
58 Reminder1Sent,
59 #[strum(serialize = "reminder_2_sent")]
61 Reminder2Sent,
62 #[strum(serialize = "reminder_3_sent")]
64 Reminder3Sent,
65 InCollections,
67 SentToAgency,
69 WrittenOff,
71 PromiseToPay,
73 PaymentPlan,
75}
76
77#[derive(
79 Debug, Clone, Copy, PartialEq, Eq, Display, EnumString, Serialize, Deserialize, Default,
80)]
81#[strum(serialize_all = "snake_case", ascii_case_insensitive)]
82#[serde(rename_all = "snake_case")]
83#[non_exhaustive]
84pub enum DunningLetterType {
85 #[default]
87 #[strum(serialize = "reminder_1")]
88 Reminder1,
89 #[strum(serialize = "reminder_2")]
91 Reminder2,
92 #[strum(serialize = "reminder_3")]
94 Reminder3,
95 DemandLetter,
97 CollectionNotice,
99}
100
101#[derive(
103 Debug, Clone, Copy, PartialEq, Eq, Display, EnumString, Serialize, Deserialize, Default,
104)]
105#[strum(serialize_all = "snake_case", ascii_case_insensitive)]
106#[serde(rename_all = "snake_case")]
107#[non_exhaustive]
108pub enum WriteOffReason {
109 #[default]
111 Uncollectible,
112 Bankruptcy,
114 CustomerDispute,
116 SmallBalance,
118 AccountClosed,
120 Deceased,
122 Other,
124}
125
126#[derive(
128 Debug, Clone, Copy, PartialEq, Eq, Display, EnumString, Serialize, Deserialize, Default,
129)]
130#[strum(serialize_all = "snake_case", ascii_case_insensitive)]
131#[serde(rename_all = "snake_case")]
132#[non_exhaustive]
133pub enum CreditMemoReason {
134 #[default]
136 ReturnedGoods,
137 PricingError,
139 Overpayment,
141 Damaged,
143 ServiceCredit,
145 GoodwillAdjustment,
147 Other,
149}
150
151#[derive(
153 Debug, Clone, Copy, PartialEq, Eq, Display, EnumString, Serialize, Deserialize, Default,
154)]
155#[strum(serialize_all = "snake_case", ascii_case_insensitive)]
156#[serde(rename_all = "snake_case")]
157#[non_exhaustive]
158pub enum CreditMemoStatus {
159 #[default]
161 Open,
162 PartiallyApplied,
164 FullyApplied,
166 Voided,
168}
169
170#[derive(
172 Debug, Clone, Copy, PartialEq, Eq, Display, EnumString, Serialize, Deserialize, Default,
173)]
174#[strum(serialize_all = "snake_case", ascii_case_insensitive)]
175#[serde(rename_all = "snake_case")]
176#[non_exhaustive]
177pub enum CollectionActivityType {
178 #[default]
180 DunningLetterSent,
181 PhoneCall,
183 Email,
185 InPersonVisit,
187 PromiseToPay,
189 PaymentPlanCreated,
191 SentToCollections,
193 WriteOffApproved,
195 DisputeLogged,
197 DisputeResolved,
199 Note,
201}
202
203#[derive(Debug, Clone, Copy, PartialEq, Eq, Display, EnumString, Serialize, Deserialize)]
205#[strum(serialize_all = "snake_case", ascii_case_insensitive)]
206#[serde(rename_all = "snake_case")]
207#[non_exhaustive]
208pub enum StatementTransactionType {
209 Invoice,
211 Payment,
213 CreditMemo,
215 WriteOff,
217 Adjustment,
219}
220
221#[derive(Debug, Clone, Serialize, Deserialize)]
227pub struct ArAgingSummary {
228 pub current: Decimal,
229 pub days_1_30: Decimal,
230 pub days_31_60: Decimal,
231 pub days_61_90: Decimal,
232 pub days_over_90: Decimal,
233 pub total: Decimal,
234 pub as_of_date: DateTime<Utc>,
235}
236
237impl ArAgingSummary {
238 #[must_use]
240 pub fn new() -> Self {
241 Self {
242 current: Decimal::ZERO,
243 days_1_30: Decimal::ZERO,
244 days_31_60: Decimal::ZERO,
245 days_61_90: Decimal::ZERO,
246 days_over_90: Decimal::ZERO,
247 total: Decimal::ZERO,
248 as_of_date: Utc::now(),
249 }
250 }
251
252 #[must_use]
254 pub fn total_overdue(&self) -> Decimal {
255 self.days_1_30 + self.days_31_60 + self.days_61_90 + self.days_over_90
256 }
257
258 #[must_use]
260 pub fn overdue_percentage(&self) -> Decimal {
261 if self.total == Decimal::ZERO {
262 Decimal::ZERO
263 } else {
264 (self.total_overdue() / self.total) * Decimal::from(100)
265 }
266 }
267}
268
269impl Default for ArAgingSummary {
270 fn default() -> Self {
271 Self::new()
272 }
273}
274
275#[derive(Debug, Clone, Serialize, Deserialize)]
277pub struct CustomerArAging {
278 pub customer_id: Uuid,
279 pub customer_name: Option<String>,
280 pub customer_email: Option<String>,
281 pub current: Decimal,
282 pub days_1_30: Decimal,
283 pub days_31_60: Decimal,
284 pub days_61_90: Decimal,
285 pub days_over_90: Decimal,
286 pub total_outstanding: Decimal,
287 pub invoice_count: i32,
288 pub oldest_invoice_date: Option<DateTime<Utc>>,
289 pub last_payment_date: Option<DateTime<Utc>>,
290}
291
292impl CustomerArAging {
293 #[must_use]
295 pub fn total_overdue(&self) -> Decimal {
296 self.days_1_30 + self.days_31_60 + self.days_61_90 + self.days_over_90
297 }
298
299 #[must_use]
301 pub fn worst_aging_bucket(&self) -> AgingBucket {
302 if self.days_over_90 > Decimal::ZERO {
303 AgingBucket::DaysOver90
304 } else if self.days_61_90 > Decimal::ZERO {
305 AgingBucket::Days61To90
306 } else if self.days_31_60 > Decimal::ZERO {
307 AgingBucket::Days31To60
308 } else if self.days_1_30 > Decimal::ZERO {
309 AgingBucket::Days1To30
310 } else {
311 AgingBucket::Current
312 }
313 }
314}
315
316#[derive(Debug, Clone, Serialize, Deserialize)]
318pub struct CollectionActivity {
319 pub id: Uuid,
320 pub invoice_id: Uuid,
321 pub customer_id: Uuid,
322 pub activity_type: CollectionActivityType,
323 pub activity_date: DateTime<Utc>,
324 pub dunning_letter_type: Option<DunningLetterType>,
325 pub notes: Option<String>,
326 pub contact_method: Option<String>,
327 pub contact_result: Option<String>,
328 pub promise_to_pay_date: Option<DateTime<Utc>>,
329 pub promise_to_pay_amount: Option<Decimal>,
330 pub performed_by: Option<String>,
331 pub created_at: DateTime<Utc>,
332}
333
334#[derive(Debug, Clone, Serialize, Deserialize)]
336pub struct WriteOff {
337 pub id: Uuid,
338 pub write_off_number: String,
339 pub invoice_id: Uuid,
340 pub customer_id: Uuid,
341 pub amount: Decimal,
342 pub reason: WriteOffReason,
343 pub notes: Option<String>,
344 pub write_off_date: DateTime<Utc>,
345 pub approved_by: Option<String>,
346 pub approved_at: Option<DateTime<Utc>>,
347 pub reversed_at: Option<DateTime<Utc>>,
348 pub gl_journal_entry_id: Option<Uuid>,
349 pub created_at: DateTime<Utc>,
350}
351
352impl WriteOff {
353 #[must_use]
355 pub const fn is_reversed(&self) -> bool {
356 self.reversed_at.is_some()
357 }
358}
359
360#[derive(Debug, Clone, Serialize, Deserialize)]
362pub struct CreditMemo {
363 pub id: Uuid,
364 pub credit_memo_number: String,
365 pub customer_id: Uuid,
366 pub original_invoice_id: Option<Uuid>,
367 pub reason: CreditMemoReason,
368 pub amount: Decimal,
369 pub applied_amount: Decimal,
370 pub unapplied_amount: Decimal,
371 pub status: CreditMemoStatus,
372 pub notes: Option<String>,
373 pub issue_date: DateTime<Utc>,
374 pub gl_journal_entry_id: Option<Uuid>,
375 pub created_at: DateTime<Utc>,
376 pub updated_at: DateTime<Utc>,
377}
378
379impl CreditMemo {
380 #[must_use]
382 pub fn can_apply(&self) -> bool {
383 self.status != CreditMemoStatus::Voided
384 && self.status != CreditMemoStatus::FullyApplied
385 && self.unapplied_amount > Decimal::ZERO
386 }
387}
388
389#[derive(Debug, Clone, Serialize, Deserialize)]
391pub struct CreditMemoApplication {
392 pub id: Uuid,
393 pub credit_memo_id: Uuid,
394 pub invoice_id: Uuid,
395 pub applied_amount: Decimal,
396 pub applied_date: DateTime<Utc>,
397 pub created_at: DateTime<Utc>,
398}
399
400#[derive(Debug, Clone, Serialize, Deserialize)]
402pub struct ArPaymentApplication {
403 pub id: Uuid,
404 pub payment_id: Uuid,
405 pub invoice_id: Uuid,
406 pub applied_amount: Decimal,
407 pub applied_date: DateTime<Utc>,
408 pub created_at: DateTime<Utc>,
409}
410
411#[derive(Debug, Clone, Serialize, Deserialize)]
413pub struct CustomerArSummary {
414 pub customer_id: Uuid,
415 pub customer_name: Option<String>,
416 pub total_outstanding: Decimal,
417 pub total_overdue: Decimal,
418 pub credit_limit: Option<Decimal>,
419 pub available_credit: Option<Decimal>,
420 pub unapplied_credits: Decimal,
421 pub unapplied_payments: Decimal,
422 pub average_days_to_pay: Option<i32>,
423 pub oldest_open_invoice: Option<DateTime<Utc>>,
424 pub last_activity_date: Option<DateTime<Utc>>,
425 pub collection_status: CollectionStatus,
426}
427
428#[derive(Debug, Clone, Serialize, Deserialize)]
430pub struct StatementLineItem {
431 pub date: DateTime<Utc>,
432 pub transaction_type: StatementTransactionType,
433 pub reference_number: String,
434 pub description: String,
435 pub debit: Option<Decimal>,
436 pub credit: Option<Decimal>,
437 pub balance: Decimal,
438}
439
440#[derive(Debug, Clone, Serialize, Deserialize)]
442pub struct CustomerStatement {
443 pub customer_id: Uuid,
444 pub customer_name: String,
445 pub customer_email: Option<String>,
446 pub billing_address: Option<String>,
447 pub statement_date: DateTime<Utc>,
448 pub period_start: DateTime<Utc>,
449 pub period_end: DateTime<Utc>,
450 pub opening_balance: Decimal,
451 pub total_invoices: Decimal,
452 pub total_payments: Decimal,
453 pub total_credits: Decimal,
454 pub closing_balance: Decimal,
455 pub aging: CustomerArAging,
456 pub line_items: Vec<StatementLineItem>,
457}
458
459#[derive(Debug, Clone, Serialize, Deserialize, Default)]
465pub struct CreateCollectionActivity {
466 pub invoice_id: Uuid,
467 pub activity_type: CollectionActivityType,
468 pub dunning_letter_type: Option<DunningLetterType>,
469 pub notes: Option<String>,
470 pub contact_method: Option<String>,
471 pub contact_result: Option<String>,
472 pub promise_to_pay_date: Option<DateTime<Utc>>,
473 pub promise_to_pay_amount: Option<Decimal>,
474 pub performed_by: Option<String>,
475}
476
477#[derive(Debug, Clone, Serialize, Deserialize)]
479pub struct CreateWriteOff {
480 pub invoice_id: Uuid,
481 pub amount: Decimal,
482 pub reason: WriteOffReason,
483 pub notes: Option<String>,
484 pub approved_by: Option<String>,
485}
486
487#[derive(Debug, Clone, Serialize, Deserialize)]
489pub struct CreateCreditMemo {
490 pub customer_id: Uuid,
491 pub original_invoice_id: Option<Uuid>,
492 pub reason: CreditMemoReason,
493 pub amount: Decimal,
494 pub notes: Option<String>,
495}
496
497#[derive(Debug, Clone, Serialize, Deserialize)]
499pub struct ApplyPaymentToInvoices {
500 pub payment_id: Uuid,
501 pub applications: Vec<PaymentApplicationLine>,
502}
503
504#[derive(Debug, Clone, Serialize, Deserialize)]
506pub struct PaymentApplicationLine {
507 pub invoice_id: Uuid,
508 pub amount: Decimal,
509}
510
511#[derive(Debug, Clone, Serialize, Deserialize)]
513pub struct ApplyCreditMemo {
514 pub credit_memo_id: Uuid,
515 pub invoice_id: Uuid,
516 pub amount: Decimal,
517}
518
519#[derive(Debug, Clone, Serialize, Deserialize, Default)]
521pub struct GenerateStatementRequest {
522 pub customer_id: Uuid,
523 pub period_start: Option<DateTime<Utc>>,
524 pub period_end: Option<DateTime<Utc>>,
525 pub include_paid_invoices: Option<bool>,
526}
527
528#[derive(Debug, Clone, Serialize, Deserialize, Default)]
534pub struct ArAgingFilter {
535 pub customer_id: Option<Uuid>,
536 pub min_balance: Option<Decimal>,
537 pub overdue_only: Option<bool>,
538 pub aging_bucket: Option<AgingBucket>,
539 pub limit: Option<u32>,
540 pub offset: Option<u32>,
541}
542
543#[derive(Debug, Clone, Serialize, Deserialize, Default)]
545pub struct CollectionActivityFilter {
546 pub invoice_id: Option<Uuid>,
547 pub customer_id: Option<Uuid>,
548 pub activity_type: Option<CollectionActivityType>,
549 pub from_date: Option<DateTime<Utc>>,
550 pub to_date: Option<DateTime<Utc>>,
551 pub limit: Option<u32>,
552 pub offset: Option<u32>,
553}
554
555#[derive(Debug, Clone, Serialize, Deserialize, Default)]
557pub struct WriteOffFilter {
558 pub customer_id: Option<Uuid>,
559 pub invoice_id: Option<Uuid>,
560 pub reason: Option<WriteOffReason>,
561 pub include_reversed: Option<bool>,
562 pub from_date: Option<DateTime<Utc>>,
563 pub to_date: Option<DateTime<Utc>>,
564 pub limit: Option<u32>,
565 pub offset: Option<u32>,
566}
567
568#[derive(Debug, Clone, Serialize, Deserialize, Default)]
570pub struct CreditMemoFilter {
571 pub customer_id: Option<Uuid>,
572 pub status: Option<CreditMemoStatus>,
573 pub reason: Option<CreditMemoReason>,
574 pub has_unapplied: Option<bool>,
575 pub from_date: Option<DateTime<Utc>>,
576 pub to_date: Option<DateTime<Utc>>,
577 pub limit: Option<u32>,
578 pub offset: Option<u32>,
579}
580
581#[must_use]
587pub fn generate_write_off_number() -> String {
588 let timestamp = chrono::Utc::now().format("%Y%m%d").to_string();
589 let random = &uuid::Uuid::new_v4().to_string()[..6].to_uppercase();
590 format!("WO-{timestamp}-{random}")
591}
592
593#[must_use]
595pub fn generate_credit_memo_number() -> String {
596 let timestamp = chrono::Utc::now().format("%Y%m%d").to_string();
597 let random = &uuid::Uuid::new_v4().to_string()[..6].to_uppercase();
598 format!("CM-{timestamp}-{random}")
599}
600
601#[must_use]
603pub fn calculate_aging_bucket(due_date: DateTime<Utc>) -> AgingBucket {
604 let now = Utc::now();
605 let days_overdue = (now - due_date).num_days();
606
607 if days_overdue <= 0 {
608 AgingBucket::Current
609 } else if days_overdue <= 30 {
610 AgingBucket::Days1To30
611 } else if days_overdue <= 60 {
612 AgingBucket::Days31To60
613 } else if days_overdue <= 90 {
614 AgingBucket::Days61To90
615 } else {
616 AgingBucket::DaysOver90
617 }
618}
619
620#[must_use]
622pub const fn suggest_dunning_letter(bucket: AgingBucket) -> Option<DunningLetterType> {
623 match bucket {
624 AgingBucket::Current => None,
625 AgingBucket::Days1To30 => Some(DunningLetterType::Reminder1),
626 AgingBucket::Days31To60 => Some(DunningLetterType::Reminder2),
627 AgingBucket::Days61To90 => Some(DunningLetterType::Reminder3),
628 AgingBucket::DaysOver90 => Some(DunningLetterType::DemandLetter),
629 }
630}
631
632#[cfg(test)]
633mod tests {
634 use super::*;
635 use chrono::Duration;
636 use rust_decimal_macros::dec;
637
638 fn due_days_ago(days: i64) -> DateTime<Utc> {
644 Utc::now() - Duration::days(days)
645 }
646
647 fn create_test_aging_summary() -> ArAgingSummary {
648 ArAgingSummary {
649 current: dec!(500.00),
650 days_1_30: dec!(200.00),
651 days_31_60: dec!(150.00),
652 days_61_90: dec!(100.00),
653 days_over_90: dec!(50.00),
654 total: dec!(1000.00),
655 as_of_date: Utc::now(),
656 }
657 }
658
659 fn create_test_customer_aging() -> CustomerArAging {
660 CustomerArAging {
661 customer_id: Uuid::new_v4(),
662 customer_name: Some("Acme".to_string()),
663 customer_email: None,
664 current: Decimal::ZERO,
665 days_1_30: Decimal::ZERO,
666 days_31_60: Decimal::ZERO,
667 days_61_90: Decimal::ZERO,
668 days_over_90: Decimal::ZERO,
669 total_outstanding: Decimal::ZERO,
670 invoice_count: 0,
671 oldest_invoice_date: None,
672 last_payment_date: None,
673 }
674 }
675
676 fn create_test_credit_memo(status: CreditMemoStatus, unapplied: Decimal) -> CreditMemo {
677 let now = Utc::now();
678 CreditMemo {
679 id: Uuid::new_v4(),
680 credit_memo_number: generate_credit_memo_number(),
681 customer_id: Uuid::new_v4(),
682 original_invoice_id: None,
683 reason: CreditMemoReason::ReturnedGoods,
684 amount: dec!(100.00),
685 applied_amount: dec!(100.00) - unapplied,
686 unapplied_amount: unapplied,
687 status,
688 notes: None,
689 issue_date: now,
690 gl_journal_entry_id: None,
691 created_at: now,
692 updated_at: now,
693 }
694 }
695
696 #[test]
701 fn due_in_future_is_current() {
702 assert_eq!(calculate_aging_bucket(due_days_ago(-5)), AgingBucket::Current);
703 }
704
705 #[test]
706 fn due_today_is_current() {
707 assert_eq!(calculate_aging_bucket(due_days_ago(0)), AgingBucket::Current);
708 }
709
710 #[test]
711 fn one_day_overdue_is_days_1_to_30() {
712 assert_eq!(calculate_aging_bucket(due_days_ago(1)), AgingBucket::Days1To30);
713 }
714
715 #[test]
716 fn thirty_days_overdue_is_days_1_to_30() {
717 assert_eq!(calculate_aging_bucket(due_days_ago(30)), AgingBucket::Days1To30);
718 }
719
720 #[test]
721 fn thirty_one_days_overdue_is_days_31_to_60() {
722 assert_eq!(calculate_aging_bucket(due_days_ago(31)), AgingBucket::Days31To60);
723 }
724
725 #[test]
726 fn sixty_days_overdue_is_days_31_to_60() {
727 assert_eq!(calculate_aging_bucket(due_days_ago(60)), AgingBucket::Days31To60);
728 }
729
730 #[test]
731 fn sixty_one_days_overdue_is_days_61_to_90() {
732 assert_eq!(calculate_aging_bucket(due_days_ago(61)), AgingBucket::Days61To90);
733 }
734
735 #[test]
736 fn ninety_days_overdue_is_days_61_to_90() {
737 assert_eq!(calculate_aging_bucket(due_days_ago(90)), AgingBucket::Days61To90);
738 }
739
740 #[test]
741 fn ninety_one_days_overdue_is_over_90() {
742 assert_eq!(calculate_aging_bucket(due_days_ago(91)), AgingBucket::DaysOver90);
743 assert_eq!(calculate_aging_bucket(due_days_ago(365)), AgingBucket::DaysOver90);
744 }
745
746 #[test]
751 fn dunning_letter_escalates_with_aging() {
752 assert_eq!(suggest_dunning_letter(AgingBucket::Current), None);
753 assert_eq!(
754 suggest_dunning_letter(AgingBucket::Days1To30),
755 Some(DunningLetterType::Reminder1)
756 );
757 assert_eq!(
758 suggest_dunning_letter(AgingBucket::Days31To60),
759 Some(DunningLetterType::Reminder2)
760 );
761 assert_eq!(
762 suggest_dunning_letter(AgingBucket::Days61To90),
763 Some(DunningLetterType::Reminder3)
764 );
765 assert_eq!(
766 suggest_dunning_letter(AgingBucket::DaysOver90),
767 Some(DunningLetterType::DemandLetter)
768 );
769 }
770
771 #[test]
776 fn aging_summary_total_overdue_excludes_current() {
777 let summary = create_test_aging_summary();
778 assert_eq!(summary.total_overdue(), dec!(500.00));
779 }
780
781 #[test]
782 fn aging_summary_overdue_percentage() {
783 let summary = create_test_aging_summary();
784 assert_eq!(summary.overdue_percentage(), dec!(50.00));
785 }
786
787 #[test]
788 fn empty_aging_summary_has_zero_overdue_percentage() {
789 let summary = ArAgingSummary::new();
790 assert_eq!(summary.total_overdue(), Decimal::ZERO);
791 assert_eq!(summary.overdue_percentage(), Decimal::ZERO);
792 }
793
794 #[test]
795 fn customer_aging_worst_bucket_picks_oldest_nonzero() {
796 let mut aging = create_test_customer_aging();
797 assert_eq!(aging.worst_aging_bucket(), AgingBucket::Current);
798
799 aging.days_1_30 = dec!(10.00);
800 assert_eq!(aging.worst_aging_bucket(), AgingBucket::Days1To30);
801
802 aging.days_31_60 = dec!(10.00);
803 assert_eq!(aging.worst_aging_bucket(), AgingBucket::Days31To60);
804
805 aging.days_61_90 = dec!(10.00);
806 assert_eq!(aging.worst_aging_bucket(), AgingBucket::Days61To90);
807
808 aging.days_over_90 = dec!(0.01);
809 assert_eq!(aging.worst_aging_bucket(), AgingBucket::DaysOver90);
810 assert_eq!(aging.total_overdue(), dec!(30.01));
811 }
812
813 #[test]
818 fn open_credit_memo_with_balance_can_apply() {
819 let memo = create_test_credit_memo(CreditMemoStatus::Open, dec!(100.00));
820 assert!(memo.can_apply());
821
822 let partial = create_test_credit_memo(CreditMemoStatus::PartiallyApplied, dec!(25.00));
823 assert!(partial.can_apply());
824 }
825
826 #[test]
827 fn exhausted_or_voided_credit_memo_cannot_apply() {
828 assert!(
829 !create_test_credit_memo(CreditMemoStatus::FullyApplied, Decimal::ZERO).can_apply()
830 );
831 assert!(!create_test_credit_memo(CreditMemoStatus::Voided, dec!(100.00)).can_apply());
832 assert!(!create_test_credit_memo(CreditMemoStatus::Open, Decimal::ZERO).can_apply());
834 }
835
836 #[test]
837 fn write_off_reversed_only_when_timestamp_set() {
838 let now = Utc::now();
839 let mut write_off = WriteOff {
840 id: Uuid::new_v4(),
841 write_off_number: generate_write_off_number(),
842 invoice_id: Uuid::new_v4(),
843 customer_id: Uuid::new_v4(),
844 amount: dec!(42.00),
845 reason: WriteOffReason::Uncollectible,
846 notes: None,
847 write_off_date: now,
848 approved_by: None,
849 approved_at: None,
850 reversed_at: None,
851 gl_journal_entry_id: None,
852 created_at: now,
853 };
854 assert!(!write_off.is_reversed());
855 write_off.reversed_at = Some(now);
856 assert!(write_off.is_reversed());
857 }
858
859 #[test]
864 fn aging_bucket_round_trips_through_strings() {
865 for bucket in [
866 AgingBucket::Current,
867 AgingBucket::Days1To30,
868 AgingBucket::Days31To60,
869 AgingBucket::Days61To90,
870 AgingBucket::DaysOver90,
871 ] {
872 let parsed: AgingBucket = bucket.to_string().parse().expect("bucket should round-trip");
873 assert_eq!(parsed, bucket);
874 }
875 assert_eq!(AgingBucket::Days1To30.to_string(), "1_30");
876 assert_eq!("days_1_to_30".parse::<AgingBucket>(), Ok(AgingBucket::Days1To30));
877 }
878
879 #[test]
880 fn collection_status_round_trips_through_strings() {
881 for status in [
882 CollectionStatus::None,
883 CollectionStatus::Reminder1Sent,
884 CollectionStatus::Reminder3Sent,
885 CollectionStatus::InCollections,
886 CollectionStatus::SentToAgency,
887 CollectionStatus::WrittenOff,
888 CollectionStatus::PromiseToPay,
889 CollectionStatus::PaymentPlan,
890 ] {
891 let parsed: CollectionStatus =
892 status.to_string().parse().expect("status should round-trip");
893 assert_eq!(parsed, status);
894 }
895 }
896
897 #[test]
898 fn write_off_and_credit_memo_enums_round_trip() {
899 for reason in [
900 WriteOffReason::Uncollectible,
901 WriteOffReason::Bankruptcy,
902 WriteOffReason::SmallBalance,
903 WriteOffReason::Other,
904 ] {
905 let parsed: WriteOffReason =
906 reason.to_string().parse().expect("reason should round-trip");
907 assert_eq!(parsed, reason);
908 }
909 for status in [
910 CreditMemoStatus::Open,
911 CreditMemoStatus::PartiallyApplied,
912 CreditMemoStatus::FullyApplied,
913 CreditMemoStatus::Voided,
914 ] {
915 let parsed: CreditMemoStatus =
916 status.to_string().parse().expect("status should round-trip");
917 assert_eq!(parsed, status);
918 }
919 }
920
921 #[test]
926 fn generated_reference_numbers_are_prefixed_and_unique() {
927 let wo1 = generate_write_off_number();
928 let wo2 = generate_write_off_number();
929 assert!(wo1.starts_with("WO-"));
930 assert_ne!(wo1, wo2);
931
932 let cm1 = generate_credit_memo_number();
933 let cm2 = generate_credit_memo_number();
934 assert!(cm1.starts_with("CM-"));
935 assert_ne!(cm1, cm2);
936 }
937}