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::ports::{ExpenseRepository, OwnerRepository, PaymentReminderRepository};
8use crate::domain::entities::{PaymentReminder, PaymentStatus, ReminderStatus};
9use chrono::{DateTime, Utc};
10use std::sync::Arc;
11use uuid::Uuid;
12
13pub struct PaymentReminderUseCases {
14    reminder_repository: Arc<dyn PaymentReminderRepository>,
15    expense_repository: Arc<dyn ExpenseRepository>,
16    owner_repository: Arc<dyn OwnerRepository>,
17}
18
19impl PaymentReminderUseCases {
20    pub fn new(
21        reminder_repository: Arc<dyn PaymentReminderRepository>,
22        expense_repository: Arc<dyn ExpenseRepository>,
23        owner_repository: Arc<dyn OwnerRepository>,
24    ) -> Self {
25        Self {
26            reminder_repository,
27            expense_repository,
28            owner_repository,
29        }
30    }
31
32    /// Helper to enrich a reminder DTO with owner information
33    async fn enrich_with_owner_info(
34        &self,
35        mut dto: PaymentReminderResponseDto,
36    ) -> Result<PaymentReminderResponseDto, String> {
37        let owner_id =
38            Uuid::parse_str(&dto.owner_id).map_err(|_| "Invalid owner_id format".to_string())?;
39
40        if let Ok(Some(owner)) = self.owner_repository.find_by_id(owner_id).await {
41            dto.owner_name = Some(owner.full_name());
42            dto.owner_email = Some(owner.email.clone());
43        }
44
45        Ok(dto)
46    }
47
48    /// Create a new payment reminder
49    pub async fn create_reminder(
50        &self,
51        dto: CreatePaymentReminderDto,
52    ) -> Result<PaymentReminderResponseDto, String> {
53        let organization_id = Uuid::parse_str(&dto.organization_id)
54            .map_err(|_| "Invalid organization_id format".to_string())?;
55        let expense_id = Uuid::parse_str(&dto.expense_id)
56            .map_err(|_| "Invalid expense_id format".to_string())?;
57        let owner_id =
58            Uuid::parse_str(&dto.owner_id).map_err(|_| "Invalid owner_id format".to_string())?;
59
60        let due_date = DateTime::parse_from_rfc3339(&dto.due_date)
61            .map_err(|_| "Invalid date format".to_string())?
62            .with_timezone(&Utc);
63
64        // Verify expense exists and is not paid
65        let expense = self
66            .expense_repository
67            .find_by_id(expense_id)
68            .await?
69            .ok_or_else(|| "Expense not found".to_string())?;
70
71        if expense.payment_status == PaymentStatus::Paid {
72            return Err("Cannot create reminder for paid expense".to_string());
73        }
74
75        // Check if reminder already exists for this expense and owner at this level
76        let existing_reminders = self.reminder_repository.find_by_expense(expense_id).await?;
77
78        if existing_reminders.iter().any(|r| {
79            r.owner_id == owner_id
80                && r.level == dto.level
81                && r.status != ReminderStatus::Cancelled
82                && r.status != ReminderStatus::Paid
83        }) {
84            return Err(format!(
85                "Active reminder already exists for this expense at {:?} level",
86                dto.level
87            ));
88        }
89
90        let reminder = PaymentReminder::new(
91            organization_id,
92            expense_id,
93            owner_id,
94            dto.level,
95            dto.amount_owed,
96            due_date,
97            dto.days_overdue,
98        )?;
99
100        let created = self.reminder_repository.create(&reminder).await?;
101        Ok(created.into())
102    }
103
104    /// Get reminder by ID
105    pub async fn get_reminder(
106        &self,
107        id: Uuid,
108    ) -> Result<Option<PaymentReminderResponseDto>, String> {
109        let reminder = self.reminder_repository.find_by_id(id).await?;
110        Ok(reminder.map(|r| r.into()))
111    }
112
113    /// List all reminders for an expense
114    pub async fn list_by_expense(
115        &self,
116        expense_id: Uuid,
117    ) -> Result<Vec<PaymentReminderResponseDto>, String> {
118        let reminders = self.reminder_repository.find_by_expense(expense_id).await?;
119        Ok(reminders.into_iter().map(|r| r.into()).collect())
120    }
121
122    /// List all reminders for an owner
123    pub async fn list_by_owner(
124        &self,
125        owner_id: Uuid,
126    ) -> Result<Vec<PaymentReminderResponseDto>, String> {
127        let reminders = self.reminder_repository.find_by_owner(owner_id).await?;
128        Ok(reminders.into_iter().map(|r| r.into()).collect())
129    }
130
131    /// List all reminders for an organization
132    pub async fn list_by_organization(
133        &self,
134        organization_id: Uuid,
135    ) -> Result<Vec<PaymentReminderResponseDto>, String> {
136        let reminders = self
137            .reminder_repository
138            .find_by_organization(organization_id)
139            .await?;
140
141        // Enrich each reminder with owner information
142        let mut enriched_reminders = Vec::new();
143        for reminder in reminders {
144            let dto: PaymentReminderResponseDto = reminder.into();
145            let enriched = self.enrich_with_owner_info(dto).await?;
146            enriched_reminders.push(enriched);
147        }
148
149        Ok(enriched_reminders)
150    }
151
152    /// List active (non-paid, non-cancelled) reminders for an owner
153    pub async fn list_active_by_owner(
154        &self,
155        owner_id: Uuid,
156    ) -> Result<Vec<PaymentReminderResponseDto>, String> {
157        let reminders = self
158            .reminder_repository
159            .find_active_by_owner(owner_id)
160            .await?;
161        Ok(reminders.into_iter().map(|r| r.into()).collect())
162    }
163
164    /// Mark reminder as sent
165    pub async fn mark_as_sent(
166        &self,
167        id: Uuid,
168        dto: MarkReminderSentDto,
169    ) -> Result<PaymentReminderResponseDto, String> {
170        let mut reminder = self
171            .reminder_repository
172            .find_by_id(id)
173            .await?
174            .ok_or_else(|| "Reminder not found".to_string())?;
175
176        reminder.mark_as_sent(dto.pdf_path)?;
177
178        let updated = self.reminder_repository.update(&reminder).await?;
179        Ok(updated.into())
180    }
181
182    /// Mark reminder as opened (email opened)
183    pub async fn mark_as_opened(&self, id: Uuid) -> Result<PaymentReminderResponseDto, String> {
184        let mut reminder = self
185            .reminder_repository
186            .find_by_id(id)
187            .await?
188            .ok_or_else(|| "Reminder not found".to_string())?;
189
190        reminder.mark_as_opened()?;
191
192        let updated = self.reminder_repository.update(&reminder).await?;
193        Ok(updated.into())
194    }
195
196    /// Mark reminder as paid
197    pub async fn mark_as_paid(&self, id: Uuid) -> Result<PaymentReminderResponseDto, String> {
198        let mut reminder = self
199            .reminder_repository
200            .find_by_id(id)
201            .await?
202            .ok_or_else(|| "Reminder not found".to_string())?;
203
204        reminder.mark_as_paid()?;
205
206        let updated = self.reminder_repository.update(&reminder).await?;
207        Ok(updated.into())
208    }
209
210    /// Cancel a reminder
211    pub async fn cancel_reminder(
212        &self,
213        id: Uuid,
214        dto: CancelReminderDto,
215    ) -> Result<PaymentReminderResponseDto, String> {
216        let mut reminder = self
217            .reminder_repository
218            .find_by_id(id)
219            .await?
220            .ok_or_else(|| "Reminder not found".to_string())?;
221
222        reminder.cancel(dto.reason)?;
223
224        let updated = self.reminder_repository.update(&reminder).await?;
225        Ok(updated.into())
226    }
227
228    /// Escalate a reminder to next level
229    pub async fn escalate_reminder(
230        &self,
231        id: Uuid,
232        _dto: EscalateReminderDto,
233    ) -> Result<Option<PaymentReminderResponseDto>, String> {
234        let mut reminder = self
235            .reminder_repository
236            .find_by_id(id)
237            .await?
238            .ok_or_else(|| "Reminder not found".to_string())?;
239
240        let next_level = reminder.escalate()?;
241
242        let updated = self.reminder_repository.update(&reminder).await?;
243
244        // If there's a next level, optionally create a new reminder automatically
245        if let Some(level) = next_level {
246            // Calculate new days overdue based on next level
247            let days_overdue = (Utc::now() - reminder.due_date).num_days();
248
249            // Create next level reminder
250            let next_reminder = PaymentReminder::new(
251                reminder.organization_id,
252                reminder.expense_id,
253                reminder.owner_id,
254                level,
255                reminder.amount_owed,
256                reminder.due_date,
257                days_overdue,
258            )?;
259
260            let created = self.reminder_repository.create(&next_reminder).await?;
261            return Ok(Some(created.into()));
262        }
263
264        Ok(Some(updated.into()))
265    }
266
267    /// Add tracking number to a reminder (for registered letters)
268    pub async fn add_tracking_number(
269        &self,
270        id: Uuid,
271        dto: AddTrackingNumberDto,
272    ) -> Result<PaymentReminderResponseDto, String> {
273        let mut reminder = self
274            .reminder_repository
275            .find_by_id(id)
276            .await?
277            .ok_or_else(|| "Reminder not found".to_string())?;
278
279        reminder.set_tracking_number(dto.tracking_number)?;
280
281        let updated = self.reminder_repository.update(&reminder).await?;
282        Ok(updated.into())
283    }
284
285    /// Find all pending reminders (to be sent)
286    pub async fn find_pending_reminders(&self) -> Result<Vec<PaymentReminderResponseDto>, String> {
287        let reminders = self.reminder_repository.find_pending_reminders().await?;
288        Ok(reminders.into_iter().map(|r| r.into()).collect())
289    }
290
291    /// Find reminders needing escalation (sent >15 days ago)
292    pub async fn find_reminders_needing_escalation(
293        &self,
294    ) -> Result<Vec<PaymentReminderResponseDto>, String> {
295        let cutoff_date = Utc::now() - chrono::Duration::days(15);
296        let reminders = self
297            .reminder_repository
298            .find_reminders_needing_escalation(cutoff_date)
299            .await?;
300
301        // Filter to only those that actually need escalation
302        let needs_escalation: Vec<PaymentReminder> = reminders
303            .into_iter()
304            .filter(|r| r.needs_escalation(Utc::now()))
305            .collect();
306
307        Ok(needs_escalation.into_iter().map(|r| r.into()).collect())
308    }
309
310    /// Get payment recovery statistics for an organization
311    pub async fn get_recovery_stats(
312        &self,
313        organization_id: Uuid,
314    ) -> Result<PaymentRecoveryStatsDto, String> {
315        let (total_owed, total_penalties, level_counts) = self
316            .reminder_repository
317            .get_dashboard_stats(organization_id)
318            .await?;
319
320        let status_counts = self
321            .reminder_repository
322            .count_by_status(organization_id)
323            .await?;
324
325        Ok(PaymentRecoveryStatsDto {
326            total_owed,
327            total_penalties,
328            reminder_counts: level_counts
329                .into_iter()
330                .map(|(level, count)| ReminderLevelCountDto { level, count })
331                .collect(),
332            status_counts: status_counts
333                .into_iter()
334                .map(|(status, count)| ReminderStatusCountDto { status, count })
335                .collect(),
336        })
337    }
338
339    /// Find overdue expenses without reminders (for automated detection)
340    pub async fn find_overdue_expenses_without_reminders(
341        &self,
342        organization_id: Uuid,
343        min_days_overdue: i64,
344    ) -> Result<Vec<OverdueExpenseDto>, String> {
345        let results = self
346            .reminder_repository
347            .find_overdue_expenses_without_reminders(organization_id, min_days_overdue)
348            .await?;
349
350        Ok(results
351            .into_iter()
352            .map(|(expense_id, owner_id, days_overdue, amount)| {
353                OverdueExpenseDto::new(
354                    expense_id.to_string(),
355                    owner_id.to_string(),
356                    days_overdue,
357                    amount,
358                )
359            })
360            .collect())
361    }
362
363    /// Bulk create reminders for all overdue expenses
364    pub async fn bulk_create_reminders(
365        &self,
366        dto: BulkCreateRemindersDto,
367    ) -> Result<BulkCreateRemindersResponseDto, String> {
368        let organization_id = Uuid::parse_str(&dto.organization_id)
369            .map_err(|_| "Invalid organization_id format".to_string())?;
370
371        let overdue_list = self
372            .find_overdue_expenses_without_reminders(organization_id, dto.min_days_overdue)
373            .await?;
374
375        let mut created_count = 0;
376        let mut skipped_count = 0;
377        let mut errors = Vec::new();
378        let mut created_reminders = Vec::new();
379
380        for overdue in overdue_list {
381            let expense_id = Uuid::parse_str(&overdue.expense_id);
382            let owner_id = Uuid::parse_str(&overdue.owner_id);
383
384            if expense_id.is_err() || owner_id.is_err() {
385                errors.push(
386                    "Invalid UUID format for expense_id or owner_id in overdue item".to_string(),
387                );
388                skipped_count += 1;
389                continue;
390            }
391
392            let expense_id = expense_id.unwrap();
393            let owner_id = owner_id.unwrap();
394
395            // Get expense to get due date
396            let expense_result = self.expense_repository.find_by_id(expense_id).await;
397
398            match expense_result {
399                Ok(Some(expense)) => {
400                    let due_date = expense.expense_date;
401
402                    let create_dto = CreatePaymentReminderDto {
403                        organization_id: organization_id.to_string(),
404                        expense_id: expense_id.to_string(),
405                        owner_id: owner_id.to_string(),
406                        level: overdue.recommended_level,
407                        amount_owed: overdue.amount,
408                        due_date: due_date.to_rfc3339(),
409                        days_overdue: overdue.days_overdue,
410                    };
411
412                    match self.create_reminder(create_dto).await {
413                        Ok(reminder) => {
414                            created_count += 1;
415                            created_reminders.push(reminder);
416                        }
417                        Err(e) => {
418                            errors.push(format!(
419                                "Error creating reminder for expense {}: {}",
420                                expense_id, e
421                            ));
422                            skipped_count += 1;
423                        }
424                    }
425                }
426                Ok(None) => {
427                    errors.push(format!("Expense {} not found", expense_id));
428                    skipped_count += 1;
429                }
430                Err(e) => {
431                    errors.push(format!("Error fetching expense {}: {}", expense_id, e));
432                    skipped_count += 1;
433                }
434            }
435        }
436
437        Ok(BulkCreateRemindersResponseDto {
438            created_count,
439            skipped_count,
440            errors,
441            created_reminders,
442        })
443    }
444
445    /// Process automatic escalations (called by cron job)
446    pub async fn process_automatic_escalations(&self) -> Result<i32, String> {
447        let reminders = self.find_reminders_needing_escalation().await?;
448        let mut escalated_count = 0;
449
450        for reminder_dto in reminders {
451            let id =
452                Uuid::parse_str(&reminder_dto.id).map_err(|_| "Invalid reminder ID".to_string())?;
453
454            match self
455                .escalate_reminder(id, EscalateReminderDto { reason: None })
456                .await
457            {
458                Ok(_) => escalated_count += 1,
459                Err(e) => {
460                    eprintln!("Error escalating reminder {}: {}", id, e);
461                }
462            }
463        }
464
465        Ok(escalated_count)
466    }
467
468    /// Recalculate penalties for all active reminders (called periodically)
469    pub async fn recalculate_all_penalties(&self, organization_id: Uuid) -> Result<i32, String> {
470        let reminders = self
471            .reminder_repository
472            .find_by_organization_and_status(organization_id, ReminderStatus::Sent)
473            .await?;
474
475        let mut updated_count = 0;
476
477        for mut reminder in reminders {
478            let current_days = (Utc::now() - reminder.due_date).num_days();
479            if current_days != reminder.days_overdue {
480                reminder.recalculate_penalties(current_days);
481                self.reminder_repository.update(&reminder).await?;
482                updated_count += 1;
483            }
484        }
485
486        Ok(updated_count)
487    }
488
489    /// Delete a reminder
490    pub async fn delete_reminder(&self, id: Uuid) -> Result<bool, String> {
491        self.reminder_repository.delete(id).await
492    }
493}
494
495#[cfg(test)]
496mod tests {
497    use super::*;
498    use crate::application::ports::PaymentReminderRepository;
499    use crate::domain::entities::ReminderLevel;
500    use async_trait::async_trait;
501    use std::collections::HashMap;
502    use std::sync::Mutex;
503
504    // Mock repository for testing
505    #[allow(dead_code)]
506    struct MockPaymentReminderRepository {
507        reminders: Mutex<HashMap<Uuid, PaymentReminder>>,
508    }
509
510    #[allow(dead_code)]
511    impl MockPaymentReminderRepository {
512        fn new() -> Self {
513            Self {
514                reminders: Mutex::new(HashMap::new()),
515            }
516        }
517    }
518
519    #[async_trait]
520    impl PaymentReminderRepository for MockPaymentReminderRepository {
521        async fn create(&self, reminder: &PaymentReminder) -> Result<PaymentReminder, String> {
522            let mut reminders = self.reminders.lock().unwrap();
523            reminders.insert(reminder.id, reminder.clone());
524            Ok(reminder.clone())
525        }
526
527        async fn find_by_id(&self, id: Uuid) -> Result<Option<PaymentReminder>, String> {
528            let reminders = self.reminders.lock().unwrap();
529            Ok(reminders.get(&id).cloned())
530        }
531
532        async fn find_by_expense(&self, expense_id: Uuid) -> Result<Vec<PaymentReminder>, String> {
533            let reminders = self.reminders.lock().unwrap();
534            Ok(reminders
535                .values()
536                .filter(|r| r.expense_id == expense_id)
537                .cloned()
538                .collect())
539        }
540
541        async fn find_by_owner(&self, owner_id: Uuid) -> Result<Vec<PaymentReminder>, String> {
542            let reminders = self.reminders.lock().unwrap();
543            Ok(reminders
544                .values()
545                .filter(|r| r.owner_id == owner_id)
546                .cloned()
547                .collect())
548        }
549
550        async fn find_by_organization(
551            &self,
552            organization_id: Uuid,
553        ) -> Result<Vec<PaymentReminder>, String> {
554            let reminders = self.reminders.lock().unwrap();
555            Ok(reminders
556                .values()
557                .filter(|r| r.organization_id == organization_id)
558                .cloned()
559                .collect())
560        }
561
562        async fn find_by_status(
563            &self,
564            status: ReminderStatus,
565        ) -> Result<Vec<PaymentReminder>, String> {
566            let reminders = self.reminders.lock().unwrap();
567            Ok(reminders
568                .values()
569                .filter(|r| r.status == status)
570                .cloned()
571                .collect())
572        }
573
574        async fn find_by_organization_and_status(
575            &self,
576            organization_id: Uuid,
577            status: ReminderStatus,
578        ) -> Result<Vec<PaymentReminder>, String> {
579            let reminders = self.reminders.lock().unwrap();
580            Ok(reminders
581                .values()
582                .filter(|r| r.organization_id == organization_id && r.status == status)
583                .cloned()
584                .collect())
585        }
586
587        async fn find_pending_reminders(&self) -> Result<Vec<PaymentReminder>, String> {
588            self.find_by_status(ReminderStatus::Pending).await
589        }
590
591        async fn find_reminders_needing_escalation(
592            &self,
593            _cutoff_date: DateTime<Utc>,
594        ) -> Result<Vec<PaymentReminder>, String> {
595            Ok(vec![])
596        }
597
598        async fn find_latest_by_expense(
599            &self,
600            _expense_id: Uuid,
601        ) -> Result<Option<PaymentReminder>, String> {
602            Ok(None)
603        }
604
605        async fn find_active_by_owner(
606            &self,
607            _owner_id: Uuid,
608        ) -> Result<Vec<PaymentReminder>, String> {
609            Ok(vec![])
610        }
611
612        async fn count_by_status(
613            &self,
614            _organization_id: Uuid,
615        ) -> Result<Vec<(ReminderStatus, i64)>, String> {
616            Ok(vec![])
617        }
618
619        async fn get_total_owed_by_organization(
620            &self,
621            _organization_id: Uuid,
622        ) -> Result<f64, String> {
623            Ok(0.0)
624        }
625
626        async fn get_total_penalties_by_organization(
627            &self,
628            _organization_id: Uuid,
629        ) -> Result<f64, String> {
630            Ok(0.0)
631        }
632
633        async fn find_overdue_expenses_without_reminders(
634            &self,
635            _organization_id: Uuid,
636            _min_days_overdue: i64,
637        ) -> Result<Vec<(Uuid, Uuid, i64, f64)>, String> {
638            Ok(vec![])
639        }
640
641        async fn update(&self, reminder: &PaymentReminder) -> Result<PaymentReminder, String> {
642            let mut reminders = self.reminders.lock().unwrap();
643            reminders.insert(reminder.id, reminder.clone());
644            Ok(reminder.clone())
645        }
646
647        async fn delete(&self, id: Uuid) -> Result<bool, String> {
648            let mut reminders = self.reminders.lock().unwrap();
649            Ok(reminders.remove(&id).is_some())
650        }
651
652        async fn get_dashboard_stats(
653            &self,
654            _organization_id: Uuid,
655        ) -> Result<(f64, f64, Vec<(ReminderLevel, i64)>), String> {
656            Ok((0.0, 0.0, vec![]))
657        }
658    }
659
660    // TODO: Add more comprehensive tests
661}