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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 reminder.can_escalate()?;
249
250 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 #[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 #[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 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 #[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 #[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 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 #[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 #[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 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 #[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 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 #[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 #[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 #[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 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 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 #[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 #[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 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}