Skip to main content

datasynth_generators/subledger/
dunning_generator.rs

1//! Dunning (Mahnungen) generator.
2//!
3//! Generates dunning runs and letters for overdue AR invoices,
4//! simulating the realistic dunning process including:
5//! - Multi-level dunning (reminders, final notices, collection)
6//! - Payment responses after dunning
7//! - Interest and charge calculations
8
9use 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/// Configuration for dunning generation.
24#[derive(Debug, Clone)]
25pub struct DunningGeneratorConfig {
26    /// Days overdue for level 1 dunning.
27    pub level_1_days_overdue: u32,
28    /// Days overdue for level 2 dunning.
29    pub level_2_days_overdue: u32,
30    /// Days overdue for level 3 dunning.
31    pub level_3_days_overdue: u32,
32    /// Days overdue for collection handover.
33    pub collection_days_overdue: u32,
34    /// Payment rate after level 1 reminder.
35    pub payment_rate_after_level_1: f64,
36    /// Payment rate after level 2 reminder.
37    pub payment_rate_after_level_2: f64,
38    /// Payment rate after level 3 final notice.
39    pub payment_rate_after_level_3: f64,
40    /// Payment rate during collection.
41    pub payment_rate_during_collection: f64,
42    /// Rate that never pays (becomes bad debt).
43    pub never_pay_rate: f64,
44    /// Rate of invoices blocked from dunning (disputes).
45    pub dunning_block_rate: f64,
46    /// Annual interest rate for overdue amounts.
47    pub interest_rate_per_year: f64,
48    /// Fixed charge per dunning letter.
49    pub dunning_charge_per_letter: Decimal,
50    /// Days between dunning run and payment deadline.
51    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
74/// Generator for dunning process.
75pub struct DunningGenerator {
76    config: DunningGeneratorConfig,
77    rng: ChaCha8Rng,
78    seed: u64,
79    run_counter: u64,
80    letter_counter: u64,
81}
82
83impl DunningGenerator {
84    /// Creates a new dunning generator.
85    pub fn new(seed: u64) -> Self {
86        Self::with_config(seed, DunningGeneratorConfig::default())
87    }
88
89    /// Creates a new dunning generator with custom configuration.
90    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    /// Executes a dunning run for a given date.
101    ///
102    /// This evaluates all open invoices and generates dunning letters
103    /// for those that meet the dunning criteria.
104    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        // Group invoices by customer
122        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        // Process each customer
143        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            // Determine highest dunning level needed
150            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            // Check if blocked from dunning
163            if self.rng.gen::<f64>() < self.config.dunning_block_rate {
164                // Skip this customer - dunning blocked
165                continue;
166            }
167
168            // Create dunning letter
169            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            // Add dunning items
188            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                // Calculate interest
195                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                // Update invoice dunning info
217                invoice.dunning_info.advance_level(run_date, run_id.clone());
218            }
219
220            // Set charges and interest
221            letter.set_charges(self.config.dunning_charge_per_letter);
222            letter.set_interest(total_interest);
223
224            // Mark as sent
225            letter.mark_sent(run_date);
226
227            // Generate journal entry for dunning charges and interest
228            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            // Simulate payment response
234            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    /// Determines the dunning level based on days overdue.
258    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    /// Calculates interest for an overdue amount.
273    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    /// Simulates a payment response based on dunning level and configuration.
280    fn simulate_payment_response(&mut self, dunning_level: u8) -> DunningResponseType {
281        let roll: f64 = self.rng.gen();
282
283        // Calculate cumulative probabilities
284        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        // Remainder is never_pay
289
290        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    /// Calculates the expected payment date based on response type.
340    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.gen_range(1..14) as i64))
348            }
349            DunningResponseType::PaymentPromise => {
350                Some(dunning_date + chrono::Duration::days(self.rng.gen_range(7..21) as i64))
351            }
352            DunningResponseType::PaymentPlan => {
353                Some(dunning_date + chrono::Duration::days(self.rng.gen_range(30..90) as i64))
354            }
355            _ => None,
356        }
357    }
358
359    /// Generates a journal entry for dunning charges and interest.
360    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        // Debit AR for charges and interest
376        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        // Credit Dunning charges revenue
390        if letter.dunning_charges > Decimal::ZERO {
391            je.add_line(JournalEntryLine {
392                line_number: line_num,
393                gl_account: "4800".to_string(), // Other operating income
394                credit_amount: letter.dunning_charges,
395                reference: Some(letter.letter_id.clone()),
396                ..Default::default()
397            });
398            line_num += 1;
399        }
400
401        // Credit Interest income
402        if letter.interest_amount > Decimal::ZERO {
403            je.add_line(JournalEntryLine {
404                line_number: line_num,
405                gl_account: "4810".to_string(), // Interest income
406                credit_amount: letter.interest_amount,
407                reference: Some(letter.letter_id.clone()),
408                ..Default::default()
409            });
410        }
411
412        je
413    }
414
415    /// Generates customer dunning summaries.
416    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    /// Generates dunning runs for a period (e.g., monthly dunning).
438    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    /// Resets the generator.
461    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/// Result of a dunning run.
469#[derive(Debug, Clone)]
470pub struct DunningRunResult {
471    /// The dunning run record.
472    pub dunning_run: DunningRun,
473    /// Letters generated.
474    pub letters: Vec<DunningLetter>,
475    /// Journal entries for charges and interest.
476    pub journal_entries: Vec<JournalEntry>,
477    /// Simulated payment responses.
478    pub payment_simulations: Vec<PaymentSimulation>,
479}
480
481/// Simulated payment response.
482#[derive(Debug, Clone)]
483pub struct PaymentSimulation {
484    /// Customer ID.
485    pub customer_id: String,
486    /// Dunning level.
487    pub dunning_level: u8,
488    /// Response type.
489    pub response: DunningResponseType,
490    /// Amount due.
491    pub amount: Decimal,
492    /// Expected payment date (if paying).
493    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        // $1000 at 9% annual for 30 days
545        let interest = gen.calculate_interest(dec!(1000), 30, 0.09);
546        // Expected: 1000 * (0.09/365) * 30 ≈ 7.40
547        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        // Check dunning levels are appropriate (invoices are ~30 days overdue)
570        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)); // Default charge
596            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}