Skip to main content

koprogo_api/domain/comptabilite/
payment_reminder.rs

1use chrono::{DateTime, Utc};
2use rust_decimal::{Decimal, RoundingStrategy};
3use rust_decimal_macros::dec;
4use serde::{Deserialize, Serialize};
5use uuid::Uuid;
6
7/// Niveau de relance de paiement
8#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, utoipa::ToSchema)]
9pub enum ReminderLevel {
10    FirstReminder,  // J+15 - Rappel aimable
11    SecondReminder, // J+30 - Relance ferme
12    FormalNotice,   // J+60 - Mise en demeure légale
13}
14
15impl ReminderLevel {
16    /// Nombre de jours après la date d'échéance pour chaque niveau
17    pub fn days_after_due_date(&self) -> i64 {
18        match self {
19            ReminderLevel::FirstReminder => 15,
20            ReminderLevel::SecondReminder => 30,
21            ReminderLevel::FormalNotice => 60,
22        }
23    }
24
25    /// Prochain niveau de relance (None si dernier niveau atteint)
26    pub fn next_level(&self) -> Option<ReminderLevel> {
27        match self {
28            ReminderLevel::FirstReminder => Some(ReminderLevel::SecondReminder),
29            ReminderLevel::SecondReminder => Some(ReminderLevel::FormalNotice),
30            ReminderLevel::FormalNotice => None, // Dernier niveau - passer à huissier
31        }
32    }
33
34    /// Ton du message pour chaque niveau
35    pub fn tone(&self) -> &'static str {
36        match self {
37            ReminderLevel::FirstReminder => "aimable",
38            ReminderLevel::SecondReminder => "ferme",
39            ReminderLevel::FormalNotice => "juridique",
40        }
41    }
42}
43
44/// Statut d'une relance
45#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, utoipa::ToSchema)]
46pub enum ReminderStatus {
47    Pending,   // En attente d'envoi
48    Sent,      // Envoyée
49    Opened,    // Email ouvert par le destinataire
50    Paid,      // Paiement reçu après relance
51    Escalated, // Escaladé au niveau supérieur
52    Cancelled, // Annulé (paiement reçu avant envoi)
53}
54
55/// Méthode d'envoi de la relance
56#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, utoipa::ToSchema)]
57pub enum DeliveryMethod {
58    Email,
59    RegisteredLetter, // Lettre recommandée
60    Bailiff,          // Huissier de justice
61}
62
63/// Représente une relance de paiement pour charges impayées
64#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
65pub struct PaymentReminder {
66    pub id: Uuid,
67
68    /// L'ACP au profit de laquelle la somme est réclamée.
69    ///
70    /// Art. 3.86 § 3 : « Le syndic peut prendre toutes les mesures judiciaires
71    /// et extrajudiciaires pour la récupération des charges ». Il recouvre
72    /// **pour** l'association ; la créance et les pénalités lui reviennent.
73    /// Cf. ADR-0045.
74    pub acp_id: Uuid,
75
76    /// Le syndic qui a émis la relance, conservé comme trace d'auteur.
77    pub organization_id: Uuid,
78    pub expense_id: Uuid,
79    pub owner_id: Uuid,
80    pub level: ReminderLevel,
81    pub status: ReminderStatus,
82    /// Montant dû (en euros). `Decimal` — ADR-0007/0008 : montant opposable.
83    pub amount_owed: Decimal,
84    /// Pénalités de retard (taux légal civil belge, 4,5 % annuel en 2026).
85    pub penalty_amount: Decimal,
86    /// Montant total (`amount_owed + penalty_amount`). L'égalité est garantie
87    /// par un `CHECK` en base, exact depuis le passage en `NUMERIC`.
88    pub total_amount: Decimal,
89    pub due_date: DateTime<Utc>, // Date d'échéance originale de la charge
90    pub days_overdue: i64,       // Nombre de jours de retard
91    pub delivery_method: DeliveryMethod,
92    pub sent_date: Option<DateTime<Utc>>,
93    pub opened_date: Option<DateTime<Utc>>,
94    pub pdf_path: Option<String>, // Chemin vers le PDF de la lettre
95    pub tracking_number: Option<String>, // Numéro de suivi (lettre recommandée)
96    pub notes: Option<String>,
97    pub created_at: DateTime<Utc>,
98    pub updated_at: DateTime<Utc>,
99}
100
101impl PaymentReminder {
102    /// Taux légal civil de pénalité de retard en Belgique (Moniteur belge)
103    /// Ce taux est publié annuellement par Arrêté Royal.
104    /// 2024: 5.25%, 2025: 4.0%, 2026: 4.5%
105    /// A mettre à jour chaque année selon publication au Moniteur belge.
106    pub const BELGIAN_PENALTY_RATE: Decimal = dec!(0.045);
107
108    /// Nombre de jours de l'année servant de base au prorata du taux annuel.
109    const DAYS_PER_YEAR: Decimal = dec!(365);
110
111    /// Crée une nouvelle relance de paiement
112    #[allow(clippy::too_many_arguments)]
113    pub fn new(
114        acp_id: Uuid,
115        organization_id: Uuid,
116        expense_id: Uuid,
117        owner_id: Uuid,
118        level: ReminderLevel,
119        amount_owed: Decimal,
120        due_date: DateTime<Utc>,
121        days_overdue: i64,
122    ) -> Result<Self, String> {
123        // Validation des business rules
124        if amount_owed <= Decimal::ZERO {
125            return Err("Amount owed must be greater than 0".to_string());
126        }
127
128        // Borne au centime : reprise de l'invariant que portait
129        // `#[validate(range(min = 0.01))]` côté DTO avant #661 — `validator` ne
130        // sachant pas borner un `Decimal`, la règle descend dans le domaine
131        // plutôt que de disparaître. Une relance pour moins d'un centime n'a
132        // aucun sens (frais d'envoi, lettre recommandée, huissier).
133        if amount_owed < dec!(0.01) {
134            return Err("Amount owed must be at least 0.01".to_string());
135        }
136
137        if days_overdue < 0 {
138            return Err("Days overdue cannot be negative".to_string());
139        }
140
141        // Vérifier que le niveau de relance correspond au nombre de jours de retard
142        let expected_days = level.days_after_due_date();
143        if days_overdue < expected_days {
144            return Err(format!(
145                "Cannot create {} reminder before {} days overdue (currently {} days)",
146                match level {
147                    ReminderLevel::FirstReminder => "first",
148                    ReminderLevel::SecondReminder => "second",
149                    ReminderLevel::FormalNotice => "formal notice",
150                },
151                expected_days,
152                days_overdue
153            ));
154        }
155
156        // Calculer les pénalités de retard (taux légal civil belge: 4.5% annuel en 2026)
157        let penalty_amount = Self::calculate_penalty(amount_owed, days_overdue);
158        let total_amount = amount_owed + penalty_amount;
159
160        // Déterminer la méthode de livraison selon le niveau
161        let delivery_method = match level {
162            ReminderLevel::FirstReminder => DeliveryMethod::Email,
163            ReminderLevel::SecondReminder => DeliveryMethod::Email,
164            ReminderLevel::FormalNotice => DeliveryMethod::RegisteredLetter,
165        };
166
167        let now = Utc::now();
168        Ok(Self {
169            id: Uuid::new_v4(),
170            acp_id,
171            organization_id,
172            expense_id,
173            owner_id,
174            level,
175            status: ReminderStatus::Pending,
176            amount_owed,
177            penalty_amount,
178            total_amount,
179            due_date,
180            days_overdue,
181            delivery_method,
182            sent_date: None,
183            opened_date: None,
184            pdf_path: None,
185            tracking_number: None,
186            notes: None,
187            created_at: now,
188            updated_at: now,
189        })
190    }
191
192    /// Calcule les pénalités de retard selon le taux légal civil belge
193    /// (4,5 % annuel en 2026).
194    ///
195    /// Formule : pénalité = montant × 0,045 × (jours_retard / 365), arrondie au
196    /// centime.
197    ///
198    /// Calcul en `Decimal` (suite #661) : c'est un montant **réclamé à un
199    /// copropriétaire**, et l'ancienne version arrondissait via
200    /// `(x * 100.0).round() / 100.0` en `f64` — un motif qui produit des écarts
201    /// d'un centime sur des valeurs parfaitement ordinaires, et qui n'arrondit
202    /// pas au plus proche de façon fiable près des demis.
203    ///
204    /// L'arrondi est **`MidpointAwayFromZero`** (arrondi commercial : 0,005 €
205    /// donne 0,01 €), et non le « banker's rounding » que `round_dp` applique
206    /// par défaut : sur une somme due, arrondir la moitié vers le pair n'a
207    /// aucun fondement, et diverge de ce que produit un tableur.
208    pub fn calculate_penalty(amount: Decimal, days_overdue: i64) -> Decimal {
209        if days_overdue <= 0 {
210            return Decimal::ZERO;
211        }
212        let yearly_penalty = amount * Self::BELGIAN_PENALTY_RATE;
213        let daily_penalty = yearly_penalty / Self::DAYS_PER_YEAR;
214        (daily_penalty * Decimal::from(days_overdue))
215            .round_dp_with_strategy(2, RoundingStrategy::MidpointAwayFromZero)
216    }
217
218    /// Marque la relance comme envoyée
219    pub fn mark_as_sent(&mut self, pdf_path: Option<String>) -> Result<(), String> {
220        if self.status != ReminderStatus::Pending {
221            return Err(format!(
222                "Cannot mark reminder as sent: current status is {:?}",
223                self.status
224            ));
225        }
226
227        self.status = ReminderStatus::Sent;
228        self.sent_date = Some(Utc::now());
229        self.pdf_path = pdf_path;
230        self.updated_at = Utc::now();
231        Ok(())
232    }
233
234    /// Marque la relance comme ouverte (email ouvert)
235    pub fn mark_as_opened(&mut self) -> Result<(), String> {
236        if self.status != ReminderStatus::Sent {
237            return Err(format!(
238                "Cannot mark reminder as opened: must be sent first (current status: {:?})",
239                self.status
240            ));
241        }
242
243        self.status = ReminderStatus::Opened;
244        self.opened_date = Some(Utc::now());
245        self.updated_at = Utc::now();
246        Ok(())
247    }
248
249    /// Marque la relance comme payée
250    pub fn mark_as_paid(&mut self) -> Result<(), String> {
251        match self.status {
252            ReminderStatus::Sent | ReminderStatus::Opened | ReminderStatus::Pending => {
253                self.status = ReminderStatus::Paid;
254                self.updated_at = Utc::now();
255                Ok(())
256            }
257            ReminderStatus::Paid => Err("Reminder is already marked as paid".to_string()),
258            ReminderStatus::Escalated => Err("Cannot mark escalated reminder as paid".to_string()),
259            ReminderStatus::Cancelled => Err("Cannot mark cancelled reminder as paid".to_string()),
260        }
261    }
262
263    /// Escalade vers le niveau de relance supérieur
264    /// Vérifie sans muter qu'une escalade est permise.
265    ///
266    /// Extrait de `escalate` pour que l'appelant puisse contrôler l'ordre :
267    /// la couche application doit refuser un dossier soldé AVANT de construire
268    /// le niveau suivant, sinon un dossier payé se voit reprocher son délai
269    /// plutôt que son statut — un message qui envoie chercher le problème au
270    /// mauvais endroit.
271    pub fn can_escalate(&self) -> Result<(), String> {
272        if self.status == ReminderStatus::Paid || self.status == ReminderStatus::Cancelled {
273            return Err(format!(
274                "Cannot escalate reminder with status {:?}",
275                self.status
276            ));
277        }
278        Ok(())
279    }
280
281    pub fn escalate(&mut self) -> Result<Option<ReminderLevel>, String> {
282        self.can_escalate()?;
283
284        self.status = ReminderStatus::Escalated;
285        self.updated_at = Utc::now();
286        Ok(self.level.next_level())
287    }
288
289    /// Annule la relance (paiement reçu avant envoi)
290    pub fn cancel(&mut self, reason: String) -> Result<(), String> {
291        if self.status == ReminderStatus::Sent || self.status == ReminderStatus::Opened {
292            return Err("Cannot cancel reminder that has already been sent".to_string());
293        }
294
295        self.status = ReminderStatus::Cancelled;
296        self.notes = Some(reason);
297        self.updated_at = Utc::now();
298        Ok(())
299    }
300
301    /// Ajoute un numéro de suivi (pour lettre recommandée)
302    pub fn set_tracking_number(&mut self, tracking_number: String) -> Result<(), String> {
303        if self.delivery_method != DeliveryMethod::RegisteredLetter {
304            return Err("Tracking number is only valid for registered letters".to_string());
305        }
306
307        self.tracking_number = Some(tracking_number);
308        self.updated_at = Utc::now();
309        Ok(())
310    }
311
312    /// Vérifie si la relance nécessite une escalade
313    pub fn needs_escalation(&self, current_date: DateTime<Utc>) -> bool {
314        if self.status != ReminderStatus::Sent && self.status != ReminderStatus::Opened {
315            return false;
316        }
317
318        if let Some(sent_date) = self.sent_date {
319            let days_since_sent = (current_date - sent_date).num_days();
320            // Escalader si pas de réponse après 15 jours
321            days_since_sent >= 15 && self.level.next_level().is_some()
322        } else {
323            false
324        }
325    }
326
327    /// Recalcule les pénalités en fonction du nombre de jours actuel
328    pub fn recalculate_penalties(&mut self, current_days_overdue: i64) {
329        self.days_overdue = current_days_overdue;
330        self.penalty_amount = Self::calculate_penalty(self.amount_owed, current_days_overdue);
331        self.total_amount = self.amount_owed + self.penalty_amount;
332        self.updated_at = Utc::now();
333    }
334}
335
336impl crate::domain::services::PieceDeGestion for PaymentReminder {
337    fn acp_id(&self) -> Uuid {
338        self.acp_id
339    }
340}
341
342#[cfg(test)]
343mod tests {
344    use super::*;
345
346    #[test]
347    fn test_create_payment_reminder_success() {
348        let org_id = Uuid::new_v4();
349        let expense_id = Uuid::new_v4();
350        let owner_id = Uuid::new_v4();
351        let due_date = Utc::now() - chrono::Duration::days(20);
352
353        let reminder = PaymentReminder::new(
354            Uuid::new_v4(), // acp_id
355            org_id,
356            expense_id,
357            owner_id,
358            ReminderLevel::FirstReminder,
359            dec!(100),
360            due_date,
361            20,
362        );
363
364        assert!(reminder.is_ok());
365        let reminder = reminder.unwrap();
366        assert_eq!(reminder.status, ReminderStatus::Pending);
367        assert_eq!(reminder.level, ReminderLevel::FirstReminder);
368        assert_eq!(reminder.delivery_method, DeliveryMethod::Email);
369    }
370
371    #[test]
372    fn test_create_reminder_too_early() {
373        let org_id = Uuid::new_v4();
374        let expense_id = Uuid::new_v4();
375        let owner_id = Uuid::new_v4();
376        let due_date = Utc::now() - chrono::Duration::days(10);
377
378        let reminder = PaymentReminder::new(
379            Uuid::new_v4(), // acp_id
380            org_id,
381            expense_id,
382            owner_id,
383            ReminderLevel::FirstReminder,
384            dec!(100),
385            due_date,
386            10, // Moins de 15 jours
387        );
388
389        assert!(reminder.is_err());
390        assert!(reminder
391            .unwrap_err()
392            .contains("Cannot create first reminder before"));
393    }
394
395    /// @happy — le calcul de pénalité au taux légal civil belge.
396    ///
397    /// Assertions en **égalité `Decimal` exacte** : les tolérances `< 0.01`
398    /// précédentes acceptaient un écart d'un centime sur une somme réclamée à
399    /// un copropriétaire, c'est-à-dire précisément l'erreur qu'un calcul en
400    /// `f64` produit.
401    #[test]
402    fn test_calculate_penalty() {
403        // 100€, 30 jours : 100 × 0,045 × (30/365) = 0,369863… → 0,37 €
404        assert_eq!(
405            PaymentReminder::calculate_penalty(dec!(100), 30),
406            dec!(0.37)
407        );
408
409        // 1000€, 365 jours (1 an pile) : 1000 × 0,045 = 45,00 € exactement.
410        assert_eq!(
411            PaymentReminder::calculate_penalty(dec!(1000), 365),
412            dec!(45.00)
413        );
414    }
415
416    /// @edge — aucune pénalité sans retard, et pas de valeur négative.
417    #[test]
418    fn test_calculate_penalty_no_overdue_days() {
419        assert_eq!(
420            PaymentReminder::calculate_penalty(dec!(100), 0),
421            Decimal::ZERO
422        );
423        assert_eq!(
424            PaymentReminder::calculate_penalty(dec!(100), -5),
425            Decimal::ZERO
426        );
427    }
428
429    /// @edge — l'arrondi au centime est **commercial** (`MidpointAwayFromZero`),
430    /// pas « banker's rounding ».
431    ///
432    /// Cas construit pour tomber exactement sur un demi-centime :
433    /// 8,11111…€ × 0,045 × (1/365) n'est pas un demi ; on utilise donc un
434    /// montant qui produit une fraction se terminant par 5 au millième.
435    /// 1000 € sur 81 jours → 1000 × 0,045 × 81/365 = 9,98630136…€ → 9,99 €.
436    #[test]
437    fn test_calculate_penalty_rounds_to_the_cent() {
438        assert_eq!(
439            PaymentReminder::calculate_penalty(dec!(1000), 81),
440            dec!(9.99)
441        );
442
443        // Le résultat n'a jamais plus de 2 décimales — invariant de la colonne
444        // NUMERIC(12,2) qui le stocke.
445        let p = PaymentReminder::calculate_penalty(dec!(1234.56), 137);
446        assert_eq!(p.scale(), 2, "la pénalité doit être arrondie au centime");
447    }
448
449    /// @security — un montant dû doit valoir au moins un centime. Cet invariant
450    /// était porté par `#[validate(range(min = 0.01))]` sur le DTO ; `validator`
451    /// ne sachant pas borner un `Decimal`, il vit désormais dans le domaine —
452    /// où il s'applique à TOUS les appelants, pas seulement à la route HTTP.
453    #[test]
454    fn test_reminder_rejects_amounts_below_one_cent() {
455        let due_date = Utc::now() - chrono::Duration::days(20);
456        for amount in [dec!(0), dec!(-100), dec!(0.009)] {
457            let r = PaymentReminder::new(
458                Uuid::new_v4(), // acp_id
459                Uuid::new_v4(),
460                Uuid::new_v4(),
461                Uuid::new_v4(),
462                ReminderLevel::FirstReminder,
463                amount,
464                due_date,
465                20,
466            );
467            assert!(r.is_err(), "montant {amount} aurait dû être rejeté");
468        }
469    }
470
471    /// @negative — le total reste exactement la somme de ses composantes.
472    /// C'est l'égalité que la contrainte `CHECK` vérifie en base : en
473    /// `DOUBLE PRECISION` elle pouvait échouer sur une ligne valide.
474    #[test]
475    fn test_total_amount_equals_owed_plus_penalty_exactly() {
476        let due_date = Utc::now() - chrono::Duration::days(90);
477        let r = PaymentReminder::new(
478            Uuid::new_v4(), // acp_id
479            Uuid::new_v4(),
480            Uuid::new_v4(),
481            Uuid::new_v4(),
482            ReminderLevel::FormalNotice,
483            dec!(1234.56),
484            due_date,
485            90,
486        )
487        .unwrap();
488
489        assert_eq!(r.total_amount, r.amount_owed + r.penalty_amount);
490        assert_eq!(r.amount_owed, dec!(1234.56));
491    }
492
493    #[test]
494    fn test_mark_as_sent() {
495        let org_id = Uuid::new_v4();
496        let expense_id = Uuid::new_v4();
497        let owner_id = Uuid::new_v4();
498        let due_date = Utc::now() - chrono::Duration::days(20);
499
500        let mut reminder = PaymentReminder::new(
501            Uuid::new_v4(), // acp_id
502            org_id,
503            expense_id,
504            owner_id,
505            ReminderLevel::FirstReminder,
506            dec!(100),
507            due_date,
508            20,
509        )
510        .unwrap();
511
512        let result = reminder.mark_as_sent(Some("/path/to/pdf".to_string()));
513        assert!(result.is_ok());
514        assert_eq!(reminder.status, ReminderStatus::Sent);
515        assert!(reminder.sent_date.is_some());
516        assert_eq!(reminder.pdf_path, Some("/path/to/pdf".to_string()));
517    }
518
519    #[test]
520    fn test_escalate() {
521        let org_id = Uuid::new_v4();
522        let expense_id = Uuid::new_v4();
523        let owner_id = Uuid::new_v4();
524        let due_date = Utc::now() - chrono::Duration::days(20);
525
526        let mut reminder = PaymentReminder::new(
527            Uuid::new_v4(), // acp_id
528            org_id,
529            expense_id,
530            owner_id,
531            ReminderLevel::FirstReminder,
532            dec!(100),
533            due_date,
534            20,
535        )
536        .unwrap();
537
538        reminder.mark_as_sent(None).unwrap();
539
540        let next_level = reminder.escalate().unwrap();
541        assert_eq!(next_level, Some(ReminderLevel::SecondReminder));
542        assert_eq!(reminder.status, ReminderStatus::Escalated);
543    }
544
545    #[test]
546    fn test_reminder_level_days() {
547        assert_eq!(ReminderLevel::FirstReminder.days_after_due_date(), 15);
548        assert_eq!(ReminderLevel::SecondReminder.days_after_due_date(), 30);
549        assert_eq!(ReminderLevel::FormalNotice.days_after_due_date(), 60);
550    }
551
552    #[test]
553    fn test_needs_escalation() {
554        let org_id = Uuid::new_v4();
555        let expense_id = Uuid::new_v4();
556        let owner_id = Uuid::new_v4();
557        let due_date = Utc::now() - chrono::Duration::days(20);
558
559        let mut reminder = PaymentReminder::new(
560            Uuid::new_v4(), // acp_id
561            org_id,
562            expense_id,
563            owner_id,
564            ReminderLevel::FirstReminder,
565            dec!(100),
566            due_date,
567            20,
568        )
569        .unwrap();
570
571        // Pas d'escalade si pas envoyé
572        assert!(!reminder.needs_escalation(Utc::now()));
573
574        // Marquer comme envoyé
575        reminder.mark_as_sent(None).unwrap();
576
577        // Pas d'escalade immédiatement après envoi
578        assert!(!reminder.needs_escalation(Utc::now()));
579
580        // Escalade nécessaire après 15 jours
581        let future_date = Utc::now() + chrono::Duration::days(16);
582        assert!(reminder.needs_escalation(future_date));
583    }
584
585    #[test]
586    fn test_recalculate_penalties() {
587        let org_id = Uuid::new_v4();
588        let expense_id = Uuid::new_v4();
589        let owner_id = Uuid::new_v4();
590        let due_date = Utc::now() - chrono::Duration::days(20);
591
592        let mut reminder = PaymentReminder::new(
593            Uuid::new_v4(), // acp_id
594            org_id,
595            expense_id,
596            owner_id,
597            ReminderLevel::FirstReminder,
598            dec!(100),
599            due_date,
600            20,
601        )
602        .unwrap();
603
604        let initial_penalty = reminder.penalty_amount;
605
606        // Recalculer avec plus de jours de retard
607        reminder.recalculate_penalties(40);
608
609        assert_eq!(reminder.days_overdue, 40);
610        assert!(reminder.penalty_amount > initial_penalty);
611        assert_eq!(
612            reminder.total_amount,
613            reminder.amount_owed + reminder.penalty_amount
614        );
615    }
616
617    #[test]
618    fn test_formal_notice_uses_registered_letter() {
619        let org_id = Uuid::new_v4();
620        let expense_id = Uuid::new_v4();
621        let owner_id = Uuid::new_v4();
622        let due_date = Utc::now() - chrono::Duration::days(70);
623
624        let reminder = PaymentReminder::new(
625            Uuid::new_v4(), // acp_id
626            org_id,
627            expense_id,
628            owner_id,
629            ReminderLevel::FormalNotice,
630            dec!(100),
631            due_date,
632            70,
633        )
634        .unwrap();
635
636        assert_eq!(reminder.delivery_method, DeliveryMethod::RegisteredLetter);
637    }
638}