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 #[serde(with = "crate::serde_timestamp::utc")]
43 pub started_at: DateTime<Utc>,
44 #[serde(default, with = "crate::serde_timestamp::utc::option")]
46 pub completed_at: Option<DateTime<Utc>>,
47 pub created_by: Option<String>,
49 pub notes: Option<String>,
51}
52
53impl DunningRun {
54 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 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 pub fn start(&mut self) {
87 self.status = DunningRunStatus::InProgress;
88 self.started_at = Utc::now();
89 }
90
91 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 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#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
113pub enum DunningRunStatus {
114 #[default]
116 Pending,
117 InProgress,
119 Completed,
121 Failed,
123 Cancelled,
125}
126
127#[derive(Debug, Clone, Serialize, Deserialize)]
129pub struct DunningLetter {
130 pub letter_id: String,
132 pub dunning_run_id: String,
134 pub company_code: String,
136 pub customer_id: String,
138 pub customer_name: String,
140 pub dunning_level: u8,
142 pub dunning_date: NaiveDate,
144 pub dunning_items: Vec<DunningItem>,
146 pub total_dunned_amount: Decimal,
148 pub dunning_charges: Decimal,
150 pub interest_amount: Decimal,
152 pub total_amount_due: Decimal,
154 pub currency: String,
156 pub payment_deadline: NaiveDate,
158 pub is_sent: bool,
160 pub sent_date: Option<NaiveDate>,
162 pub response_type: Option<DunningResponseType>,
164 pub response_date: Option<NaiveDate>,
166 pub status: DunningLetterStatus,
168 pub contact_address: Option<String>,
170 pub notes: Option<String>,
172 #[serde(with = "crate::serde_timestamp::utc")]
174 pub created_at: DateTime<Utc>,
175}
176
177impl DunningLetter {
178 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 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 pub fn set_charges(&mut self, charges: Decimal) {
225 self.dunning_charges = charges;
226 self.recalculate_totals();
227 }
228
229 pub fn set_interest(&mut self, interest: Decimal) {
231 self.interest_amount = interest;
232 self.recalculate_totals();
233 }
234
235 fn recalculate_totals(&mut self) {
237 self.total_amount_due =
238 self.total_dunned_amount + self.dunning_charges + self.interest_amount;
239 }
240
241 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 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 pub fn escalate_to_collection(&mut self) {
267 self.status = DunningLetterStatus::EscalatedToCollection;
268 }
269}
270
271#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
273pub enum DunningLetterStatus {
274 #[default]
276 Created,
277 Sent,
279 Disputed,
281 Resolved,
283 EscalatedToCollection,
285 WrittenOff,
287 Cancelled,
289}
290
291#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
293pub enum DunningResponseType {
294 NoResponse,
296 PaymentPromise,
298 Paid,
300 Dispute,
302 PartialDispute,
304 PaymentPlan,
306 Bankruptcy,
308}
309
310#[derive(Debug, Clone, Serialize, Deserialize)]
312pub struct DunningItem {
313 pub invoice_number: String,
315 pub invoice_date: NaiveDate,
317 pub due_date: NaiveDate,
319 pub original_amount: Decimal,
321 pub open_amount: Decimal,
323 pub days_overdue: u32,
325 pub interest_amount: Decimal,
327 pub previous_dunning_level: u8,
329 pub new_dunning_level: u8,
331 pub is_blocked: bool,
333 pub block_reason: Option<String>,
335}
336
337impl DunningItem {
338 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 pub fn with_interest(mut self, interest: Decimal) -> Self {
366 self.interest_amount = interest;
367 self
368 }
369
370 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#[derive(Debug, Clone, Serialize, Deserialize)]
380pub struct CustomerDunningSummary {
381 pub customer_id: String,
383 pub customer_name: String,
385 pub current_dunning_level: u8,
387 pub letters_sent: u32,
389 pub total_dunned_amount: Decimal,
391 pub total_charges: Decimal,
393 pub total_interest: Decimal,
395 pub last_dunning_date: Option<NaiveDate>,
397 pub is_blocked: bool,
399 pub in_collection: bool,
401}
402
403impl CustomerDunningSummary {
404 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)); 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}