Skip to main content

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