1use chrono::NaiveDate;
10use rand::prelude::*;
11use rand_chacha::ChaCha8Rng;
12use rust_decimal::Decimal;
13use rust_decimal_macros::dec;
14
15use datasynth_core::accounts::control_accounts;
16use datasynth_core::models::subledger::ar::{
17 ARInvoice, CustomerDunningSummary, DunningItem, DunningLetter, DunningResponseType, DunningRun,
18};
19use datasynth_core::models::subledger::SubledgerDocumentStatus;
20use datasynth_core::models::{JournalEntry, JournalEntryLine};
21
22#[derive(Debug, Clone)]
24pub struct DunningGeneratorConfig {
25 pub level_1_days_overdue: u32,
27 pub level_2_days_overdue: u32,
29 pub level_3_days_overdue: u32,
31 pub collection_days_overdue: u32,
33 pub payment_rate_after_level_1: f64,
35 pub payment_rate_after_level_2: f64,
37 pub payment_rate_after_level_3: f64,
39 pub payment_rate_during_collection: f64,
41 pub never_pay_rate: f64,
43 pub dunning_block_rate: f64,
45 pub interest_rate_per_year: f64,
47 pub dunning_charge_per_letter: Decimal,
49 pub payment_deadline_days: u32,
51}
52
53impl Default for DunningGeneratorConfig {
54 fn default() -> Self {
55 Self {
56 level_1_days_overdue: 14,
57 level_2_days_overdue: 28,
58 level_3_days_overdue: 42,
59 collection_days_overdue: 60,
60 payment_rate_after_level_1: 0.40,
61 payment_rate_after_level_2: 0.30,
62 payment_rate_after_level_3: 0.15,
63 payment_rate_during_collection: 0.05,
64 never_pay_rate: 0.10,
65 dunning_block_rate: 0.05,
66 interest_rate_per_year: 0.09,
67 dunning_charge_per_letter: dec!(25),
68 payment_deadline_days: 14,
69 }
70 }
71}
72
73pub struct DunningGenerator {
75 config: DunningGeneratorConfig,
76 rng: ChaCha8Rng,
77 seed: u64,
78 run_counter: u64,
79 letter_counter: u64,
80}
81
82impl DunningGenerator {
83 pub fn new(seed: u64) -> Self {
85 Self::with_config(seed, DunningGeneratorConfig::default())
86 }
87
88 pub fn with_config(seed: u64, config: DunningGeneratorConfig) -> Self {
90 Self {
91 config,
92 rng: ChaCha8Rng::seed_from_u64(seed),
93 seed,
94 run_counter: 0,
95 letter_counter: 0,
96 }
97 }
98
99 pub fn execute_dunning_run(
104 &mut self,
105 company_code: &str,
106 run_date: NaiveDate,
107 invoices: &mut [ARInvoice],
108 currency: &str,
109 ) -> DunningRunResult {
110 self.run_counter += 1;
111 let run_id = format!("DR-{}-{:06}", company_code, self.run_counter);
112
113 let mut run = DunningRun::new(run_id.clone(), company_code.to_string(), run_date);
114 run.start();
115
116 let mut letters = Vec::new();
117 let mut journal_entries = Vec::new();
118 let mut payment_simulations = Vec::new();
119
120 let mut customer_invoices: std::collections::HashMap<String, Vec<&mut ARInvoice>> =
122 std::collections::HashMap::new();
123
124 for invoice in invoices.iter_mut() {
125 if invoice.company_code == company_code
126 && matches!(
127 invoice.status,
128 SubledgerDocumentStatus::Open | SubledgerDocumentStatus::PartiallyCleared
129 )
130 && invoice.is_overdue(run_date)
131 {
132 customer_invoices
133 .entry(invoice.customer_id.clone())
134 .or_default()
135 .push(invoice);
136 }
137 }
138
139 run.customers_evaluated = customer_invoices.len() as u32;
140
141 for (customer_id, customer_invoices) in customer_invoices.iter_mut() {
143 let customer_name = customer_invoices
144 .first()
145 .map(|i| i.customer_name.clone())
146 .unwrap_or_default();
147
148 let max_days_overdue = customer_invoices
150 .iter()
151 .map(|i| i.days_overdue(run_date) as u32)
152 .max()
153 .unwrap_or(0);
154
155 let dunning_level = self.determine_dunning_level(max_days_overdue);
156
157 if dunning_level == 0 {
158 continue;
159 }
160
161 if self.rng.gen::<f64>() < self.config.dunning_block_rate {
163 continue;
165 }
166
167 self.letter_counter += 1;
169 let letter_id = format!("DL-{}-{:08}", company_code, self.letter_counter);
170
171 let payment_deadline =
172 run_date + chrono::Duration::days(self.config.payment_deadline_days as i64);
173
174 let mut letter = DunningLetter::new(
175 letter_id,
176 run_id.clone(),
177 company_code.to_string(),
178 customer_id.clone(),
179 customer_name,
180 dunning_level,
181 run_date,
182 payment_deadline,
183 currency.to_string(),
184 );
185
186 let mut total_interest = Decimal::ZERO;
188 for invoice in customer_invoices.iter_mut() {
189 let days_overdue = invoice.days_overdue(run_date) as u32;
190 let previous_level = invoice.dunning_info.dunning_level;
191 let new_level = self.determine_dunning_level(days_overdue);
192
193 let interest = self.calculate_interest(
195 invoice.amount_remaining,
196 days_overdue,
197 self.config.interest_rate_per_year,
198 );
199 total_interest += interest;
200
201 let item = DunningItem::new(
202 invoice.invoice_number.clone(),
203 invoice.invoice_date,
204 invoice.due_date,
205 invoice.gross_amount.document_amount,
206 invoice.amount_remaining,
207 days_overdue,
208 previous_level,
209 new_level,
210 )
211 .with_interest(interest);
212
213 letter.add_item(item);
214
215 invoice.dunning_info.advance_level(run_date, run_id.clone());
217 }
218
219 letter.set_charges(self.config.dunning_charge_per_letter);
221 letter.set_interest(total_interest);
222
223 letter.mark_sent(run_date);
225
226 if letter.dunning_charges > Decimal::ZERO || letter.interest_amount > Decimal::ZERO {
228 let je = self.generate_dunning_je(&letter, company_code, currency);
229 journal_entries.push(je);
230 }
231
232 let response = self.simulate_payment_response(dunning_level);
234 payment_simulations.push(PaymentSimulation {
235 customer_id: customer_id.clone(),
236 dunning_level,
237 response,
238 amount: letter.total_dunned_amount,
239 expected_payment_date: self.calculate_expected_payment_date(run_date, response),
240 });
241
242 run.add_letter(letter.clone());
243 letters.push(letter);
244 }
245
246 run.complete();
247
248 DunningRunResult {
249 dunning_run: run,
250 letters,
251 journal_entries,
252 payment_simulations,
253 }
254 }
255
256 fn determine_dunning_level(&self, days_overdue: u32) -> u8 {
258 if days_overdue >= self.config.collection_days_overdue {
259 4
260 } else if days_overdue >= self.config.level_3_days_overdue {
261 3
262 } else if days_overdue >= self.config.level_2_days_overdue {
263 2
264 } else if days_overdue >= self.config.level_1_days_overdue {
265 1
266 } else {
267 0
268 }
269 }
270
271 fn calculate_interest(&self, amount: Decimal, days_overdue: u32, annual_rate: f64) -> Decimal {
273 let daily_rate = annual_rate / 365.0;
274 let interest_factor = daily_rate * days_overdue as f64;
275 (amount * Decimal::try_from(interest_factor).unwrap_or(Decimal::ZERO)).round_dp(2)
276 }
277
278 fn simulate_payment_response(&mut self, dunning_level: u8) -> DunningResponseType {
280 let roll: f64 = self.rng.gen();
281
282 let p1 = self.config.payment_rate_after_level_1;
284 let p2 = p1 + self.config.payment_rate_after_level_2;
285 let p3 = p2 + self.config.payment_rate_after_level_3;
286 let p4 = p3 + self.config.payment_rate_during_collection;
287 match dunning_level {
290 1 => {
291 if roll < p1 {
292 DunningResponseType::Paid
293 } else if roll < p1 + 0.05 {
294 DunningResponseType::PaymentPromise
295 } else if roll < p1 + 0.10 {
296 DunningResponseType::Dispute
297 } else {
298 DunningResponseType::NoResponse
299 }
300 }
301 2 => {
302 if roll < p2 - p1 {
303 DunningResponseType::Paid
304 } else if roll < (p2 - p1) + 0.10 {
305 DunningResponseType::PaymentPromise
306 } else if roll < (p2 - p1) + 0.15 {
307 DunningResponseType::PaymentPlan
308 } else if roll < (p2 - p1) + 0.20 {
309 DunningResponseType::Dispute
310 } else {
311 DunningResponseType::NoResponse
312 }
313 }
314 3 => {
315 if roll < p3 - p2 {
316 DunningResponseType::Paid
317 } else if roll < (p3 - p2) + 0.05 {
318 DunningResponseType::PaymentPlan
319 } else if roll < (p3 - p2) + 0.10 {
320 DunningResponseType::PartialDispute
321 } else {
322 DunningResponseType::NoResponse
323 }
324 }
325 4 => {
326 if roll < p4 - p3 {
327 DunningResponseType::Paid
328 } else if roll < (p4 - p3) + 0.02 {
329 DunningResponseType::Bankruptcy
330 } else {
331 DunningResponseType::NoResponse
332 }
333 }
334 _ => DunningResponseType::NoResponse,
335 }
336 }
337
338 fn calculate_expected_payment_date(
340 &mut self,
341 dunning_date: NaiveDate,
342 response: DunningResponseType,
343 ) -> Option<NaiveDate> {
344 match response {
345 DunningResponseType::Paid => {
346 Some(dunning_date + chrono::Duration::days(self.rng.gen_range(1..14) as i64))
347 }
348 DunningResponseType::PaymentPromise => {
349 Some(dunning_date + chrono::Duration::days(self.rng.gen_range(7..21) as i64))
350 }
351 DunningResponseType::PaymentPlan => {
352 Some(dunning_date + chrono::Duration::days(self.rng.gen_range(30..90) as i64))
353 }
354 _ => None,
355 }
356 }
357
358 fn generate_dunning_je(
360 &self,
361 letter: &DunningLetter,
362 company_code: &str,
363 _currency: &str,
364 ) -> JournalEntry {
365 let mut je = JournalEntry::new_simple(
366 format!("JE-DUNN-{}", letter.letter_id),
367 company_code.to_string(),
368 letter.dunning_date,
369 format!("Dunning charges letter {}", letter.letter_id),
370 );
371
372 let mut line_num = 1;
373
374 let total_receivable = letter.dunning_charges + letter.interest_amount;
376 if total_receivable > Decimal::ZERO {
377 je.add_line(JournalEntryLine {
378 line_number: line_num,
379 gl_account: control_accounts::AR_CONTROL.to_string(),
380 debit_amount: total_receivable,
381 reference: Some(letter.letter_id.clone()),
382 assignment: Some(letter.customer_id.clone()),
383 ..Default::default()
384 });
385 line_num += 1;
386 }
387
388 if letter.dunning_charges > Decimal::ZERO {
390 je.add_line(JournalEntryLine {
391 line_number: line_num,
392 gl_account: "4800".to_string(), credit_amount: letter.dunning_charges,
394 reference: Some(letter.letter_id.clone()),
395 ..Default::default()
396 });
397 line_num += 1;
398 }
399
400 if letter.interest_amount > Decimal::ZERO {
402 je.add_line(JournalEntryLine {
403 line_number: line_num,
404 gl_account: "4810".to_string(), credit_amount: letter.interest_amount,
406 reference: Some(letter.letter_id.clone()),
407 ..Default::default()
408 });
409 }
410
411 je
412 }
413
414 pub fn generate_customer_summaries(
416 &self,
417 letters: &[DunningLetter],
418 ) -> Vec<CustomerDunningSummary> {
419 let customer_ids: std::collections::HashSet<_> =
420 letters.iter().map(|l| l.customer_id.clone()).collect();
421
422 customer_ids
423 .into_iter()
424 .map(|customer_id| {
425 let customer_name = letters
426 .iter()
427 .find(|l| l.customer_id == customer_id)
428 .map(|l| l.customer_name.clone())
429 .unwrap_or_default();
430
431 CustomerDunningSummary::from_letters(customer_id, customer_name, letters)
432 })
433 .collect()
434 }
435
436 pub fn generate_period_dunning_runs(
438 &mut self,
439 company_code: &str,
440 start_date: NaiveDate,
441 end_date: NaiveDate,
442 invoices: &mut [ARInvoice],
443 currency: &str,
444 run_frequency_days: u32,
445 ) -> Vec<DunningRunResult> {
446 let mut results = Vec::new();
447 let mut current_date = start_date;
448
449 while current_date <= end_date {
450 let result = self.execute_dunning_run(company_code, current_date, invoices, currency);
451 results.push(result);
452
453 current_date += chrono::Duration::days(run_frequency_days as i64);
454 }
455
456 results
457 }
458
459 pub fn reset(&mut self) {
461 self.rng = ChaCha8Rng::seed_from_u64(self.seed);
462 self.run_counter = 0;
463 self.letter_counter = 0;
464 }
465}
466
467#[derive(Debug, Clone)]
469pub struct DunningRunResult {
470 pub dunning_run: DunningRun,
472 pub letters: Vec<DunningLetter>,
474 pub journal_entries: Vec<JournalEntry>,
476 pub payment_simulations: Vec<PaymentSimulation>,
478}
479
480#[derive(Debug, Clone)]
482pub struct PaymentSimulation {
483 pub customer_id: String,
485 pub dunning_level: u8,
487 pub response: DunningResponseType,
489 pub amount: Decimal,
491 pub expected_payment_date: Option<NaiveDate>,
493}
494
495#[cfg(test)]
496mod tests {
497 use super::*;
498 use datasynth_core::models::subledger::ar::DunningRunStatus;
499 use datasynth_core::models::subledger::{CurrencyAmount, PaymentTerms};
500
501 fn create_test_invoice(
502 invoice_number: &str,
503 customer_id: &str,
504 invoice_date: NaiveDate,
505 due_date: NaiveDate,
506 amount: Decimal,
507 ) -> ARInvoice {
508 let mut invoice = ARInvoice::new(
509 invoice_number.to_string(),
510 "1000".to_string(),
511 customer_id.to_string(),
512 format!("Customer {}", customer_id),
513 invoice_date,
514 PaymentTerms::net_30(),
515 "USD".to_string(),
516 );
517 invoice.due_date = due_date;
518 invoice.gross_amount = CurrencyAmount::single_currency(amount, "USD".to_string());
519 invoice.amount_remaining = amount;
520 invoice
521 }
522
523 #[test]
524 fn test_dunning_level_determination() {
525 let gen = DunningGenerator::new(42);
526
527 assert_eq!(gen.determine_dunning_level(10), 0);
528 assert_eq!(gen.determine_dunning_level(14), 1);
529 assert_eq!(gen.determine_dunning_level(20), 1);
530 assert_eq!(gen.determine_dunning_level(28), 2);
531 assert_eq!(gen.determine_dunning_level(35), 2);
532 assert_eq!(gen.determine_dunning_level(42), 3);
533 assert_eq!(gen.determine_dunning_level(50), 3);
534 assert_eq!(gen.determine_dunning_level(60), 4);
535 assert_eq!(gen.determine_dunning_level(90), 4);
536 }
537
538 #[test]
539 fn test_interest_calculation() {
540 let gen = DunningGenerator::new(42);
541
542 let interest = gen.calculate_interest(dec!(1000), 30, 0.09);
544 assert!(interest > dec!(7) && interest < dec!(8));
546 }
547
548 #[test]
549 fn test_dunning_run_execution() {
550 let mut gen = DunningGenerator::new(42);
551
552 let run_date = NaiveDate::from_ymd_opt(2024, 3, 15).unwrap();
553 let invoice_date = NaiveDate::from_ymd_opt(2024, 1, 15).unwrap();
554 let due_date = NaiveDate::from_ymd_opt(2024, 2, 14).unwrap();
555
556 let mut invoices = vec![
557 create_test_invoice("INV-001", "CUST001", invoice_date, due_date, dec!(1000)),
558 create_test_invoice("INV-002", "CUST001", invoice_date, due_date, dec!(500)),
559 create_test_invoice("INV-003", "CUST002", invoice_date, due_date, dec!(2000)),
560 ];
561
562 let result = gen.execute_dunning_run("1000", run_date, &mut invoices, "USD");
563
564 assert_eq!(result.dunning_run.status, DunningRunStatus::Completed);
565 assert!(!result.letters.is_empty());
566
567 for letter in &result.letters {
569 assert!(letter.dunning_level >= 1);
570 assert!(letter.dunning_level <= 2);
571 }
572 }
573
574 #[test]
575 fn test_dunning_charges_and_interest() {
576 let mut gen = DunningGenerator::new(42);
577
578 let run_date = NaiveDate::from_ymd_opt(2024, 3, 15).unwrap();
579 let invoice_date = NaiveDate::from_ymd_opt(2024, 1, 15).unwrap();
580 let due_date = NaiveDate::from_ymd_opt(2024, 2, 14).unwrap();
581
582 let mut invoices = vec![create_test_invoice(
583 "INV-001",
584 "CUST001",
585 invoice_date,
586 due_date,
587 dec!(1000),
588 )];
589
590 let result = gen.execute_dunning_run("1000", run_date, &mut invoices, "USD");
591
592 if let Some(letter) = result.letters.first() {
593 assert_eq!(letter.dunning_charges, dec!(25)); assert!(letter.interest_amount > Decimal::ZERO);
595 assert!(letter.total_amount_due > letter.total_dunned_amount);
596 }
597 }
598
599 #[test]
600 fn test_deterministic_generation() {
601 let run_date = NaiveDate::from_ymd_opt(2024, 3, 15).unwrap();
602 let invoice_date = NaiveDate::from_ymd_opt(2024, 1, 15).unwrap();
603 let due_date = NaiveDate::from_ymd_opt(2024, 2, 14).unwrap();
604
605 let create_invoices = || {
606 vec![create_test_invoice(
607 "INV-001",
608 "CUST001",
609 invoice_date,
610 due_date,
611 dec!(1000),
612 )]
613 };
614
615 let mut gen1 = DunningGenerator::new(42);
616 let mut gen2 = DunningGenerator::new(42);
617
618 let mut invoices1 = create_invoices();
619 let mut invoices2 = create_invoices();
620
621 let result1 = gen1.execute_dunning_run("1000", run_date, &mut invoices1, "USD");
622 let result2 = gen2.execute_dunning_run("1000", run_date, &mut invoices2, "USD");
623
624 assert_eq!(result1.letters.len(), result2.letters.len());
625 assert_eq!(
626 result1.dunning_run.total_amount_dunned,
627 result2.dunning_run.total_amount_dunned
628 );
629 }
630}