datasynth_core/models/subledger/ar/
dunning.rs

1//! Dunning (Mahnungen) models for AR collections.
2//!
3//! This module provides models for the dunning process including:
4//! - Dunning runs (batch processing of overdue invoices)
5//! - Dunning letters (reminders sent to customers)
6//! - Dunning items (individual invoices included in a letter)
7
8#![allow(clippy::too_many_arguments)]
9
10use chrono::{DateTime, NaiveDate, Utc};
11use rust_decimal::Decimal;
12use serde::{Deserialize, Serialize};
13
14/// A dunning run represents a batch execution of the dunning process.
15#[derive(Debug, Clone, Serialize, Deserialize)]
16pub struct DunningRun {
17    /// Unique run identifier.
18    pub run_id: String,
19    /// Company code.
20    pub company_code: String,
21    /// Run execution date.
22    pub run_date: NaiveDate,
23    /// Dunning date used for calculations.
24    pub dunning_date: NaiveDate,
25    /// Number of customers evaluated.
26    pub customers_evaluated: u32,
27    /// Number of customers with letters generated.
28    pub customers_with_letters: u32,
29    /// Number of letters generated.
30    pub letters_generated: u32,
31    /// Total amount dunned across all letters.
32    pub total_amount_dunned: Decimal,
33    /// Total dunning charges applied.
34    pub total_dunning_charges: Decimal,
35    /// Total interest calculated.
36    pub total_interest_amount: Decimal,
37    /// Run status.
38    pub status: DunningRunStatus,
39    /// Letters generated in this run.
40    pub letters: Vec<DunningLetter>,
41    /// Started timestamp.
42    pub started_at: DateTime<Utc>,
43    /// Completed timestamp.
44    pub completed_at: Option<DateTime<Utc>>,
45    /// User who initiated the run.
46    pub created_by: Option<String>,
47    /// Notes.
48    pub notes: Option<String>,
49}
50
51impl DunningRun {
52    /// Creates a new dunning run.
53    pub fn new(run_id: String, company_code: String, run_date: NaiveDate) -> Self {
54        Self {
55            run_id,
56            company_code,
57            run_date,
58            dunning_date: run_date,
59            customers_evaluated: 0,
60            customers_with_letters: 0,
61            letters_generated: 0,
62            total_amount_dunned: Decimal::ZERO,
63            total_dunning_charges: Decimal::ZERO,
64            total_interest_amount: Decimal::ZERO,
65            status: DunningRunStatus::Pending,
66            letters: Vec::new(),
67            started_at: Utc::now(),
68            completed_at: None,
69            created_by: None,
70            notes: None,
71        }
72    }
73
74    /// Adds a dunning letter to the run.
75    pub fn add_letter(&mut self, letter: DunningLetter) {
76        self.total_amount_dunned += letter.total_dunned_amount;
77        self.total_dunning_charges += letter.dunning_charges;
78        self.total_interest_amount += letter.interest_amount;
79        self.letters_generated += 1;
80        self.letters.push(letter);
81    }
82
83    /// Marks the run as started.
84    pub fn start(&mut self) {
85        self.status = DunningRunStatus::InProgress;
86        self.started_at = Utc::now();
87    }
88
89    /// Marks the run as completed.
90    pub fn complete(&mut self) {
91        self.status = DunningRunStatus::Completed;
92        self.completed_at = Some(Utc::now());
93        self.customers_with_letters = self
94            .letters
95            .iter()
96            .map(|l| l.customer_id.clone())
97            .collect::<std::collections::HashSet<_>>()
98            .len() as u32;
99    }
100
101    /// Marks the run as failed.
102    pub fn fail(&mut self, reason: String) {
103        self.status = DunningRunStatus::Failed;
104        self.completed_at = Some(Utc::now());
105        self.notes = Some(reason);
106    }
107}
108
109/// Status of a dunning run.
110#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
111pub enum DunningRunStatus {
112    /// Run is pending execution.
113    #[default]
114    Pending,
115    /// Run is in progress.
116    InProgress,
117    /// Run completed successfully.
118    Completed,
119    /// Run failed.
120    Failed,
121    /// Run was cancelled.
122    Cancelled,
123}
124
125/// A dunning letter sent to a customer.
126#[derive(Debug, Clone, Serialize, Deserialize)]
127pub struct DunningLetter {
128    /// Unique letter identifier.
129    pub letter_id: String,
130    /// Reference to the dunning run.
131    pub dunning_run_id: String,
132    /// Company code.
133    pub company_code: String,
134    /// Customer ID.
135    pub customer_id: String,
136    /// Customer name.
137    pub customer_name: String,
138    /// Dunning level (1-4).
139    pub dunning_level: u8,
140    /// Date of the dunning letter.
141    pub dunning_date: NaiveDate,
142    /// Items included in this letter.
143    pub dunning_items: Vec<DunningItem>,
144    /// Total amount being dunned.
145    pub total_dunned_amount: Decimal,
146    /// Dunning charges applied.
147    pub dunning_charges: Decimal,
148    /// Interest amount calculated.
149    pub interest_amount: Decimal,
150    /// Total amount due (principal + charges + interest).
151    pub total_amount_due: Decimal,
152    /// Currency.
153    pub currency: String,
154    /// Payment deadline.
155    pub payment_deadline: NaiveDate,
156    /// Whether the letter has been sent.
157    pub is_sent: bool,
158    /// Date sent.
159    pub sent_date: Option<NaiveDate>,
160    /// Response received from customer.
161    pub response_type: Option<DunningResponseType>,
162    /// Response date.
163    pub response_date: Option<NaiveDate>,
164    /// Status of the letter.
165    pub status: DunningLetterStatus,
166    /// Customer contact address.
167    pub contact_address: Option<String>,
168    /// Notes.
169    pub notes: Option<String>,
170    /// Created timestamp.
171    pub created_at: DateTime<Utc>,
172}
173
174impl DunningLetter {
175    /// Creates a new dunning letter.
176    pub fn new(
177        letter_id: String,
178        dunning_run_id: String,
179        company_code: String,
180        customer_id: String,
181        customer_name: String,
182        dunning_level: u8,
183        dunning_date: NaiveDate,
184        payment_deadline: NaiveDate,
185        currency: String,
186    ) -> Self {
187        Self {
188            letter_id,
189            dunning_run_id,
190            company_code,
191            customer_id,
192            customer_name,
193            dunning_level,
194            dunning_date,
195            dunning_items: Vec::new(),
196            total_dunned_amount: Decimal::ZERO,
197            dunning_charges: Decimal::ZERO,
198            interest_amount: Decimal::ZERO,
199            total_amount_due: Decimal::ZERO,
200            currency,
201            payment_deadline,
202            is_sent: false,
203            sent_date: None,
204            response_type: None,
205            response_date: None,
206            status: DunningLetterStatus::Created,
207            contact_address: None,
208            notes: None,
209            created_at: Utc::now(),
210        }
211    }
212
213    /// Adds a dunning item to the letter.
214    pub fn add_item(&mut self, item: DunningItem) {
215        self.total_dunned_amount += item.open_amount;
216        self.dunning_items.push(item);
217        self.recalculate_totals();
218    }
219
220    /// Sets dunning charges.
221    pub fn set_charges(&mut self, charges: Decimal) {
222        self.dunning_charges = charges;
223        self.recalculate_totals();
224    }
225
226    /// Sets interest amount.
227    pub fn set_interest(&mut self, interest: Decimal) {
228        self.interest_amount = interest;
229        self.recalculate_totals();
230    }
231
232    /// Recalculates total amount due.
233    fn recalculate_totals(&mut self) {
234        self.total_amount_due =
235            self.total_dunned_amount + self.dunning_charges + self.interest_amount;
236    }
237
238    /// Marks the letter as sent.
239    pub fn mark_sent(&mut self, sent_date: NaiveDate) {
240        self.is_sent = true;
241        self.sent_date = Some(sent_date);
242        self.status = DunningLetterStatus::Sent;
243    }
244
245    /// Records customer response.
246    pub fn record_response(&mut self, response: DunningResponseType, response_date: NaiveDate) {
247        self.response_type = Some(response);
248        self.response_date = Some(response_date);
249        self.status = match response {
250            DunningResponseType::PaymentPromise | DunningResponseType::Paid => {
251                DunningLetterStatus::Resolved
252            }
253            DunningResponseType::Dispute | DunningResponseType::PartialDispute => {
254                DunningLetterStatus::Disputed
255            }
256            DunningResponseType::NoResponse => DunningLetterStatus::Sent,
257            DunningResponseType::PaymentPlan => DunningLetterStatus::Resolved,
258            DunningResponseType::Bankruptcy => DunningLetterStatus::WrittenOff,
259        };
260    }
261
262    /// Marks as escalated to collection.
263    pub fn escalate_to_collection(&mut self) {
264        self.status = DunningLetterStatus::EscalatedToCollection;
265    }
266}
267
268/// Status of a dunning letter.
269#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
270pub enum DunningLetterStatus {
271    /// Letter created but not sent.
272    #[default]
273    Created,
274    /// Letter sent to customer.
275    Sent,
276    /// Customer disputed the charges.
277    Disputed,
278    /// Matter resolved (paid or payment plan).
279    Resolved,
280    /// Escalated to collection agency.
281    EscalatedToCollection,
282    /// Written off as bad debt.
283    WrittenOff,
284    /// Letter cancelled.
285    Cancelled,
286}
287
288/// Type of response to a dunning letter.
289#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
290pub enum DunningResponseType {
291    /// No response received.
292    NoResponse,
293    /// Customer promised to pay.
294    PaymentPromise,
295    /// Customer paid the amount.
296    Paid,
297    /// Customer disputes the full amount.
298    Dispute,
299    /// Customer disputes part of the amount.
300    PartialDispute,
301    /// Customer requests payment plan.
302    PaymentPlan,
303    /// Customer filed for bankruptcy.
304    Bankruptcy,
305}
306
307/// An individual invoice/item included in a dunning letter.
308#[derive(Debug, Clone, Serialize, Deserialize)]
309pub struct DunningItem {
310    /// Reference to the AR invoice.
311    pub invoice_number: String,
312    /// Invoice date.
313    pub invoice_date: NaiveDate,
314    /// Due date.
315    pub due_date: NaiveDate,
316    /// Original invoice amount.
317    pub original_amount: Decimal,
318    /// Open/remaining amount.
319    pub open_amount: Decimal,
320    /// Days overdue.
321    pub days_overdue: u32,
322    /// Interest calculated on this item.
323    pub interest_amount: Decimal,
324    /// Previous dunning level before this run.
325    pub previous_dunning_level: u8,
326    /// New dunning level after this run.
327    pub new_dunning_level: u8,
328    /// Whether this item was blocked from dunning.
329    pub is_blocked: bool,
330    /// Block reason if blocked.
331    pub block_reason: Option<String>,
332}
333
334impl DunningItem {
335    /// Creates a new dunning item.
336    pub fn new(
337        invoice_number: String,
338        invoice_date: NaiveDate,
339        due_date: NaiveDate,
340        original_amount: Decimal,
341        open_amount: Decimal,
342        days_overdue: u32,
343        previous_dunning_level: u8,
344        new_dunning_level: u8,
345    ) -> Self {
346        Self {
347            invoice_number,
348            invoice_date,
349            due_date,
350            original_amount,
351            open_amount,
352            days_overdue,
353            interest_amount: Decimal::ZERO,
354            previous_dunning_level,
355            new_dunning_level,
356            is_blocked: false,
357            block_reason: None,
358        }
359    }
360
361    /// Sets the interest amount.
362    pub fn with_interest(mut self, interest: Decimal) -> Self {
363        self.interest_amount = interest;
364        self
365    }
366
367    /// Blocks the item from dunning.
368    pub fn block(mut self, reason: String) -> Self {
369        self.is_blocked = true;
370        self.block_reason = Some(reason);
371        self
372    }
373}
374
375/// Summary of dunning activity for a customer.
376#[derive(Debug, Clone, Serialize, Deserialize)]
377pub struct CustomerDunningSummary {
378    /// Customer ID.
379    pub customer_id: String,
380    /// Customer name.
381    pub customer_name: String,
382    /// Current highest dunning level.
383    pub current_dunning_level: u8,
384    /// Number of dunning letters sent.
385    pub letters_sent: u32,
386    /// Total amount currently dunned.
387    pub total_dunned_amount: Decimal,
388    /// Total dunning charges.
389    pub total_charges: Decimal,
390    /// Total interest accrued.
391    pub total_interest: Decimal,
392    /// Last dunning date.
393    pub last_dunning_date: Option<NaiveDate>,
394    /// Whether customer is blocked from dunning.
395    pub is_blocked: bool,
396    /// Whether customer is in collection.
397    pub in_collection: bool,
398}
399
400impl CustomerDunningSummary {
401    /// Creates from dunning letters.
402    pub fn from_letters(
403        customer_id: String,
404        customer_name: String,
405        letters: &[DunningLetter],
406    ) -> Self {
407        let customer_letters: Vec<_> = letters
408            .iter()
409            .filter(|l| l.customer_id == customer_id)
410            .collect();
411
412        let current_dunning_level = customer_letters
413            .iter()
414            .map(|l| l.dunning_level)
415            .max()
416            .unwrap_or(0);
417
418        let total_dunned_amount: Decimal = customer_letters
419            .iter()
420            .filter(|l| l.status != DunningLetterStatus::Resolved)
421            .map(|l| l.total_dunned_amount)
422            .sum();
423
424        let total_charges: Decimal = customer_letters.iter().map(|l| l.dunning_charges).sum();
425
426        let total_interest: Decimal = customer_letters.iter().map(|l| l.interest_amount).sum();
427
428        let last_dunning_date = customer_letters.iter().map(|l| l.dunning_date).max();
429
430        let in_collection = customer_letters
431            .iter()
432            .any(|l| l.status == DunningLetterStatus::EscalatedToCollection);
433
434        Self {
435            customer_id,
436            customer_name,
437            current_dunning_level,
438            letters_sent: customer_letters.iter().filter(|l| l.is_sent).count() as u32,
439            total_dunned_amount,
440            total_charges,
441            total_interest,
442            last_dunning_date,
443            is_blocked: false,
444            in_collection,
445        }
446    }
447}
448
449#[cfg(test)]
450mod tests {
451    use super::*;
452
453    #[test]
454    fn test_dunning_run_creation() {
455        let run = DunningRun::new(
456            "DR-2024-001".to_string(),
457            "1000".to_string(),
458            NaiveDate::from_ymd_opt(2024, 3, 15).unwrap(),
459        );
460
461        assert_eq!(run.status, DunningRunStatus::Pending);
462        assert_eq!(run.letters_generated, 0);
463    }
464
465    #[test]
466    fn test_dunning_letter_creation() {
467        let letter = DunningLetter::new(
468            "DL-2024-001".to_string(),
469            "DR-2024-001".to_string(),
470            "1000".to_string(),
471            "CUST001".to_string(),
472            "Test Customer".to_string(),
473            1,
474            NaiveDate::from_ymd_opt(2024, 3, 15).unwrap(),
475            NaiveDate::from_ymd_opt(2024, 3, 29).unwrap(),
476            "USD".to_string(),
477        );
478
479        assert_eq!(letter.dunning_level, 1);
480        assert!(!letter.is_sent);
481        assert_eq!(letter.status, DunningLetterStatus::Created);
482    }
483
484    #[test]
485    fn test_dunning_letter_items() {
486        let mut letter = DunningLetter::new(
487            "DL-2024-001".to_string(),
488            "DR-2024-001".to_string(),
489            "1000".to_string(),
490            "CUST001".to_string(),
491            "Test Customer".to_string(),
492            1,
493            NaiveDate::from_ymd_opt(2024, 3, 15).unwrap(),
494            NaiveDate::from_ymd_opt(2024, 3, 29).unwrap(),
495            "USD".to_string(),
496        );
497
498        let item = DunningItem::new(
499            "INV-001".to_string(),
500            NaiveDate::from_ymd_opt(2024, 1, 15).unwrap(),
501            NaiveDate::from_ymd_opt(2024, 2, 14).unwrap(),
502            Decimal::from(1000),
503            Decimal::from(1000),
504            30,
505            0,
506            1,
507        );
508
509        letter.add_item(item);
510        letter.set_charges(Decimal::from(25));
511        letter.set_interest(Decimal::from(7)); // ~9% annual on $1000 for 30 days
512
513        assert_eq!(letter.total_dunned_amount, Decimal::from(1000));
514        assert_eq!(letter.dunning_charges, Decimal::from(25));
515        assert_eq!(letter.interest_amount, Decimal::from(7));
516        assert_eq!(letter.total_amount_due, Decimal::from(1032));
517    }
518
519    #[test]
520    fn test_dunning_run_with_letters() {
521        let mut run = DunningRun::new(
522            "DR-2024-001".to_string(),
523            "1000".to_string(),
524            NaiveDate::from_ymd_opt(2024, 3, 15).unwrap(),
525        );
526
527        run.start();
528        assert_eq!(run.status, DunningRunStatus::InProgress);
529
530        let mut letter = DunningLetter::new(
531            "DL-2024-001".to_string(),
532            "DR-2024-001".to_string(),
533            "1000".to_string(),
534            "CUST001".to_string(),
535            "Test Customer".to_string(),
536            1,
537            NaiveDate::from_ymd_opt(2024, 3, 15).unwrap(),
538            NaiveDate::from_ymd_opt(2024, 3, 29).unwrap(),
539            "USD".to_string(),
540        );
541
542        letter.add_item(DunningItem::new(
543            "INV-001".to_string(),
544            NaiveDate::from_ymd_opt(2024, 1, 15).unwrap(),
545            NaiveDate::from_ymd_opt(2024, 2, 14).unwrap(),
546            Decimal::from(1000),
547            Decimal::from(1000),
548            30,
549            0,
550            1,
551        ));
552        letter.set_charges(Decimal::from(25));
553
554        run.add_letter(letter);
555        run.complete();
556
557        assert_eq!(run.status, DunningRunStatus::Completed);
558        assert_eq!(run.letters_generated, 1);
559        assert_eq!(run.total_amount_dunned, Decimal::from(1000));
560        assert_eq!(run.total_dunning_charges, Decimal::from(25));
561    }
562
563    #[test]
564    fn test_letter_response() {
565        let mut letter = DunningLetter::new(
566            "DL-2024-001".to_string(),
567            "DR-2024-001".to_string(),
568            "1000".to_string(),
569            "CUST001".to_string(),
570            "Test Customer".to_string(),
571            1,
572            NaiveDate::from_ymd_opt(2024, 3, 15).unwrap(),
573            NaiveDate::from_ymd_opt(2024, 3, 29).unwrap(),
574            "USD".to_string(),
575        );
576
577        letter.mark_sent(NaiveDate::from_ymd_opt(2024, 3, 15).unwrap());
578        assert!(letter.is_sent);
579        assert_eq!(letter.status, DunningLetterStatus::Sent);
580
581        letter.record_response(
582            DunningResponseType::PaymentPromise,
583            NaiveDate::from_ymd_opt(2024, 3, 20).unwrap(),
584        );
585        assert_eq!(letter.status, DunningLetterStatus::Resolved);
586    }
587}