koprogo_api/domain/entities/
payment_reminder.rs

1use chrono::{DateTime, Utc};
2use serde::{Deserialize, Serialize};
3use uuid::Uuid;
4
5/// Niveau de relance de paiement
6#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
7pub enum ReminderLevel {
8    FirstReminder,  // J+15 - Rappel aimable
9    SecondReminder, // J+30 - Relance ferme
10    FormalNotice,   // J+60 - Mise en demeure légale
11}
12
13impl ReminderLevel {
14    /// Nombre de jours après la date d'échéance pour chaque niveau
15    pub fn days_after_due_date(&self) -> i64 {
16        match self {
17            ReminderLevel::FirstReminder => 15,
18            ReminderLevel::SecondReminder => 30,
19            ReminderLevel::FormalNotice => 60,
20        }
21    }
22
23    /// Prochain niveau de relance (None si dernier niveau atteint)
24    pub fn next_level(&self) -> Option<ReminderLevel> {
25        match self {
26            ReminderLevel::FirstReminder => Some(ReminderLevel::SecondReminder),
27            ReminderLevel::SecondReminder => Some(ReminderLevel::FormalNotice),
28            ReminderLevel::FormalNotice => None, // Dernier niveau - passer à huissier
29        }
30    }
31
32    /// Ton du message pour chaque niveau
33    pub fn tone(&self) -> &'static str {
34        match self {
35            ReminderLevel::FirstReminder => "aimable",
36            ReminderLevel::SecondReminder => "ferme",
37            ReminderLevel::FormalNotice => "juridique",
38        }
39    }
40}
41
42/// Statut d'une relance
43#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
44pub enum ReminderStatus {
45    Pending,   // En attente d'envoi
46    Sent,      // Envoyée
47    Opened,    // Email ouvert par le destinataire
48    Paid,      // Paiement reçu après relance
49    Escalated, // Escaladé au niveau supérieur
50    Cancelled, // Annulé (paiement reçu avant envoi)
51}
52
53/// Méthode d'envoi de la relance
54#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
55pub enum DeliveryMethod {
56    Email,
57    RegisteredLetter, // Lettre recommandée
58    Bailiff,          // Huissier de justice
59}
60
61/// Représente une relance de paiement pour charges impayées
62#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
63pub struct PaymentReminder {
64    pub id: Uuid,
65    pub organization_id: Uuid,
66    pub expense_id: Uuid,
67    pub owner_id: Uuid,
68    pub level: ReminderLevel,
69    pub status: ReminderStatus,
70    pub amount_owed: f64,        // Montant dû (en euros)
71    pub penalty_amount: f64,     // Pénalités de retard (8% annuel en Belgique)
72    pub total_amount: f64,       // Montant total (owed + penalties)
73    pub due_date: DateTime<Utc>, // Date d'échéance originale de la charge
74    pub days_overdue: i64,       // Nombre de jours de retard
75    pub delivery_method: DeliveryMethod,
76    pub sent_date: Option<DateTime<Utc>>,
77    pub opened_date: Option<DateTime<Utc>>,
78    pub pdf_path: Option<String>, // Chemin vers le PDF de la lettre
79    pub tracking_number: Option<String>, // Numéro de suivi (lettre recommandée)
80    pub notes: Option<String>,
81    pub created_at: DateTime<Utc>,
82    pub updated_at: DateTime<Utc>,
83}
84
85impl PaymentReminder {
86    /// Taux légal de pénalité de retard en Belgique (8% annuel)
87    pub const BELGIAN_PENALTY_RATE: f64 = 0.08;
88
89    /// Crée une nouvelle relance de paiement
90    #[allow(clippy::too_many_arguments)]
91    pub fn new(
92        organization_id: Uuid,
93        expense_id: Uuid,
94        owner_id: Uuid,
95        level: ReminderLevel,
96        amount_owed: f64,
97        due_date: DateTime<Utc>,
98        days_overdue: i64,
99    ) -> Result<Self, String> {
100        // Validation des business rules
101        if amount_owed <= 0.0 {
102            return Err("Amount owed must be greater than 0".to_string());
103        }
104
105        if days_overdue < 0 {
106            return Err("Days overdue cannot be negative".to_string());
107        }
108
109        // Vérifier que le niveau de relance correspond au nombre de jours de retard
110        let expected_days = level.days_after_due_date();
111        if days_overdue < expected_days {
112            return Err(format!(
113                "Cannot create {} reminder before {} days overdue (currently {} days)",
114                match level {
115                    ReminderLevel::FirstReminder => "first",
116                    ReminderLevel::SecondReminder => "second",
117                    ReminderLevel::FormalNotice => "formal notice",
118                },
119                expected_days,
120                days_overdue
121            ));
122        }
123
124        // Calculer les pénalités de retard (taux légal belge: 8% annuel)
125        let penalty_amount = Self::calculate_penalty(amount_owed, days_overdue);
126        let total_amount = amount_owed + penalty_amount;
127
128        // Déterminer la méthode de livraison selon le niveau
129        let delivery_method = match level {
130            ReminderLevel::FirstReminder => DeliveryMethod::Email,
131            ReminderLevel::SecondReminder => DeliveryMethod::Email,
132            ReminderLevel::FormalNotice => DeliveryMethod::RegisteredLetter,
133        };
134
135        let now = Utc::now();
136        Ok(Self {
137            id: Uuid::new_v4(),
138            organization_id,
139            expense_id,
140            owner_id,
141            level,
142            status: ReminderStatus::Pending,
143            amount_owed,
144            penalty_amount,
145            total_amount,
146            due_date,
147            days_overdue,
148            delivery_method,
149            sent_date: None,
150            opened_date: None,
151            pdf_path: None,
152            tracking_number: None,
153            notes: None,
154            created_at: now,
155            updated_at: now,
156        })
157    }
158
159    /// Calcule les pénalités de retard selon le taux légal belge (8% annuel)
160    /// Formule: pénalité = montant * 0.08 * (jours_retard / 365)
161    pub fn calculate_penalty(amount: f64, days_overdue: i64) -> f64 {
162        if days_overdue <= 0 {
163            return 0.0;
164        }
165        let yearly_penalty = amount * Self::BELGIAN_PENALTY_RATE;
166        let daily_penalty = yearly_penalty / 365.0;
167        (daily_penalty * days_overdue as f64 * 100.0).round() / 100.0 // Arrondi à 2 décimales
168    }
169
170    /// Marque la relance comme envoyée
171    pub fn mark_as_sent(&mut self, pdf_path: Option<String>) -> Result<(), String> {
172        if self.status != ReminderStatus::Pending {
173            return Err(format!(
174                "Cannot mark reminder as sent: current status is {:?}",
175                self.status
176            ));
177        }
178
179        self.status = ReminderStatus::Sent;
180        self.sent_date = Some(Utc::now());
181        self.pdf_path = pdf_path;
182        self.updated_at = Utc::now();
183        Ok(())
184    }
185
186    /// Marque la relance comme ouverte (email ouvert)
187    pub fn mark_as_opened(&mut self) -> Result<(), String> {
188        if self.status != ReminderStatus::Sent {
189            return Err(format!(
190                "Cannot mark reminder as opened: must be sent first (current status: {:?})",
191                self.status
192            ));
193        }
194
195        self.status = ReminderStatus::Opened;
196        self.opened_date = Some(Utc::now());
197        self.updated_at = Utc::now();
198        Ok(())
199    }
200
201    /// Marque la relance comme payée
202    pub fn mark_as_paid(&mut self) -> Result<(), String> {
203        match self.status {
204            ReminderStatus::Sent | ReminderStatus::Opened | ReminderStatus::Pending => {
205                self.status = ReminderStatus::Paid;
206                self.updated_at = Utc::now();
207                Ok(())
208            }
209            ReminderStatus::Paid => Err("Reminder is already marked as paid".to_string()),
210            ReminderStatus::Escalated => Err("Cannot mark escalated reminder as paid".to_string()),
211            ReminderStatus::Cancelled => Err("Cannot mark cancelled reminder as paid".to_string()),
212        }
213    }
214
215    /// Escalade vers le niveau de relance supérieur
216    pub fn escalate(&mut self) -> Result<Option<ReminderLevel>, String> {
217        if self.status == ReminderStatus::Paid || self.status == ReminderStatus::Cancelled {
218            return Err(format!(
219                "Cannot escalate reminder with status {:?}",
220                self.status
221            ));
222        }
223
224        self.status = ReminderStatus::Escalated;
225        self.updated_at = Utc::now();
226        Ok(self.level.next_level())
227    }
228
229    /// Annule la relance (paiement reçu avant envoi)
230    pub fn cancel(&mut self, reason: String) -> Result<(), String> {
231        if self.status == ReminderStatus::Sent || self.status == ReminderStatus::Opened {
232            return Err("Cannot cancel reminder that has already been sent".to_string());
233        }
234
235        self.status = ReminderStatus::Cancelled;
236        self.notes = Some(reason);
237        self.updated_at = Utc::now();
238        Ok(())
239    }
240
241    /// Ajoute un numéro de suivi (pour lettre recommandée)
242    pub fn set_tracking_number(&mut self, tracking_number: String) -> Result<(), String> {
243        if self.delivery_method != DeliveryMethod::RegisteredLetter {
244            return Err("Tracking number is only valid for registered letters".to_string());
245        }
246
247        self.tracking_number = Some(tracking_number);
248        self.updated_at = Utc::now();
249        Ok(())
250    }
251
252    /// Vérifie si la relance nécessite une escalade
253    pub fn needs_escalation(&self, current_date: DateTime<Utc>) -> bool {
254        if self.status != ReminderStatus::Sent && self.status != ReminderStatus::Opened {
255            return false;
256        }
257
258        if let Some(sent_date) = self.sent_date {
259            let days_since_sent = (current_date - sent_date).num_days();
260            // Escalader si pas de réponse après 15 jours
261            days_since_sent >= 15 && self.level.next_level().is_some()
262        } else {
263            false
264        }
265    }
266
267    /// Recalcule les pénalités en fonction du nombre de jours actuel
268    pub fn recalculate_penalties(&mut self, current_days_overdue: i64) {
269        self.days_overdue = current_days_overdue;
270        self.penalty_amount = Self::calculate_penalty(self.amount_owed, current_days_overdue);
271        self.total_amount = self.amount_owed + self.penalty_amount;
272        self.updated_at = Utc::now();
273    }
274}
275
276#[cfg(test)]
277mod tests {
278    use super::*;
279
280    #[test]
281    fn test_create_payment_reminder_success() {
282        let org_id = Uuid::new_v4();
283        let expense_id = Uuid::new_v4();
284        let owner_id = Uuid::new_v4();
285        let due_date = Utc::now() - chrono::Duration::days(20);
286
287        let reminder = PaymentReminder::new(
288            org_id,
289            expense_id,
290            owner_id,
291            ReminderLevel::FirstReminder,
292            100.0,
293            due_date,
294            20,
295        );
296
297        assert!(reminder.is_ok());
298        let reminder = reminder.unwrap();
299        assert_eq!(reminder.status, ReminderStatus::Pending);
300        assert_eq!(reminder.level, ReminderLevel::FirstReminder);
301        assert_eq!(reminder.delivery_method, DeliveryMethod::Email);
302    }
303
304    #[test]
305    fn test_create_reminder_too_early() {
306        let org_id = Uuid::new_v4();
307        let expense_id = Uuid::new_v4();
308        let owner_id = Uuid::new_v4();
309        let due_date = Utc::now() - chrono::Duration::days(10);
310
311        let reminder = PaymentReminder::new(
312            org_id,
313            expense_id,
314            owner_id,
315            ReminderLevel::FirstReminder,
316            100.0,
317            due_date,
318            10, // Moins de 15 jours
319        );
320
321        assert!(reminder.is_err());
322        assert!(reminder
323            .unwrap_err()
324            .contains("Cannot create first reminder before"));
325    }
326
327    #[test]
328    fn test_calculate_penalty() {
329        // 100€, 30 jours de retard, taux 8% annuel
330        // Pénalité = 100 * 0.08 * (30/365) = 0.66€
331        let penalty = PaymentReminder::calculate_penalty(100.0, 30);
332        assert!((penalty - 0.66).abs() < 0.01);
333
334        // 1000€, 365 jours de retard (1 an)
335        // Pénalité = 1000 * 0.08 * 1 = 80€
336        let penalty = PaymentReminder::calculate_penalty(1000.0, 365);
337        assert!((penalty - 80.0).abs() < 0.01);
338    }
339
340    #[test]
341    fn test_mark_as_sent() {
342        let org_id = Uuid::new_v4();
343        let expense_id = Uuid::new_v4();
344        let owner_id = Uuid::new_v4();
345        let due_date = Utc::now() - chrono::Duration::days(20);
346
347        let mut reminder = PaymentReminder::new(
348            org_id,
349            expense_id,
350            owner_id,
351            ReminderLevel::FirstReminder,
352            100.0,
353            due_date,
354            20,
355        )
356        .unwrap();
357
358        let result = reminder.mark_as_sent(Some("/path/to/pdf".to_string()));
359        assert!(result.is_ok());
360        assert_eq!(reminder.status, ReminderStatus::Sent);
361        assert!(reminder.sent_date.is_some());
362        assert_eq!(reminder.pdf_path, Some("/path/to/pdf".to_string()));
363    }
364
365    #[test]
366    fn test_escalate() {
367        let org_id = Uuid::new_v4();
368        let expense_id = Uuid::new_v4();
369        let owner_id = Uuid::new_v4();
370        let due_date = Utc::now() - chrono::Duration::days(20);
371
372        let mut reminder = PaymentReminder::new(
373            org_id,
374            expense_id,
375            owner_id,
376            ReminderLevel::FirstReminder,
377            100.0,
378            due_date,
379            20,
380        )
381        .unwrap();
382
383        reminder.mark_as_sent(None).unwrap();
384
385        let next_level = reminder.escalate().unwrap();
386        assert_eq!(next_level, Some(ReminderLevel::SecondReminder));
387        assert_eq!(reminder.status, ReminderStatus::Escalated);
388    }
389
390    #[test]
391    fn test_reminder_level_days() {
392        assert_eq!(ReminderLevel::FirstReminder.days_after_due_date(), 15);
393        assert_eq!(ReminderLevel::SecondReminder.days_after_due_date(), 30);
394        assert_eq!(ReminderLevel::FormalNotice.days_after_due_date(), 60);
395    }
396
397    #[test]
398    fn test_needs_escalation() {
399        let org_id = Uuid::new_v4();
400        let expense_id = Uuid::new_v4();
401        let owner_id = Uuid::new_v4();
402        let due_date = Utc::now() - chrono::Duration::days(20);
403
404        let mut reminder = PaymentReminder::new(
405            org_id,
406            expense_id,
407            owner_id,
408            ReminderLevel::FirstReminder,
409            100.0,
410            due_date,
411            20,
412        )
413        .unwrap();
414
415        // Pas d'escalade si pas envoyé
416        assert!(!reminder.needs_escalation(Utc::now()));
417
418        // Marquer comme envoyé
419        reminder.mark_as_sent(None).unwrap();
420
421        // Pas d'escalade immédiatement après envoi
422        assert!(!reminder.needs_escalation(Utc::now()));
423
424        // Escalade nécessaire après 15 jours
425        let future_date = Utc::now() + chrono::Duration::days(16);
426        assert!(reminder.needs_escalation(future_date));
427    }
428
429    #[test]
430    fn test_recalculate_penalties() {
431        let org_id = Uuid::new_v4();
432        let expense_id = Uuid::new_v4();
433        let owner_id = Uuid::new_v4();
434        let due_date = Utc::now() - chrono::Duration::days(20);
435
436        let mut reminder = PaymentReminder::new(
437            org_id,
438            expense_id,
439            owner_id,
440            ReminderLevel::FirstReminder,
441            100.0,
442            due_date,
443            20,
444        )
445        .unwrap();
446
447        let initial_penalty = reminder.penalty_amount;
448
449        // Recalculer avec plus de jours de retard
450        reminder.recalculate_penalties(40);
451
452        assert_eq!(reminder.days_overdue, 40);
453        assert!(reminder.penalty_amount > initial_penalty);
454        assert_eq!(
455            reminder.total_amount,
456            reminder.amount_owed + reminder.penalty_amount
457        );
458    }
459
460    #[test]
461    fn test_formal_notice_uses_registered_letter() {
462        let org_id = Uuid::new_v4();
463        let expense_id = Uuid::new_v4();
464        let owner_id = Uuid::new_v4();
465        let due_date = Utc::now() - chrono::Duration::days(70);
466
467        let reminder = PaymentReminder::new(
468            org_id,
469            expense_id,
470            owner_id,
471            ReminderLevel::FormalNotice,
472            100.0,
473            due_date,
474            70,
475        )
476        .unwrap();
477
478        assert_eq!(reminder.delivery_method, DeliveryMethod::RegisteredLetter);
479    }
480}