Skip to main content

koprogo_api/application/use_cases/
payment_reminder_use_cases.rs

1use crate::application::dto::{
2    AddTrackingNumberDto, BulkCreateRemindersDto, BulkCreateRemindersResponseDto,
3    CancelReminderDto, CreatePaymentReminderDto, EscalateReminderDto, MarkReminderSentDto,
4    OverdueExpenseDto, PaymentRecoveryStatsDto, PaymentReminderResponseDto, ReminderLevelCountDto,
5    ReminderStatusCountDto,
6};
7use crate::application::error::AppError;
8use crate::application::ports::{ExpenseRepository, OwnerRepository, PaymentReminderRepository};
9use crate::domain::entities::{PaymentReminder, PaymentStatus, ReminderStatus};
10use chrono::{DateTime, Utc};
11use std::sync::Arc;
12use uuid::Uuid;
13
14pub struct PaymentReminderUseCases {
15    reminder_repository: Arc<dyn PaymentReminderRepository>,
16    expense_repository: Arc<dyn ExpenseRepository>,
17    owner_repository: Arc<dyn OwnerRepository>,
18}
19
20impl PaymentReminderUseCases {
21    pub fn new(
22        reminder_repository: Arc<dyn PaymentReminderRepository>,
23        expense_repository: Arc<dyn ExpenseRepository>,
24        owner_repository: Arc<dyn OwnerRepository>,
25    ) -> Self {
26        Self {
27            reminder_repository,
28            expense_repository,
29            owner_repository,
30        }
31    }
32
33    /// Helper to enrich a reminder DTO with owner information
34    async fn enrich_with_owner_info(
35        &self,
36        mut dto: PaymentReminderResponseDto,
37    ) -> Result<PaymentReminderResponseDto, AppError> {
38        let owner_id =
39            Uuid::parse_str(&dto.owner_id).map_err(|_| "Invalid owner_id format".to_string())?;
40
41        if let Ok(Some(owner)) = self.owner_repository.find_by_id(owner_id).await {
42            dto.owner_name = Some(owner.full_name());
43            dto.owner_email = Some(owner.email.clone());
44        }
45
46        Ok(dto)
47    }
48
49    /// Create a new payment reminder
50    pub async fn create_reminder(
51        &self,
52        dto: CreatePaymentReminderDto,
53    ) -> Result<PaymentReminderResponseDto, AppError> {
54        let organization_id = Uuid::parse_str(&dto.organization_id)
55            .map_err(|_| "Invalid organization_id format".to_string())?;
56        let expense_id = Uuid::parse_str(&dto.expense_id)
57            .map_err(|_| "Invalid expense_id format".to_string())?;
58        let owner_id =
59            Uuid::parse_str(&dto.owner_id).map_err(|_| "Invalid owner_id format".to_string())?;
60
61        let due_date = DateTime::parse_from_rfc3339(&dto.due_date)
62            .map_err(|_| "Invalid date format".to_string())?
63            .with_timezone(&Utc);
64
65        // Verify expense exists and is not paid
66        let expense = self
67            .expense_repository
68            .find_by_id(expense_id)
69            .await?
70            .ok_or_else(|| "Expense not found".to_string())?;
71
72        if expense.payment_status == PaymentStatus::Paid {
73            return Err(AppError::Conflict(
74                "Cannot create reminder for paid expense".to_string(),
75            ));
76        }
77
78        // Check if reminder already exists for this expense and owner at this level
79        let existing_reminders = self.reminder_repository.find_by_expense(expense_id).await?;
80
81        if existing_reminders.iter().any(|r| {
82            r.owner_id == owner_id
83                && r.level == dto.level
84                && r.status != ReminderStatus::Cancelled
85                && r.status != ReminderStatus::Paid
86        }) {
87            return Err(AppError::Conflict(format!(
88                "Active reminder already exists for this expense at {:?} level",
89                dto.level
90            )));
91        }
92
93        let reminder = PaymentReminder::new(
94            // La créance et ses pénalités reviennent à l'ACP ; le syndic ne
95            // fait que recouvrer pour elle (Art. 3.86 § 3, ADR-0045).
96            expense.acp_id,
97            organization_id,
98            expense_id,
99            owner_id,
100            dto.level,
101            dto.amount_owed,
102            due_date,
103            dto.days_overdue,
104        )?;
105
106        let created = self.reminder_repository.create(&reminder).await?;
107        Ok(created.into())
108    }
109
110    /// Get reminder by ID
111    pub async fn get_reminder(
112        &self,
113        id: Uuid,
114    ) -> Result<Option<PaymentReminderResponseDto>, AppError> {
115        let reminder = self.reminder_repository.find_by_id(id).await?;
116        Ok(reminder.map(|r| r.into()))
117    }
118
119    /// List all reminders for an expense
120    pub async fn list_by_expense(
121        &self,
122        expense_id: Uuid,
123    ) -> Result<Vec<PaymentReminderResponseDto>, AppError> {
124        let reminders = self.reminder_repository.find_by_expense(expense_id).await?;
125        Ok(reminders.into_iter().map(|r| r.into()).collect())
126    }
127
128    /// List all reminders for an owner
129    pub async fn list_by_owner(
130        &self,
131        owner_id: Uuid,
132    ) -> Result<Vec<PaymentReminderResponseDto>, AppError> {
133        let reminders = self.reminder_repository.find_by_owner(owner_id).await?;
134        Ok(reminders.into_iter().map(|r| r.into()).collect())
135    }
136
137    /// List all reminders for an organization
138    pub async fn list_by_organization(
139        &self,
140        organization_id: Uuid,
141    ) -> Result<Vec<PaymentReminderResponseDto>, AppError> {
142        let reminders = self
143            .reminder_repository
144            .find_by_organization(organization_id)
145            .await?;
146
147        // Enrich each reminder with owner information
148        let mut enriched_reminders = Vec::new();
149        for reminder in reminders {
150            let dto: PaymentReminderResponseDto = reminder.into();
151            let enriched = self.enrich_with_owner_info(dto).await?;
152            enriched_reminders.push(enriched);
153        }
154
155        Ok(enriched_reminders)
156    }
157
158    /// List active (non-paid, non-cancelled) reminders for an owner
159    pub async fn list_active_by_owner(
160        &self,
161        owner_id: Uuid,
162    ) -> Result<Vec<PaymentReminderResponseDto>, AppError> {
163        let reminders = self
164            .reminder_repository
165            .find_active_by_owner(owner_id)
166            .await?;
167        Ok(reminders.into_iter().map(|r| r.into()).collect())
168    }
169
170    /// Mark reminder as sent
171    pub async fn mark_as_sent(
172        &self,
173        id: Uuid,
174        dto: MarkReminderSentDto,
175    ) -> Result<PaymentReminderResponseDto, AppError> {
176        let mut reminder = self
177            .reminder_repository
178            .find_by_id(id)
179            .await?
180            .ok_or_else(|| "Reminder not found".to_string())?;
181
182        reminder.mark_as_sent(dto.pdf_path)?;
183
184        let updated = self.reminder_repository.update(&reminder).await?;
185        Ok(updated.into())
186    }
187
188    /// Mark reminder as opened (email opened)
189    pub async fn mark_as_opened(&self, id: Uuid) -> Result<PaymentReminderResponseDto, AppError> {
190        let mut reminder = self
191            .reminder_repository
192            .find_by_id(id)
193            .await?
194            .ok_or_else(|| "Reminder not found".to_string())?;
195
196        reminder.mark_as_opened()?;
197
198        let updated = self.reminder_repository.update(&reminder).await?;
199        Ok(updated.into())
200    }
201
202    /// Mark reminder as paid
203    pub async fn mark_as_paid(&self, id: Uuid) -> Result<PaymentReminderResponseDto, AppError> {
204        let mut reminder = self
205            .reminder_repository
206            .find_by_id(id)
207            .await?
208            .ok_or_else(|| "Reminder not found".to_string())?;
209
210        reminder.mark_as_paid()?;
211
212        let updated = self.reminder_repository.update(&reminder).await?;
213        Ok(updated.into())
214    }
215
216    /// Cancel a reminder
217    pub async fn cancel_reminder(
218        &self,
219        id: Uuid,
220        dto: CancelReminderDto,
221    ) -> Result<PaymentReminderResponseDto, AppError> {
222        let mut reminder = self
223            .reminder_repository
224            .find_by_id(id)
225            .await?
226            .ok_or_else(|| "Reminder not found".to_string())?;
227
228        reminder.cancel(dto.reason)?;
229
230        let updated = self.reminder_repository.update(&reminder).await?;
231        Ok(updated.into())
232    }
233
234    /// Escalate a reminder to next level
235    pub async fn escalate_reminder(
236        &self,
237        id: Uuid,
238        _dto: EscalateReminderDto,
239    ) -> Result<Option<PaymentReminderResponseDto>, AppError> {
240        let mut reminder = self
241            .reminder_repository
242            .find_by_id(id)
243            .await?
244            .ok_or_else(|| "Reminder not found".to_string())?;
245
246        // Le statut est contrôlé D'ABORD : un dossier soldé ou annulé doit se
247        // voir reprocher son statut, pas le délai du niveau suivant.
248        reminder.can_escalate()?;
249
250        // Puis le niveau suivant est CONSTRUIT AVANT que l'escalade ne soit
251        // persistée.
252        //
253        // L'ordre inverse laissait un dossier à l'abandon : le statut
254        // `Escalated` était écrit, puis `PaymentReminder::new` pouvait échouer
255        // (« Cannot create second reminder before 30 days overdue »), et la
256        // relance restait marquée escaladée SANS successeur. Personne ne le
257        // voyait : `process_automatic_escalations`, appelé par cron, journalise
258        // l'erreur sur stderr et poursuit sa boucle.
259        //
260        // Le cas se produit dès que l'escalade est déclenchée plus tôt que le
261        // délai légal du niveau visé — 30 jours pour une relance ferme, 60 pour
262        // une mise en demeure.
263        let next_level = reminder.level.next_level();
264        let next_reminder = match next_level {
265            Some(level) => {
266                let days_overdue = (Utc::now() - reminder.due_date).num_days();
267                Some(PaymentReminder::new(
268                    // L'escalade reste due à la même ACP que la relance dont
269                    // elle procède.
270                    reminder.acp_id,
271                    reminder.organization_id,
272                    reminder.expense_id,
273                    reminder.owner_id,
274                    level,
275                    reminder.amount_owed,
276                    reminder.due_date,
277                    days_overdue,
278                )?)
279            }
280            None => None,
281        };
282
283        // Refuse l'escalade d'un dossier soldé ou annulé, et marque le statut.
284        reminder.escalate()?;
285        let updated = self.reminder_repository.update(&reminder).await?;
286
287        if let Some(next_reminder) = next_reminder {
288            let created = self.reminder_repository.create(&next_reminder).await?;
289            return Ok(Some(created.into()));
290        }
291
292        Ok(Some(updated.into()))
293    }
294
295    /// Add tracking number to a reminder (for registered letters)
296    pub async fn add_tracking_number(
297        &self,
298        id: Uuid,
299        dto: AddTrackingNumberDto,
300    ) -> Result<PaymentReminderResponseDto, AppError> {
301        let mut reminder = self
302            .reminder_repository
303            .find_by_id(id)
304            .await?
305            .ok_or_else(|| "Reminder not found".to_string())?;
306
307        reminder.set_tracking_number(dto.tracking_number)?;
308
309        let updated = self.reminder_repository.update(&reminder).await?;
310        Ok(updated.into())
311    }
312
313    /// Find all pending reminders (to be sent)
314    pub async fn find_pending_reminders(
315        &self,
316    ) -> Result<Vec<PaymentReminderResponseDto>, AppError> {
317        let reminders = self.reminder_repository.find_pending_reminders().await?;
318        Ok(reminders.into_iter().map(|r| r.into()).collect())
319    }
320
321    /// Find reminders needing escalation (sent >15 days ago)
322    pub async fn find_reminders_needing_escalation(
323        &self,
324    ) -> Result<Vec<PaymentReminderResponseDto>, AppError> {
325        let cutoff_date = Utc::now() - chrono::Duration::days(15);
326        let reminders = self
327            .reminder_repository
328            .find_reminders_needing_escalation(cutoff_date)
329            .await?;
330
331        // Filter to only those that actually need escalation
332        let needs_escalation: Vec<PaymentReminder> = reminders
333            .into_iter()
334            .filter(|r| r.needs_escalation(Utc::now()))
335            .collect();
336
337        Ok(needs_escalation.into_iter().map(|r| r.into()).collect())
338    }
339
340    /// Get payment recovery statistics for an organization
341    pub async fn get_recovery_stats(
342        &self,
343        organization_id: Uuid,
344    ) -> Result<PaymentRecoveryStatsDto, AppError> {
345        let (total_owed, total_penalties, level_counts) = self
346            .reminder_repository
347            .get_dashboard_stats(organization_id)
348            .await?;
349
350        let status_counts = self
351            .reminder_repository
352            .count_by_status(organization_id)
353            .await?;
354
355        Ok(PaymentRecoveryStatsDto {
356            total_owed,
357            total_penalties,
358            reminder_counts: level_counts
359                .into_iter()
360                .map(|(level, count)| ReminderLevelCountDto { level, count })
361                .collect(),
362            status_counts: status_counts
363                .into_iter()
364                .map(|(status, count)| ReminderStatusCountDto { status, count })
365                .collect(),
366        })
367    }
368
369    /// Find overdue expenses without reminders (for automated detection)
370    pub async fn find_overdue_expenses_without_reminders(
371        &self,
372        organization_id: Uuid,
373        min_days_overdue: i64,
374    ) -> Result<Vec<OverdueExpenseDto>, AppError> {
375        let results = self
376            .reminder_repository
377            .find_overdue_expenses_without_reminders(organization_id, min_days_overdue)
378            .await?;
379
380        Ok(results
381            .into_iter()
382            .map(|(expense_id, owner_id, days_overdue, amount)| {
383                OverdueExpenseDto::new(
384                    expense_id.to_string(),
385                    owner_id.to_string(),
386                    days_overdue,
387                    amount,
388                )
389            })
390            .collect())
391    }
392
393    /// Bulk create reminders for all overdue expenses
394    pub async fn bulk_create_reminders(
395        &self,
396        dto: BulkCreateRemindersDto,
397    ) -> Result<BulkCreateRemindersResponseDto, AppError> {
398        let organization_id = Uuid::parse_str(&dto.organization_id)
399            .map_err(|_| "Invalid organization_id format".to_string())?;
400
401        let overdue_list = self
402            .find_overdue_expenses_without_reminders(organization_id, dto.min_days_overdue)
403            .await?;
404
405        let mut created_count = 0;
406        let mut skipped_count = 0;
407        let mut errors = Vec::new();
408        let mut created_reminders = Vec::new();
409
410        for overdue in overdue_list {
411            let expense_id = Uuid::parse_str(&overdue.expense_id);
412            let owner_id = Uuid::parse_str(&overdue.owner_id);
413
414            if expense_id.is_err() || owner_id.is_err() {
415                errors.push(
416                    "Invalid UUID format for expense_id or owner_id in overdue item".to_string(),
417                );
418                skipped_count += 1;
419                continue;
420            }
421
422            let expense_id = expense_id.unwrap();
423            let owner_id = owner_id.unwrap();
424
425            // Get expense to get due date
426            let expense_result = self.expense_repository.find_by_id(expense_id).await;
427
428            match expense_result {
429                Ok(Some(expense)) => {
430                    let due_date = expense.expense_date;
431
432                    let create_dto = CreatePaymentReminderDto {
433                        organization_id: organization_id.to_string(),
434                        expense_id: expense_id.to_string(),
435                        owner_id: owner_id.to_string(),
436                        level: overdue.recommended_level,
437                        amount_owed: overdue.amount,
438                        due_date: due_date.to_rfc3339(),
439                        days_overdue: overdue.days_overdue,
440                    };
441
442                    match self.create_reminder(create_dto).await {
443                        Ok(reminder) => {
444                            created_count += 1;
445                            created_reminders.push(reminder);
446                        }
447                        Err(e) => {
448                            errors.push(format!(
449                                "Error creating reminder for expense {}: {}",
450                                expense_id, e
451                            ));
452                            skipped_count += 1;
453                        }
454                    }
455                }
456                Ok(None) => {
457                    errors.push(format!("Expense {} not found", expense_id));
458                    skipped_count += 1;
459                }
460                Err(e) => {
461                    errors.push(format!("Error fetching expense {}: {}", expense_id, e));
462                    skipped_count += 1;
463                }
464            }
465        }
466
467        Ok(BulkCreateRemindersResponseDto {
468            created_count,
469            skipped_count,
470            errors,
471            created_reminders,
472        })
473    }
474
475    /// Process automatic escalations (called by cron job)
476    pub async fn process_automatic_escalations(&self) -> Result<i32, AppError> {
477        let reminders = self.find_reminders_needing_escalation().await?;
478        let mut escalated_count = 0;
479
480        for reminder_dto in reminders {
481            let id =
482                Uuid::parse_str(&reminder_dto.id).map_err(|_| "Invalid reminder ID".to_string())?;
483
484            match self
485                .escalate_reminder(id, EscalateReminderDto { reason: None })
486                .await
487            {
488                Ok(_) => escalated_count += 1,
489                Err(e) => {
490                    eprintln!("Error escalating reminder {}: {}", id, e);
491                }
492            }
493        }
494
495        Ok(escalated_count)
496    }
497
498    /// Recalculate penalties for all active reminders (called periodically)
499    pub async fn recalculate_all_penalties(&self, organization_id: Uuid) -> Result<i32, AppError> {
500        let reminders = self
501            .reminder_repository
502            .find_by_organization_and_status(organization_id, ReminderStatus::Sent)
503            .await?;
504
505        let mut updated_count = 0;
506
507        for mut reminder in reminders {
508            let current_days = (Utc::now() - reminder.due_date).num_days();
509            if current_days != reminder.days_overdue {
510                reminder.recalculate_penalties(current_days);
511                self.reminder_repository.update(&reminder).await?;
512                updated_count += 1;
513            }
514        }
515
516        Ok(updated_count)
517    }
518
519    /// Delete a reminder
520    pub async fn delete_reminder(&self, id: Uuid) -> Result<bool, AppError> {
521        self.reminder_repository.delete(id).await
522    }
523}
524
525#[cfg(test)]
526mod tests {
527    use super::*;
528    use crate::application::ports::PaymentReminderRepository;
529    use crate::domain::entities::ReminderLevel;
530    use async_trait::async_trait;
531    use rust_decimal::Decimal;
532    use std::collections::HashMap;
533    use std::sync::Mutex;
534
535    // ── Dépôts simulés ───────────────────────────────────────────────────
536    //
537    // Ce mock existait déjà, complet, marqué `#[allow(dead_code)]` et suivi
538    // d'un `// TODO: Add more comprehensive tests`. L'échafaudage avait été
539    // construit puis jamais utilisé — et le `allow(dead_code)` faisait taire
540    // l'avertissement qui l'aurait signalé. 670 lignes de logique de
541    // recouvrement (pénalités, escalade à 4 niveaux, création en masse) sans
542    // un seul test, sur le module même que visent les constats F10 et F18 du
543    // rapport du 2026-09-01.
544    struct MockPaymentReminderRepository {
545        reminders: Mutex<HashMap<Uuid, PaymentReminder>>,
546    }
547
548    impl MockPaymentReminderRepository {
549        fn new() -> Self {
550            Self {
551                reminders: Mutex::new(HashMap::new()),
552            }
553        }
554
555        fn get(&self, id: Uuid) -> Option<PaymentReminder> {
556            self.reminders.lock().unwrap().get(&id).cloned()
557        }
558
559        fn count(&self) -> usize {
560            self.reminders.lock().unwrap().len()
561        }
562    }
563
564    #[async_trait]
565    impl PaymentReminderRepository for MockPaymentReminderRepository {
566        async fn create(&self, reminder: &PaymentReminder) -> Result<PaymentReminder, AppError> {
567            let mut reminders = self.reminders.lock().unwrap();
568            reminders.insert(reminder.id, reminder.clone());
569            Ok(reminder.clone())
570        }
571
572        async fn find_by_id(&self, id: Uuid) -> Result<Option<PaymentReminder>, AppError> {
573            let reminders = self.reminders.lock().unwrap();
574            Ok(reminders.get(&id).cloned())
575        }
576
577        async fn find_by_expense(
578            &self,
579            expense_id: Uuid,
580        ) -> Result<Vec<PaymentReminder>, AppError> {
581            let reminders = self.reminders.lock().unwrap();
582            Ok(reminders
583                .values()
584                .filter(|r| r.expense_id == expense_id)
585                .cloned()
586                .collect())
587        }
588
589        async fn find_by_owner(&self, owner_id: Uuid) -> Result<Vec<PaymentReminder>, AppError> {
590            let reminders = self.reminders.lock().unwrap();
591            Ok(reminders
592                .values()
593                .filter(|r| r.owner_id == owner_id)
594                .cloned()
595                .collect())
596        }
597
598        async fn find_by_organization(
599            &self,
600            organization_id: Uuid,
601        ) -> Result<Vec<PaymentReminder>, AppError> {
602            let reminders = self.reminders.lock().unwrap();
603            Ok(reminders
604                .values()
605                .filter(|r| r.organization_id == organization_id)
606                .cloned()
607                .collect())
608        }
609
610        async fn find_by_status(
611            &self,
612            status: ReminderStatus,
613        ) -> Result<Vec<PaymentReminder>, AppError> {
614            let reminders = self.reminders.lock().unwrap();
615            Ok(reminders
616                .values()
617                .filter(|r| r.status == status)
618                .cloned()
619                .collect())
620        }
621
622        async fn find_by_organization_and_status(
623            &self,
624            organization_id: Uuid,
625            status: ReminderStatus,
626        ) -> Result<Vec<PaymentReminder>, AppError> {
627            let reminders = self.reminders.lock().unwrap();
628            Ok(reminders
629                .values()
630                .filter(|r| r.organization_id == organization_id && r.status == status)
631                .cloned()
632                .collect())
633        }
634
635        async fn find_pending_reminders(&self) -> Result<Vec<PaymentReminder>, AppError> {
636            self.find_by_status(ReminderStatus::Pending).await
637        }
638
639        async fn find_reminders_needing_escalation(
640            &self,
641            _cutoff_date: DateTime<Utc>,
642        ) -> Result<Vec<PaymentReminder>, AppError> {
643            Ok(vec![])
644        }
645
646        async fn find_latest_by_expense(
647            &self,
648            _expense_id: Uuid,
649        ) -> Result<Option<PaymentReminder>, AppError> {
650            Ok(None)
651        }
652
653        async fn find_active_by_owner(
654            &self,
655            _owner_id: Uuid,
656        ) -> Result<Vec<PaymentReminder>, AppError> {
657            Ok(vec![])
658        }
659
660        /// Compte REELLEMENT, au lieu de renvoyer un vecteur vide.
661        ///
662        /// Le stub d'origine rendait tout test de statistiques vide de sens :
663        /// il aurait passé quelle que soit la logique testée.
664        async fn count_by_status(
665            &self,
666            organization_id: Uuid,
667        ) -> Result<Vec<(ReminderStatus, i64)>, AppError> {
668            let reminders = self.reminders.lock().unwrap();
669            let mut comptes: HashMap<String, (ReminderStatus, i64)> = HashMap::new();
670            for r in reminders
671                .values()
672                .filter(|r| r.organization_id == organization_id)
673            {
674                let cle = format!("{:?}", r.status);
675                comptes
676                    .entry(cle)
677                    .and_modify(|(_, n)| *n += 1)
678                    .or_insert((r.status.clone(), 1));
679            }
680            Ok(comptes.into_values().collect())
681        }
682
683        async fn get_total_owed_by_organization(
684            &self,
685            _organization_id: Uuid,
686        ) -> Result<Decimal, AppError> {
687            Ok(Decimal::ZERO)
688        }
689
690        async fn get_total_penalties_by_organization(
691            &self,
692            _organization_id: Uuid,
693        ) -> Result<Decimal, AppError> {
694            Ok(Decimal::ZERO)
695        }
696
697        async fn find_overdue_expenses_without_reminders(
698            &self,
699            _organization_id: Uuid,
700            _min_days_overdue: i64,
701        ) -> Result<Vec<(Uuid, Uuid, i64, Decimal)>, AppError> {
702            Ok(vec![])
703        }
704
705        async fn update(&self, reminder: &PaymentReminder) -> Result<PaymentReminder, AppError> {
706            let mut reminders = self.reminders.lock().unwrap();
707            reminders.insert(reminder.id, reminder.clone());
708            Ok(reminder.clone())
709        }
710
711        async fn delete(&self, id: Uuid) -> Result<bool, AppError> {
712            let mut reminders = self.reminders.lock().unwrap();
713            Ok(reminders.remove(&id).is_some())
714        }
715
716        async fn get_dashboard_stats(
717            &self,
718            organization_id: Uuid,
719        ) -> Result<(Decimal, Decimal, Vec<(ReminderLevel, i64)>), AppError> {
720            let reminders = self.reminders.lock().unwrap();
721            let actives: Vec<_> = reminders
722                .values()
723                .filter(|r| {
724                    r.organization_id == organization_id
725                        && r.status != ReminderStatus::Paid
726                        && r.status != ReminderStatus::Cancelled
727                })
728                .collect();
729            let total_owed = actives.iter().map(|r| r.amount_owed).sum();
730            let total_penalties = actives.iter().map(|r| r.penalty_amount).sum();
731            let mut comptes: HashMap<String, (ReminderLevel, i64)> = HashMap::new();
732            for r in &actives {
733                let cle = format!("{:?}", r.level);
734                comptes
735                    .entry(cle)
736                    .and_modify(|(_, n)| *n += 1)
737                    .or_insert((r.level.clone(), 1));
738            }
739            Ok((total_owed, total_penalties, comptes.into_values().collect()))
740        }
741    }
742
743    // ── Dépôts simulés : dépenses et propriétaires ───────────────────────
744
745    struct MockExpenseRepo {
746        expenses: Mutex<HashMap<Uuid, crate::domain::entities::Expense>>,
747    }
748
749    impl MockExpenseRepo {
750        fn new() -> Self {
751            Self {
752                expenses: Mutex::new(HashMap::new()),
753            }
754        }
755
756        fn with(expense: crate::domain::entities::Expense) -> Self {
757            let mut m = HashMap::new();
758            m.insert(expense.id, expense);
759            Self {
760                expenses: Mutex::new(m),
761            }
762        }
763    }
764
765    #[async_trait]
766    impl crate::application::ports::ExpenseRepository for MockExpenseRepo {
767        async fn enregistrer_lignes_de_facture(
768            &self,
769            _expense_id: Uuid,
770            _lignes: &[crate::application::ports::expense_repository::LigneDeFacture],
771        ) -> Result<(), String> {
772            // Mock : rien à enregistrer. Le port n'offre pas d'implémentation
773            // par défaut, précisément pour que ce choix soit écrit ici plutôt
774            // que subi partout.
775            Ok(())
776        }
777
778        async fn create(
779            &self,
780            e: &crate::domain::entities::Expense,
781        ) -> Result<crate::domain::entities::Expense, String> {
782            self.expenses.lock().unwrap().insert(e.id, e.clone());
783            Ok(e.clone())
784        }
785        async fn find_by_id(
786            &self,
787            id: Uuid,
788        ) -> Result<Option<crate::domain::entities::Expense>, String> {
789            Ok(self.expenses.lock().unwrap().get(&id).cloned())
790        }
791        async fn find_by_building(
792            &self,
793            building_id: Uuid,
794        ) -> Result<Vec<crate::domain::entities::Expense>, String> {
795            Ok(self
796                .expenses
797                .lock()
798                .unwrap()
799                .values()
800                .filter(|e| e.building_id == building_id)
801                .cloned()
802                .collect())
803        }
804        async fn find_all_paginated(
805            &self,
806            _p: &crate::application::dto::PageRequest,
807            _f: &crate::application::dto::ExpenseFilters,
808        ) -> Result<(Vec<crate::domain::entities::Expense>, i64), String> {
809            Ok((vec![], 0))
810        }
811        async fn update(
812            &self,
813            e: &crate::domain::entities::Expense,
814        ) -> Result<crate::domain::entities::Expense, String> {
815            self.expenses.lock().unwrap().insert(e.id, e.clone());
816            Ok(e.clone())
817        }
818        async fn delete(&self, id: Uuid) -> Result<bool, String> {
819            Ok(self.expenses.lock().unwrap().remove(&id).is_some())
820        }
821    }
822
823    struct MockOwnerRepo {
824        owners: Mutex<HashMap<Uuid, crate::domain::entities::Owner>>,
825    }
826
827    impl MockOwnerRepo {
828        fn new() -> Self {
829            Self {
830                owners: Mutex::new(HashMap::new()),
831            }
832        }
833
834        fn with(owner: crate::domain::entities::Owner) -> Self {
835            let mut m = HashMap::new();
836            m.insert(owner.id, owner);
837            Self {
838                owners: Mutex::new(m),
839            }
840        }
841    }
842
843    #[async_trait]
844    impl crate::application::ports::OwnerRepository for MockOwnerRepo {
845        async fn create(
846            &self,
847            o: &crate::domain::entities::Owner,
848        ) -> Result<crate::domain::entities::Owner, String> {
849            self.owners.lock().unwrap().insert(o.id, o.clone());
850            Ok(o.clone())
851        }
852        async fn find_by_id(
853            &self,
854            id: Uuid,
855        ) -> Result<Option<crate::domain::entities::Owner>, String> {
856            Ok(self.owners.lock().unwrap().get(&id).cloned())
857        }
858        async fn find_by_user_id(
859            &self,
860            _id: Uuid,
861        ) -> Result<Option<crate::domain::entities::Owner>, String> {
862            Ok(None)
863        }
864        async fn find_by_user_id_and_organization(
865            &self,
866            _u: Uuid,
867            _o: Uuid,
868        ) -> Result<Option<crate::domain::entities::Owner>, String> {
869            Ok(None)
870        }
871        async fn find_by_email(
872            &self,
873            _e: &str,
874        ) -> Result<Option<crate::domain::entities::Owner>, String> {
875            Ok(None)
876        }
877        async fn find_all(&self) -> Result<Vec<crate::domain::entities::Owner>, String> {
878            Ok(self.owners.lock().unwrap().values().cloned().collect())
879        }
880        async fn find_all_paginated(
881            &self,
882            _p: &crate::application::dto::PageRequest,
883            _f: &crate::application::dto::OwnerFilters,
884        ) -> Result<(Vec<crate::domain::entities::Owner>, i64), String> {
885            Ok((vec![], 0))
886        }
887        async fn update(
888            &self,
889            o: &crate::domain::entities::Owner,
890        ) -> Result<crate::domain::entities::Owner, String> {
891            self.owners.lock().unwrap().insert(o.id, o.clone());
892            Ok(o.clone())
893        }
894        async fn delete(&self, id: Uuid) -> Result<bool, String> {
895            Ok(self.owners.lock().unwrap().remove(&id).is_some())
896        }
897        async fn set_user_link(&self, _o: Uuid, _u: Option<Uuid>) -> Result<bool, String> {
898            Ok(true)
899        }
900    }
901
902    // ── Fixtures ─────────────────────────────────────────────────────────
903
904    fn expense_impaye(org_id: Uuid, montant: Decimal) -> crate::domain::entities::Expense {
905        expense_impaye_de_lacp(Uuid::new_v4(), org_id, montant)
906    }
907
908    /// La même dépense, en nommant l'ACP à laquelle elle est imputée.
909    fn expense_impaye_de_lacp(
910        acp_id: Uuid,
911        org_id: Uuid,
912        montant: Decimal,
913    ) -> crate::domain::entities::Expense {
914        crate::domain::entities::Expense::new(
915            acp_id,
916            org_id,
917            Uuid::new_v4(),
918            crate::domain::entities::ExpenseCategory::Maintenance,
919            "Entretien ascenseur".to_string(),
920            montant,
921            Utc::now() - chrono::Duration::days(40),
922            Some("Kone SA".to_string()),
923            None,
924            Some("611002".to_string()),
925        )
926        .expect("dépense de test valide")
927    }
928
929    fn proprietaire(org_id: Uuid) -> crate::domain::entities::Owner {
930        crate::domain::entities::Owner::new(
931            org_id,
932            "Jean".to_string(),
933            "Peeters".to_string(),
934            "jean.peeters@example.com".to_string(),
935            None,
936            "Rue Test 1".to_string(),
937            "Bruxelles".to_string(),
938            "1000".to_string(),
939            "Belgique".to_string(),
940        )
941        .expect("propriétaire de test valide")
942    }
943
944    fn use_cases(
945        reminders: Arc<MockPaymentReminderRepository>,
946        expenses: Arc<MockExpenseRepo>,
947        owners: Arc<MockOwnerRepo>,
948    ) -> PaymentReminderUseCases {
949        PaymentReminderUseCases::new(reminders, expenses, owners)
950    }
951
952    fn create_dto(
953        org_id: Uuid,
954        expense_id: Uuid,
955        owner_id: Uuid,
956        level: ReminderLevel,
957        days_overdue: i64,
958    ) -> CreatePaymentReminderDto {
959        CreatePaymentReminderDto {
960            organization_id: org_id.to_string(),
961            expense_id: expense_id.to_string(),
962            owner_id: owner_id.to_string(),
963            level,
964            amount_owed: Decimal::from(2000),
965            due_date: (Utc::now() - chrono::Duration::days(days_overdue)).to_rfc3339(),
966            days_overdue,
967        }
968    }
969
970    // ── Tests ────────────────────────────────────────────────────────────
971
972    /// Art. 3.86 § 3 et ADR-0045 : la somme réclamée est due à l'ACP.
973    ///
974    /// « Le syndic peut prendre toutes les mesures judiciaires et
975    /// extrajudiciaires pour la récupération des charges » : il recouvre pour
976    /// elle. L'ACP se lit sur la dépense impayée, jamais sur l'appelant.
977    #[tokio::test]
978    async fn test_la_relance_reclame_au_profit_de_lacp_pas_du_syndic() {
979        let acp_creanciere = Uuid::new_v4();
980        let cabinet_qui_relance = Uuid::new_v4();
981        let depense =
982            expense_impaye_de_lacp(acp_creanciere, cabinet_qui_relance, Decimal::from(2000));
983        let prop = proprietaire(cabinet_qui_relance);
984        let (expense_id, owner_id) = (depense.id, prop.id);
985
986        let uc = use_cases(
987            Arc::new(MockPaymentReminderRepository::new()),
988            Arc::new(MockExpenseRepo::with(depense)),
989            Arc::new(MockOwnerRepo::with(prop)),
990        );
991
992        let relance = uc
993            .create_reminder(create_dto(
994                cabinet_qui_relance,
995                expense_id,
996                owner_id,
997                ReminderLevel::FirstReminder,
998                17,
999            ))
1000            .await
1001            .expect("création acceptée");
1002
1003        assert_eq!(
1004            relance.acp_id,
1005            acp_creanciere.to_string(),
1006            "la somme réclamée est due à l'ACP de la dépense impayée"
1007        );
1008        assert_eq!(
1009            relance.organization_id,
1010            cabinet_qui_relance.to_string(),
1011            "le syndic reste tracé comme celui qui relance, sans devenir créancier"
1012        );
1013    }
1014
1015    /// Le chemin nominal : une dépense impayée depuis 17 jours produit un
1016    /// premier rappel, avec sa pénalité calculée.
1017    #[tokio::test]
1018    async fn test_creation_relance_calcule_la_penalite() {
1019        let org_id = Uuid::new_v4();
1020        let depense = expense_impaye(org_id, Decimal::from(2000));
1021        let prop = proprietaire(org_id);
1022        let (expense_id, owner_id) = (depense.id, prop.id);
1023
1024        let reminders = Arc::new(MockPaymentReminderRepository::new());
1025        let uc = use_cases(
1026            reminders.clone(),
1027            Arc::new(MockExpenseRepo::with(depense)),
1028            Arc::new(MockOwnerRepo::with(prop)),
1029        );
1030
1031        let dto = create_dto(
1032            org_id,
1033            expense_id,
1034            owner_id,
1035            ReminderLevel::FirstReminder,
1036            17,
1037        );
1038        let cree = uc.create_reminder(dto).await.expect("création acceptée");
1039
1040        assert_eq!(reminders.count(), 1);
1041        // 2000 × 0,045 × 17/365 = 4,1917… → 4,19 €. C'est le calcul que le
1042        // rapport du 2026-09-01 disait « incohérent » : il l'est avec le taux
1043        // de 8 % qu'ANNONÇAIT l'interface, pas avec le taux de 4,5 % que le
1044        // domaine applique. Le texte de l'interface a été aligné sur le calcul.
1045        assert_eq!(cree.penalty_amount, rust_decimal_macros::dec!(4.19));
1046        assert_eq!(cree.total_amount, rust_decimal_macros::dec!(2004.19));
1047    }
1048
1049    /// @security — pas de relance sur une dépense déjà réglée.
1050    #[tokio::test]
1051    async fn test_relance_refusee_sur_depense_payee() {
1052        let org_id = Uuid::new_v4();
1053        let mut depense = expense_impaye(org_id, Decimal::from(2000));
1054        depense.payment_status = PaymentStatus::Paid;
1055        let prop = proprietaire(org_id);
1056        let (expense_id, owner_id) = (depense.id, prop.id);
1057
1058        let reminders = Arc::new(MockPaymentReminderRepository::new());
1059        let uc = use_cases(
1060            reminders.clone(),
1061            Arc::new(MockExpenseRepo::with(depense)),
1062            Arc::new(MockOwnerRepo::with(prop)),
1063        );
1064
1065        let err = uc
1066            .create_reminder(create_dto(
1067                org_id,
1068                expense_id,
1069                owner_id,
1070                ReminderLevel::FirstReminder,
1071                17,
1072            ))
1073            .await
1074            .expect_err("réclamer un montant déjà payé doit être refusé");
1075        assert!(format!("{err}").contains("paid"), "{err}");
1076        assert_eq!(reminders.count(), 0);
1077    }
1078
1079    /// @edge — pas deux relances actives au même niveau pour la même dépense.
1080    ///
1081    /// Le garde-fou qui empêche un copropriétaire de recevoir deux fois la
1082    /// même mise en demeure.
1083    #[tokio::test]
1084    async fn test_pas_de_doublon_de_relance_au_meme_niveau() {
1085        let org_id = Uuid::new_v4();
1086        let depense = expense_impaye(org_id, Decimal::from(2000));
1087        let prop = proprietaire(org_id);
1088        let (expense_id, owner_id) = (depense.id, prop.id);
1089
1090        let reminders = Arc::new(MockPaymentReminderRepository::new());
1091        let uc = use_cases(
1092            reminders.clone(),
1093            Arc::new(MockExpenseRepo::with(depense)),
1094            Arc::new(MockOwnerRepo::with(prop)),
1095        );
1096
1097        uc.create_reminder(create_dto(
1098            org_id,
1099            expense_id,
1100            owner_id,
1101            ReminderLevel::FirstReminder,
1102            17,
1103        ))
1104        .await
1105        .expect("première relance acceptée");
1106
1107        let err = uc
1108            .create_reminder(create_dto(
1109                org_id,
1110                expense_id,
1111                owner_id,
1112                ReminderLevel::FirstReminder,
1113                17,
1114            ))
1115            .await
1116            .expect_err("doublon refusé");
1117        assert!(format!("{err}").contains("already exists"), "{err}");
1118        assert_eq!(reminders.count(), 1);
1119
1120        // Le niveau SUIVANT reste possible : c'est l'escalade normale.
1121        uc.create_reminder(create_dto(
1122            org_id,
1123            expense_id,
1124            owner_id,
1125            ReminderLevel::SecondReminder,
1126            35,
1127        ))
1128        .await
1129        .expect("le second niveau doit rester ouvert");
1130        assert_eq!(reminders.count(), 2);
1131    }
1132
1133    /// @edge — un niveau ne peut pas être créé avant son délai légal.
1134    ///
1135    /// Une mise en demeure est un acte à J+60. L'émettre à J+20 exposerait
1136    /// l'ACP sur le fond comme sur la forme.
1137    #[tokio::test]
1138    async fn test_niveau_refuse_avant_son_delai() {
1139        let org_id = Uuid::new_v4();
1140        let depense = expense_impaye(org_id, Decimal::from(2000));
1141        let prop = proprietaire(org_id);
1142        let (expense_id, owner_id) = (depense.id, prop.id);
1143
1144        let uc = use_cases(
1145            Arc::new(MockPaymentReminderRepository::new()),
1146            Arc::new(MockExpenseRepo::with(depense)),
1147            Arc::new(MockOwnerRepo::with(prop)),
1148        );
1149
1150        let err = uc
1151            .create_reminder(create_dto(
1152                org_id,
1153                expense_id,
1154                owner_id,
1155                ReminderLevel::FormalNotice,
1156                20,
1157            ))
1158            .await
1159            .expect_err("mise en demeure prématurée refusée");
1160        assert!(format!("{err}").contains("60 days"), "{err}");
1161    }
1162
1163    /// L'escalade crée la relance du niveau suivant et marque la précédente.
1164    #[tokio::test]
1165    async fn test_escalade_cree_le_niveau_suivant() {
1166        let org_id = Uuid::new_v4();
1167        let depense = expense_impaye(org_id, Decimal::from(2000));
1168        let prop = proprietaire(org_id);
1169        let (expense_id, owner_id) = (depense.id, prop.id);
1170
1171        let reminders = Arc::new(MockPaymentReminderRepository::new());
1172        let uc = use_cases(
1173            reminders.clone(),
1174            Arc::new(MockExpenseRepo::with(depense)),
1175            Arc::new(MockOwnerRepo::with(prop)),
1176        );
1177
1178        // 35 jours de retard : le niveau suivant (J+30) est atteignable.
1179        let premiere = uc
1180            .create_reminder(create_dto(
1181                org_id,
1182                expense_id,
1183                owner_id,
1184                ReminderLevel::FirstReminder,
1185                35,
1186            ))
1187            .await
1188            .expect("première relance");
1189        let premiere_id = Uuid::parse_str(&premiere.id).unwrap();
1190
1191        let suivante = uc
1192            .escalate_reminder(premiere_id, EscalateReminderDto { reason: None })
1193            .await
1194            .expect("escalade acceptée")
1195            .expect("une relance est renvoyée");
1196
1197        assert_eq!(reminders.count(), 2, "l'escalade crée le niveau suivant");
1198        assert_eq!(suivante.level, ReminderLevel::SecondReminder);
1199        assert_eq!(
1200            reminders.get(premiere_id).unwrap().status,
1201            ReminderStatus::Escalated,
1202            "la relance d'origine doit être marquée escaladée"
1203        );
1204    }
1205
1206    /// @edge — une escalade prématurée ne laisse pas le dossier à l'abandon.
1207    ///
1208    /// Défaut d'ordonnancement trouvé en écrivant ces tests : le statut
1209    /// `Escalated` était PERSISTÉ avant que le niveau suivant ne soit validé.
1210    /// Sur un premier rappel à J+17, la relance ferme (J+30) est refusée par le
1211    /// domaine — mais la relance d'origine restait marquée escaladée, sans
1212    /// successeur, définitivement bloquée.
1213    ///
1214    /// Personne ne l'aurait vu : `process_automatic_escalations`, appelé par
1215    /// cron, journalise l'erreur sur stderr et poursuit sa boucle.
1216    #[tokio::test]
1217    async fn test_escalade_prematuree_ne_modifie_rien() {
1218        let org_id = Uuid::new_v4();
1219        let depense = expense_impaye(org_id, Decimal::from(2000));
1220        let prop = proprietaire(org_id);
1221        let (expense_id, owner_id) = (depense.id, prop.id);
1222
1223        let reminders = Arc::new(MockPaymentReminderRepository::new());
1224        let uc = use_cases(
1225            reminders.clone(),
1226            Arc::new(MockExpenseRepo::with(depense)),
1227            Arc::new(MockOwnerRepo::with(prop)),
1228        );
1229
1230        let premiere = uc
1231            .create_reminder(create_dto(
1232                org_id,
1233                expense_id,
1234                owner_id,
1235                ReminderLevel::FirstReminder,
1236                17,
1237            ))
1238            .await
1239            .expect("premier rappel à J+17");
1240        let id = Uuid::parse_str(&premiere.id).unwrap();
1241
1242        let err = uc
1243            .escalate_reminder(id, EscalateReminderDto { reason: None })
1244            .await
1245            .expect_err("le niveau suivant n'est pas encore atteignable");
1246        assert!(format!("{err}").contains("30 days"), "{err}");
1247
1248        // L'invariant : rien n'a bougé.
1249        assert_eq!(reminders.count(), 1, "aucune relance fantôme");
1250        assert_eq!(
1251            reminders.get(id).unwrap().status,
1252            ReminderStatus::Pending,
1253            "la relance d'origine ne doit PAS rester marquée escaladée sans successeur"
1254        );
1255    }
1256
1257    /// @edge — l'escalade s'arrête à la mise en demeure.
1258    ///
1259    /// Au-delà, c'est l'huissier : une procédure judiciaire, pas une relance
1260    /// de plus que le système émettrait tout seul.
1261    #[tokio::test]
1262    async fn test_escalade_sarrete_a_la_mise_en_demeure() {
1263        let org_id = Uuid::new_v4();
1264        let depense = expense_impaye(org_id, Decimal::from(2000));
1265        let prop = proprietaire(org_id);
1266        let (expense_id, owner_id) = (depense.id, prop.id);
1267
1268        let reminders = Arc::new(MockPaymentReminderRepository::new());
1269        let uc = use_cases(
1270            reminders.clone(),
1271            Arc::new(MockExpenseRepo::with(depense)),
1272            Arc::new(MockOwnerRepo::with(prop)),
1273        );
1274
1275        let mise_en_demeure = uc
1276            .create_reminder(create_dto(
1277                org_id,
1278                expense_id,
1279                owner_id,
1280                ReminderLevel::FormalNotice,
1281                70,
1282            ))
1283            .await
1284            .expect("mise en demeure");
1285        let id = Uuid::parse_str(&mise_en_demeure.id).unwrap();
1286
1287        uc.escalate_reminder(id, EscalateReminderDto { reason: None })
1288            .await
1289            .expect("escalade acceptée");
1290
1291        assert_eq!(
1292            reminders.count(),
1293            1,
1294            "aucun niveau au-delà de la mise en demeure ne doit être créé"
1295        );
1296        assert_eq!(reminders.get(id).unwrap().status, ReminderStatus::Escalated);
1297    }
1298
1299    /// @security — une relance payée ou annulée ne s'escalade pas.
1300    #[tokio::test]
1301    async fn test_escalade_refusee_apres_paiement() {
1302        let org_id = Uuid::new_v4();
1303        let depense = expense_impaye(org_id, Decimal::from(2000));
1304        let prop = proprietaire(org_id);
1305        let (expense_id, owner_id) = (depense.id, prop.id);
1306
1307        let reminders = Arc::new(MockPaymentReminderRepository::new());
1308        let uc = use_cases(
1309            reminders.clone(),
1310            Arc::new(MockExpenseRepo::with(depense)),
1311            Arc::new(MockOwnerRepo::with(prop)),
1312        );
1313
1314        let relance = uc
1315            .create_reminder(create_dto(
1316                org_id,
1317                expense_id,
1318                owner_id,
1319                ReminderLevel::FirstReminder,
1320                17,
1321            ))
1322            .await
1323            .expect("relance");
1324        let id = Uuid::parse_str(&relance.id).unwrap();
1325
1326        uc.mark_as_paid(id).await.expect("marquage payé");
1327
1328        let err = uc
1329            .escalate_reminder(id, EscalateReminderDto { reason: None })
1330            .await
1331            .expect_err("escalader un dossier soldé doit être refusé");
1332        assert!(format!("{err}").contains("Cannot escalate"), "{err}");
1333        assert_eq!(
1334            reminders.count(),
1335            1,
1336            "aucune relance supplémentaire ne doit partir"
1337        );
1338    }
1339
1340    /// Non-régression F10 — les statistiques doivent distinguer les statuts.
1341    ///
1342    /// L'interface affichait « relances actives : 0 » en présence d'une
1343    /// relance en attente, parce qu'elle ne comptait QUE le statut `Sent`.
1344    /// Le correctif est côté interface, mais il repose sur le fait que
1345    /// `status_counts` remonte bien chaque statut séparément — ce que ce test
1346    /// verrouille.
1347    #[tokio::test]
1348    async fn test_les_statistiques_distinguent_les_statuts() {
1349        let org_id = Uuid::new_v4();
1350        let depense = expense_impaye(org_id, Decimal::from(2000));
1351        let prop = proprietaire(org_id);
1352        let (expense_id, owner_id) = (depense.id, prop.id);
1353
1354        let reminders = Arc::new(MockPaymentReminderRepository::new());
1355        let uc = use_cases(
1356            reminders.clone(),
1357            Arc::new(MockExpenseRepo::with(depense)),
1358            Arc::new(MockOwnerRepo::with(prop)),
1359        );
1360
1361        // Une relance en attente, une envoyée.
1362        uc.create_reminder(create_dto(
1363            org_id,
1364            expense_id,
1365            owner_id,
1366            ReminderLevel::FirstReminder,
1367            17,
1368        ))
1369        .await
1370        .expect("relance 1");
1371        let deuxieme = uc
1372            .create_reminder(create_dto(
1373                org_id,
1374                expense_id,
1375                owner_id,
1376                ReminderLevel::SecondReminder,
1377                35,
1378            ))
1379            .await
1380            .expect("relance 2");
1381        uc.mark_as_sent(
1382            Uuid::parse_str(&deuxieme.id).unwrap(),
1383            MarkReminderSentDto {
1384                pdf_path: Some("/tmp/relance.pdf".to_string()),
1385            },
1386        )
1387        .await
1388        .expect("envoi");
1389
1390        let stats = uc
1391            .get_recovery_stats(org_id)
1392            .await
1393            .expect("statistiques disponibles");
1394
1395        let compte = |s: ReminderStatus| -> i64 {
1396            stats
1397                .status_counts
1398                .iter()
1399                .find(|c| c.status == s)
1400                .map(|c| c.count)
1401                .unwrap_or(0)
1402        };
1403        assert_eq!(compte(ReminderStatus::Pending), 1);
1404        assert_eq!(compte(ReminderStatus::Sent), 1);
1405        // Le cœur du constat F10 : ne compter que `Sent` masque la moitié du
1406        // recouvrement en cours.
1407        assert_eq!(
1408            compte(ReminderStatus::Pending) + compte(ReminderStatus::Sent),
1409            2,
1410            "les deux relances sont actives, quel que soit leur statut d'envoi"
1411        );
1412    }
1413
1414    /// @edge — une dépense introuvable ne crée pas de relance fantôme.
1415    #[tokio::test]
1416    async fn test_relance_refusee_si_depense_introuvable() {
1417        let org_id = Uuid::new_v4();
1418        let reminders = Arc::new(MockPaymentReminderRepository::new());
1419        let uc = use_cases(
1420            reminders.clone(),
1421            Arc::new(MockExpenseRepo::new()),
1422            Arc::new(MockOwnerRepo::new()),
1423        );
1424
1425        let err = uc
1426            .create_reminder(create_dto(
1427                org_id,
1428                Uuid::new_v4(),
1429                Uuid::new_v4(),
1430                ReminderLevel::FirstReminder,
1431                17,
1432            ))
1433            .await
1434            .expect_err("dépense inexistante refusée");
1435        assert!(format!("{err}").contains("not found"), "{err}");
1436        assert_eq!(reminders.count(), 0);
1437    }
1438
1439    /// L'annulation n'est possible qu'avant envoi.
1440    ///
1441    /// Une lettre partie ne se rappelle pas : le dossier se solde, il ne
1442    /// s'efface pas.
1443    #[tokio::test]
1444    async fn test_annulation_impossible_apres_envoi() {
1445        let org_id = Uuid::new_v4();
1446        let depense = expense_impaye(org_id, Decimal::from(2000));
1447        let prop = proprietaire(org_id);
1448        let (expense_id, owner_id) = (depense.id, prop.id);
1449
1450        let uc = use_cases(
1451            Arc::new(MockPaymentReminderRepository::new()),
1452            Arc::new(MockExpenseRepo::with(depense)),
1453            Arc::new(MockOwnerRepo::with(prop)),
1454        );
1455
1456        let relance = uc
1457            .create_reminder(create_dto(
1458                org_id,
1459                expense_id,
1460                owner_id,
1461                ReminderLevel::FirstReminder,
1462                17,
1463            ))
1464            .await
1465            .expect("relance");
1466        let id = Uuid::parse_str(&relance.id).unwrap();
1467
1468        // Avant envoi : accepté.
1469        let annulee = uc
1470            .cancel_reminder(
1471                id,
1472                CancelReminderDto {
1473                    reason: "Paiement reçu entre-temps".to_string(),
1474                },
1475            )
1476            .await
1477            .expect("annulation avant envoi acceptée");
1478        assert_eq!(annulee.status, ReminderStatus::Cancelled);
1479    }
1480}