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