Skip to main content

stateset_core/models/
accounts_receivable.rs

1//! Accounts Receivable domain models
2//!
3//! Extends the Invoice module with AR-specific features:
4//! - Aging analysis and tracking
5//! - Collections management (dunning)
6//! - Write-offs and bad debt
7//! - Credit memos and payment application
8//! - Customer AR summaries and statements
9
10use chrono::{DateTime, Utc};
11use rust_decimal::Decimal;
12use serde::{Deserialize, Serialize};
13use strum::{Display, EnumString};
14use uuid::Uuid;
15
16// ============================================================================
17// Enums
18// ============================================================================
19
20/// AR aging bucket classification
21#[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    /// Invoice is not yet past due.
29    #[default]
30    Current,
31    /// Invoice is 1–30 days past due.
32    #[strum(to_string = "1_30", serialize = "days_1_to_30")]
33    Days1To30,
34    /// Invoice is 31–60 days past due.
35    #[strum(to_string = "31_60", serialize = "days_31_to_60")]
36    Days31To60,
37    /// Invoice is 61–90 days past due.
38    #[strum(to_string = "61_90", serialize = "days_61_to_90")]
39    Days61To90,
40    /// Invoice is more than 90 days past due.
41    #[strum(to_string = "over_90", serialize = "days_over_90")]
42    DaysOver90,
43}
44
45/// Collection status for an invoice
46#[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    /// No collection activity has been initiated.
54    #[default]
55    None,
56    /// First payment reminder has been sent to the customer.
57    #[strum(serialize = "reminder_1_sent")]
58    Reminder1Sent,
59    /// Second payment reminder has been sent to the customer.
60    #[strum(serialize = "reminder_2_sent")]
61    Reminder2Sent,
62    /// Third and final reminder has been sent before escalation.
63    #[strum(serialize = "reminder_3_sent")]
64    Reminder3Sent,
65    /// Account has been escalated to an internal collections team.
66    InCollections,
67    /// Account has been referred to a third-party collections agency.
68    SentToAgency,
69    /// Balance has been written off as uncollectible.
70    WrittenOff,
71    /// Customer has committed to pay by a specific date.
72    PromiseToPay,
73    /// Customer is on an agreed installment payment plan.
74    PaymentPlan,
75}
76
77/// Dunning letter template type
78#[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    /// Friendly first reminder sent shortly after the due date.
86    #[default]
87    #[strum(serialize = "reminder_1")]
88    Reminder1,
89    /// Second reminder with a stronger tone.
90    #[strum(serialize = "reminder_2")]
91    Reminder2,
92    /// Final reminder before formal demand.
93    #[strum(serialize = "reminder_3")]
94    Reminder3,
95    /// Formal demand for immediate payment.
96    DemandLetter,
97    /// Notification that the account has been sent to collections.
98    CollectionNotice,
99}
100
101/// Write-off reason code
102#[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    /// Debt is deemed uncollectible after exhausting collection efforts.
110    #[default]
111    Uncollectible,
112    /// Customer has filed for bankruptcy protection.
113    Bankruptcy,
114    /// Balance is disputed and resolution favoured the customer.
115    CustomerDispute,
116    /// Balance is too small to justify further collection costs.
117    SmallBalance,
118    /// Customer account has been closed.
119    AccountClosed,
120    /// Customer is deceased and the estate cannot cover the balance.
121    Deceased,
122    /// Write-off reason not covered by other variants.
123    Other,
124}
125
126/// Credit memo reason
127#[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    /// Customer returned goods for a refund.
135    #[default]
136    ReturnedGoods,
137    /// Invoice contained an incorrect price.
138    PricingError,
139    /// Customer paid more than the amount owed.
140    Overpayment,
141    /// Goods were delivered in a damaged condition.
142    Damaged,
143    /// Credit issued for unsatisfactory service.
144    ServiceCredit,
145    /// Discretionary credit offered to maintain customer goodwill.
146    GoodwillAdjustment,
147    /// Reason not covered by other variants.
148    Other,
149}
150
151/// Credit memo status
152#[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    /// Credit memo is available and has not been applied.
160    #[default]
161    Open,
162    /// A portion of the credit has been applied to one or more invoices.
163    PartiallyApplied,
164    /// The entire credit amount has been applied and the memo is exhausted.
165    FullyApplied,
166    /// Credit memo has been cancelled and cannot be applied.
167    Voided,
168}
169
170/// Collection activity type
171#[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    /// A dunning letter was sent to the customer.
179    #[default]
180    DunningLetterSent,
181    /// A phone call was made to the customer.
182    PhoneCall,
183    /// An email was sent to the customer.
184    Email,
185    /// A representative visited the customer in person.
186    InPersonVisit,
187    /// Customer provided a verbal or written commitment to pay by a date.
188    PromiseToPay,
189    /// An installment payment plan was established with the customer.
190    PaymentPlanCreated,
191    /// Account was referred to an internal or external collections team.
192    SentToCollections,
193    /// Write-off was reviewed and approved.
194    WriteOffApproved,
195    /// A formal dispute was opened by the customer.
196    DisputeLogged,
197    /// A customer dispute was resolved.
198    DisputeResolved,
199    /// General free-text note recorded on the account.
200    Note,
201}
202
203/// Statement transaction type
204#[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    /// A customer invoice that increases the balance owed.
210    Invoice,
211    /// A payment that reduces the outstanding balance.
212    Payment,
213    /// A credit memo that offsets invoice charges.
214    CreditMemo,
215    /// A bad-debt write-off that removes the balance.
216    WriteOff,
217    /// A manual balance correction.
218    Adjustment,
219}
220
221// ============================================================================
222// Core Structs
223// ============================================================================
224
225/// AR aging summary across all customers
226#[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    /// Create an empty aging summary as of now
239    #[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    /// Returns the total overdue amount (everything except current)
253    #[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    /// Returns percentage of total that is overdue
259    #[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/// AR aging by customer
276#[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    /// Returns the total overdue amount
294    #[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    /// Returns the worst aging bucket with a balance
300    #[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/// Collection activity record
317#[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/// Write-off record
335#[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    /// Returns true if the write-off has been reversed
354    #[must_use]
355    pub const fn is_reversed(&self) -> bool {
356        self.reversed_at.is_some()
357    }
358}
359
360/// Credit memo
361#[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    /// Returns true if the credit memo can be applied to invoices
381    #[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/// Credit memo application to invoice
390#[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/// AR payment application (maps payments to invoices)
401#[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/// Customer AR summary
412#[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/// Customer statement line item
429#[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/// Customer statement
441#[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// ============================================================================
460// Input Types
461// ============================================================================
462
463/// Input for logging a collection activity
464#[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/// Input for creating a write-off
478#[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/// Input for creating a credit memo
488#[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/// Input for applying a payment across invoices
498#[derive(Debug, Clone, Serialize, Deserialize)]
499pub struct ApplyPaymentToInvoices {
500    pub payment_id: Uuid,
501    pub applications: Vec<PaymentApplicationLine>,
502}
503
504/// Payment allocation to a single invoice
505#[derive(Debug, Clone, Serialize, Deserialize)]
506pub struct PaymentApplicationLine {
507    pub invoice_id: Uuid,
508    pub amount: Decimal,
509}
510
511/// Input for applying a credit memo to an invoice
512#[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/// Request to generate a customer statement
520#[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// ============================================================================
529// Filter Types
530// ============================================================================
531
532/// Filter for AR aging queries
533#[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/// Filter for collection activity queries
544#[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/// Filter for write-off queries
556#[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/// Filter for credit memo queries
569#[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// ============================================================================
582// Helper Functions
583// ============================================================================
584
585/// Generate a write-off reference number
586#[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/// Generate a credit memo reference number
594#[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/// Calculate the aging bucket for a given due date
602#[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/// Get the suggested dunning letter type based on aging
621#[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    // ========================================================================
639    // Test Helpers
640    // ========================================================================
641
642    /// Due date that is `days` past due as of now (negative = due in future).
643    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    // ========================================================================
697    // Aging Bucket Classification Edges
698    // ========================================================================
699
700    #[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    // ========================================================================
747    // Dunning Suggestions
748    // ========================================================================
749
750    #[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    // ========================================================================
772    // Aging Summary Math
773    // ========================================================================
774
775    #[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    // ========================================================================
814    // Credit Memos and Write-Offs
815    // ========================================================================
816
817    #[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        // Open but no unapplied balance left
833        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    // ========================================================================
860    // Enum Round-Trips
861    // ========================================================================
862
863    #[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    // ========================================================================
922    // Reference Numbers
923    // ========================================================================
924
925    #[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}