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