1#![allow(clippy::too_many_arguments)]
9
10use chrono::{DateTime, NaiveDate, Utc};
11use rust_decimal::Decimal;
12use serde::{Deserialize, Serialize};
13
14#[derive(Debug, Clone, Serialize, Deserialize)]
16pub struct DunningRun {
17 pub run_id: String,
19 pub company_code: String,
21 pub run_date: NaiveDate,
23 pub dunning_date: NaiveDate,
25 pub customers_evaluated: u32,
27 pub customers_with_letters: u32,
29 pub letters_generated: u32,
31 pub total_amount_dunned: Decimal,
33 pub total_dunning_charges: Decimal,
35 pub total_interest_amount: Decimal,
37 pub status: DunningRunStatus,
39 pub letters: Vec<DunningLetter>,
41 pub started_at: DateTime<Utc>,
43 pub completed_at: Option<DateTime<Utc>>,
45 pub created_by: Option<String>,
47 pub notes: Option<String>,
49}
50
51impl DunningRun {
52 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 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 pub fn start(&mut self) {
85 self.status = DunningRunStatus::InProgress;
86 self.started_at = Utc::now();
87 }
88
89 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 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#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
111pub enum DunningRunStatus {
112 #[default]
114 Pending,
115 InProgress,
117 Completed,
119 Failed,
121 Cancelled,
123}
124
125#[derive(Debug, Clone, Serialize, Deserialize)]
127pub struct DunningLetter {
128 pub letter_id: String,
130 pub dunning_run_id: String,
132 pub company_code: String,
134 pub customer_id: String,
136 pub customer_name: String,
138 pub dunning_level: u8,
140 pub dunning_date: NaiveDate,
142 pub dunning_items: Vec<DunningItem>,
144 pub total_dunned_amount: Decimal,
146 pub dunning_charges: Decimal,
148 pub interest_amount: Decimal,
150 pub total_amount_due: Decimal,
152 pub currency: String,
154 pub payment_deadline: NaiveDate,
156 pub is_sent: bool,
158 pub sent_date: Option<NaiveDate>,
160 pub response_type: Option<DunningResponseType>,
162 pub response_date: Option<NaiveDate>,
164 pub status: DunningLetterStatus,
166 pub contact_address: Option<String>,
168 pub notes: Option<String>,
170 pub created_at: DateTime<Utc>,
172}
173
174impl DunningLetter {
175 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 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 pub fn set_charges(&mut self, charges: Decimal) {
222 self.dunning_charges = charges;
223 self.recalculate_totals();
224 }
225
226 pub fn set_interest(&mut self, interest: Decimal) {
228 self.interest_amount = interest;
229 self.recalculate_totals();
230 }
231
232 fn recalculate_totals(&mut self) {
234 self.total_amount_due =
235 self.total_dunned_amount + self.dunning_charges + self.interest_amount;
236 }
237
238 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 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 pub fn escalate_to_collection(&mut self) {
264 self.status = DunningLetterStatus::EscalatedToCollection;
265 }
266}
267
268#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
270pub enum DunningLetterStatus {
271 #[default]
273 Created,
274 Sent,
276 Disputed,
278 Resolved,
280 EscalatedToCollection,
282 WrittenOff,
284 Cancelled,
286}
287
288#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
290pub enum DunningResponseType {
291 NoResponse,
293 PaymentPromise,
295 Paid,
297 Dispute,
299 PartialDispute,
301 PaymentPlan,
303 Bankruptcy,
305}
306
307#[derive(Debug, Clone, Serialize, Deserialize)]
309pub struct DunningItem {
310 pub invoice_number: String,
312 pub invoice_date: NaiveDate,
314 pub due_date: NaiveDate,
316 pub original_amount: Decimal,
318 pub open_amount: Decimal,
320 pub days_overdue: u32,
322 pub interest_amount: Decimal,
324 pub previous_dunning_level: u8,
326 pub new_dunning_level: u8,
328 pub is_blocked: bool,
330 pub block_reason: Option<String>,
332}
333
334impl DunningItem {
335 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 pub fn with_interest(mut self, interest: Decimal) -> Self {
363 self.interest_amount = interest;
364 self
365 }
366
367 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#[derive(Debug, Clone, Serialize, Deserialize)]
377pub struct CustomerDunningSummary {
378 pub customer_id: String,
380 pub customer_name: String,
382 pub current_dunning_level: u8,
384 pub letters_sent: u32,
386 pub total_dunned_amount: Decimal,
388 pub total_charges: Decimal,
390 pub total_interest: Decimal,
392 pub last_dunning_date: Option<NaiveDate>,
394 pub is_blocked: bool,
396 pub in_collection: bool,
398}
399
400impl CustomerDunningSummary {
401 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)); 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}