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